Python版　Aterm検索ツール

# Python版　Aterm検索ツール
## 1.説明
　NECのWifiルータAtermシリーズのためのツール「Aterm検索ツール」を使おうとしたときに、htaファイルでIEのインストールが必要だと知ってたまげました。「いまさらIEなんていれねーよ」って方や「Mac or Linuxなんですけど」って方のため、あと自分の勉強のため、htaからpythonに置き換えました。詳しい解説は後につけますので、まずはコードをどうぞ。
  
 (追記)改良版を投稿しました。

https://qiita.com/Dodo1985/items/c59cab25bcf64d10303a

## 2.Python版 Aterm検索ツール コード
開発環境:   
　Windows11 Pro 24H2
　python 3.10.6
動作確認環境: 
　Windows11 Pro 24h2

<details><summary>_/_/_/コード折りたたみ_/_/_/</summary>

```python:AtermSearch.py
# ネットワークモジュール
import psutil
import socket
import netifaces
import ipaddress
# 非同期処理モジュール
import threading
import asyncio
import aiohttp
# UIモジュール
import tkinter as tk
from tkinter import font
from tkinter import ttk     # tkとの混同に注意、動作は違う
from tkinter import messagebox
import webbrowser

# グローバル定数
VERSION = "2.0"
MODEARRAY = [
    "ブリッジ", "PPPoEルータ", "ローカルルータ",
    "無線LAN子機", "無線LAN中継機", "MAP-E",
    "464XLAT", "DS-Lite", "固定IP1", "複数固定IP", "メッシュ中継機"
]
MAX_PREFIX = 24         # MAX_DEVICE_NUM代替
TIMEOUT = 5
# グローバル変数
# ipAddress,ipSubnet,si,t不要
search_button = None    # sb
status_str = None       # ss
search_table = None     # st


# スクロールバー付テーブルクラス
# appの子要素canvasがscrollbarとリンクし、canvasの子要素のtable内をスクロール領域とする
class AtermTable:
    def __init__(self, app, row):
        self.labels = []

        style = ttk.Style()
        style.configure("White.TFrame", background="#ffffff")
        style.configure("White.TLabel", background="#ffffff")

        self.canvas = tk.Canvas(app, bg="#ffffff")
        self.canvas.grid(row=row, column=0, sticky="nsew")

        self.scrollbar = ttk.Scrollbar(
            app, orient="vertical", command=self.canvas.yview
        )
        self.scrollbar.grid(row=row, column=1, sticky="ns")
        # canvasとscrollbarをリンク
        self.canvas.configure(yscrollcommand=self.scrollbar.set)

        self.table = ttk.Frame(self.canvas, style="White.TFrame")
        self.table.bind(
            "<Configure>",
            lambda e: self.canvas.configure(
                scrollregion=self.canvas.bbox("all")
            )
        )

        self.canvas.create_window((0, 0), window=self.table, anchor="nw")

        custom_font = font.Font(size=14, weight="bold")
        ttk.Label(
            self.table, text="機種名", style="White.TLabel",
            font=custom_font, anchor="center"
        ).grid(row=0, column=0, padx=40)
        ttk.Label(
            self.table, text="動作モード", style="White.TLabel",
            font=custom_font, anchor="center"
        ).grid(row=0, column=1, padx=40)
        ttk.Label(
            self.table, text="クイック設定Web", style="White.TLabel",
            font=custom_font, anchor="center"
        ).grid(row=0, column=2, padx=40)

        self.ttk_row_num = 1

    # AddTable関数相当
    def add_table(self, string1, string2, string3, link):
        custom_font = font.Font(size=14)
        name_label = ttk.Label(
            self.table, text=string1, style="White.TLabel",
            font=custom_font, anchor="center"
        )
        name_label.grid(row=self.ttk_row_num, column=0, padx=20)
        self.labels.append(name_label)

        mode_label = ttk.Label(
            self.table, text=string2, style="White.TLabel",
            font=custom_font, anchor="center"
        )
        mode_label.grid(row=self.ttk_row_num, column=1, padx=20)
        self.labels.append(mode_label)

        hyperlink_label = HyperlinkLabel(
            self.table, text=string3, url=link,
            style="White.TLabel", anchor="center"
        )
        hyperlink_label.grid(row=self.ttk_row_num, column=2, padx=20)
        self.labels.append(hyperlink_label)
        self.ttk_row_num += 1

    # ClearTable関数相当
    def clear_label(self):
        for label in self.labels:
            if label is not None:
                label.grid_forget()
        self.ttk_row_num = 1


# ハイパーリンク作成クラス
# ttk.Labelクラス継承
class HyperlinkLabel(ttk.Label):
    def __init__(self, master=None, text="", url="", **kwargs):
        super().__init__(master, text=text, **kwargs)
        self.url = url
        self.configure(
            foreground="#176eb3", cursor="hand2",
            font=("Helvetica", 14, "underline")
        )
        self.bind("<Button-1>", self.open_link)

    def open_link(self, event):
        webbrowser.open(self.url)


# GetLocalIP関数相当
# return:ipaddressオブジェクト network
def get_local_ip():
    network = None

    #  デフォルトゲートウェイの取得
    try:
        gateways = netifaces.gateways()
        default_gateway = gateways.get(netifaces.AF_INET)
        if default_gateway:
            gw = ipaddress.ip_address(default_gateway[0][0])
        else:
            raise AttributeError("Default gateway not found")
        
    except AttributeError as e:
        messagebox.showerror(
            f"エラー:{e}",
            "デフォルトゲートウェイ情報の取得に失敗しました。"
            "本ツールを終了し、ネットワークに接続していることを確認後、再度実行してください。\n"
        )
        return

    net_info = psutil.net_if_addrs()
    net_stats = psutil.net_if_stats()

    for interface, stats in net_stats.items():
        if stats.isup:  # インターフェースが有効であることを確認
            for addr in net_info.get(interface, []):
                if addr.family == socket.AF_INET:
                    network = ipaddress.ip_network(
                        f"{addr.address}/{addr.netmask}", strict=False
                    )
                    # デフォルトゲートウェイを含むプライベートネットワークのみを成功とする
                    if network.is_private and gw in network:
                        return network
                    else:
                        network = None


# XHR_GetSysMode関数相当
# テーブルへの書き込みは元コードもここ
async def get_sys_mode(session, ip, product_name):
    global search_table
    url = f"http://{ip}/aterm_httpif.cgi/getparamcmd_no_auth"
    data = "REQ_ID=SYS_MODE_GET"
    headers = {'Content-Type': 'application/x-www-form-urlencoded'}

    async with session.post(
        url=url, data=data, headers=headers, timeout=TIMEOUT
    ) as response:
        if response.status == 200:
            response_text = await response.text()
            response_text = response_text.strip()

            # modeの数字はAtermの機械依存なので想定しない数値が来た場合のケア
            mode = response_text.split("=")[1]
            try:
                mode_name = MODEARRAY[int(mode)].strip()
            except IndexError:
                mode_name = "Unknown Mode"

            search_table.add_table(
                product_name, mode_name, ip, f"http://{ip}"
            )


# XHR_GetProductName関数相当
async def get_product_name(session, ip):
    global search_table
    url = f"http://{ip}/aterm_httpif.cgi/getparamcmd_no_auth"
    data = "REQ_ID=PRODUCT_NAME_GET"
    headers = {'Content-Type': 'application/x-www-form-urlencoded'}

    async with session.post(
        url=url, data=data, headers=headers, timeout=TIMEOUT
    ) as response:
        if response.status == 200:
            response_text = await response.text()
            response_text = response_text.strip()

            # response_textは適切な処理の場合"PRODUCT_NAME=製品名"を持つ
            if response_text.split("=")[0] == "PRODUCT_NAME":
                await get_sys_mode(
                    session, ip, response_text.split("=")[1]
                )


# AtermSearchFin関数相当
def aterm_serach_fin(network):
    global search_button
    global status_str

    status_str.config(
        text=f"{(network.network_address + 1)}""-"
        f"{(network.broadcast_address-1)} 検索完了"
    )

    messagebox.showinfo("Aterm検索ツール", "検索完了")

    search_button.config(state=tk.ACTIVE)


# AtermSearchIdx関数相当
# 元コードの再帰的実行ではなく並行実行するので進捗表示を変更
async def aterm_search_index(network):
    global search_table
    async with aiohttp.ClientSession() as session:
        tasks = []
        # networkに登録されたネットワークのホストアドレスに限ったIPv4アドレス配列
        for ip in network.hosts():
            ip = str(ip)    # IPv4アドレス型から文字列型に変換
            tasks.append(get_product_name(session, ip))
        # 登録された非同期処理を並行実行し全て終わるまで待つ
        # AtermSearchWaiting関数相当
        await asyncio.gather(*tasks, return_exceptions=True)

        aterm_serach_fin(network)


# SetMaxDevice関数相当
def set_max_device(network):
    if network.prefixlen < MAX_PREFIX:
        new_prefix = MAX_PREFIX
        network = ipaddress.IPv4Network(
            (int(network.network_address), new_prefix)
        )
        messagebox.showinfo(
            "Aterm検索ツール",
            "検索対象のネットワーク範囲が大きいため、検索できない場合があります。"
        )
    return network


# StartAtermSearch関数相当
def start_aterm_search():
    global search_table
    global search_button
    global status_str
    # ネットワーク情報の取得
    network = get_local_ip()
    if network is not None:
        network = set_max_device(network)
        search_table.clear_label()
        search_button.config(state=tk.DISABLED)
        status_str.config(
            text=f"{(network.network_address + 1)}""-"
            f"{(network.broadcast_address-1)} 検索中..."
        )
        # 非同期タスクの開始
        loop = asyncio.new_event_loop()
        threading.Thread(
            target=loop.run_until_complete,
            args=(aterm_search_index(network),)     # 単一の値を持つタプル型","は必須
        ).start()

    else:
        messagebox.showerror(
            "エラー",
            "ネットワーク情報の取得に失敗しました。"
            "本ツールを終了し、Atermに接続していることを確認後、再度実行してください。\n"
            "再実行後も同様なエラーが表示される場合は、"
            "ご利用のパソコンにIPアドレス、ネットマスクがアサインされているかご確認ください。\n"
        )


if __name__ == "__main__":
    app = tk.Tk()
    app.title("Aterm検索ツール")
    app.geometry("800x640")
    app.config(bg="#ffffff")
    app.grid_columnconfigure(0, weight=1)
    app.grid_rowconfigure(6, weight=1)

    h1 = tk.Label(app, text="Aterm検索ツール", padx=20, pady=10, anchor="w")
    h1.grid(row=0, columnspan=2, sticky="ew")
    h1.configure(
        bg="#176eb3", fg="#ffffff",
        font=font.Font(size=16, weight="bold")
    )

    line = tk.Label(app, text=" ", bg="#5fb6f5", font=font.Font(size=1))
    line.grid(row=1, columnspan=2, sticky="ew")

    copy_r = tk.Label(
        app, text="元ネタ NEC Platforms, Ltd. 2001-2020", anchor="e"
    )
    copy_r.grid(row=2, columnspan=2, sticky="ew")
    copy_r.configure(bg="#ffffff", font=font.Font(size=12, weight="bold"))

    ver_str = tk.Label(app, text="Ver."+VERSION, anchor="e")
    ver_str.grid(row=3, columnspan=2, sticky="e")
    ver_str.configure(bg="#ffffff", font=font.Font(size=12, weight="bold"))

    search_button = tk.Button(
        app, text="Aterm検索", command=start_aterm_search, padx=3, pady=3
    )
    search_button.grid(row=4, padx=20, sticky="w")
    search_button.configure(font=font.Font(size=10, weight="bold"))

    status_str = tk.Label(app, text="")
    status_str.grid(row=5, padx=20, sticky="w")
    status_str.configure(bg="#ffffff", font=font.Font(size=12, weight="bold"))

    search_table = AtermTable(app=app, row=6)

    app.mainloop()
```
</details>

## 3.解説
　Aterm検索ツールの要件として以下のものが挙げられます。
１．ユーザのローカルネットワーク情報が取得できる
２．取得したネットワーク情報から各ホストにHTTP通信できる
３．GUIを備えている
### 3.1.ネットワーク情報
　元のhtaではWMI(Windows Management Instrumentation)のオブジェクトを取得して、そこから情報を得ているのですが、この処理がIEじゃないと走らないし、そもそもWindows依存です。MacやLinuxでも動くものにしたかったのでpythonのwmiモジュールは使わず、psutil、socket、netifacesでの実装となっています。

### 3.2.HTTP通信
　Aterm機器かをどうやって確認しているかが、この部分です。具体的にはHTTPのPOSTメソッドで"REQ_ID=PRODUCT_NAME_GET"を送ります。"PRODUCT_NAME=WX1800HP"などと帰ってこればAterm機器というわけです。なので、下記のURLにこのヘッダでこのデータをHTTPでPOSTできるならpythonじゃなくても成立します。
```
    url = f"http://{ip}/aterm_httpif.cgi/getparamcmd_no_auth"
    data = "REQ_ID=PRODUCT_NAME_GET"
    headers = {'Content-Type': 'application/x-www-form-urlencoded'}
```
続けて"REQ_ID=SYS_MODE_GET"でモード番号を取得し、MODEARRAYにあてはめます。
```
MODEARRAY = [
    "ブリッジ", "PPPoEルータ", "ローカルルータ",
    "無線LAN子機", "無線LAN中継機", "MAP-E",
    "464XLAT", "DS-Lite", "固定IP1", "複数固定IP", "メッシュ中継機"
]
```
NECはこれ以外のモードを作りたい場合とかどうするつもりなんだ？
### 3.3.GUI
　今回見た目と使い心地も似せました。そもそもPCやネットワークに詳しい方なら、機器の外に書いてあるMACアドレスに対して固定IPアドレスを割り振ればこんなツールいりません。だから、CLIを触ることなく動かせるってのは要件かなって思います。ただまぁ、このコードだけの状態だとpython環境がないと動かせないのでこの理論は破綻するのです。なので、NECさんは自分たちでこのコード精査して企業グレードのコードにしてpythonの下記ライブラリで各環境の実行ファイルにして配布してもらえればなぁと思います。
 ```
 pyinstaller .\AtermSearch.py --noconsole --onefile
```
## 4.余談
### 4.1.なぜpythonなんだ
　最初は各要件を踏まえて、実行環境を揃える必要がないPowerShellスクリプトで置き換えを狙っていました。パワーが強すぎてウィルス対策ソフトに止められました。PowershellでWMIからネットワーク情報取得してテンポラリのHTMLファイルを作成し、Edge上でJavascriptを実行というのもうまく動きませんでした。EdgeはHTTP通信をHTTPSに勝手に置き換えるんですって。HTTPSじゃあ駄目なんですよね。つまり、簡単な方法はセキュリティ的理由でことごとく潰してあるのが現在のWindows環境なのです。そこで、簡単で流行ってて情報がいっぱいあるのがpythonだったんです。
### 4.2.どうして〇〇していないんだ
　すいません。私は元々python文化圏の人間ではないのです。新参です。一応pycodestyleの小姑じみた指摘は全てクリアしているので許してください。

 以上です。