仙石浩明の日記

2026年9月21日

カシムラ USBスティック型 Wi-Fiルータ KD-249 (AM-WF003共通) の接続端末リストを取得する Python スクリプトを Gemini に作ってもらった

カシムラ製 Wi-Fiルータ KD-249 は車載環境に対応した SIMフリー無線LANルータということで、 バッテリーを内蔵しておらず USB電源のみで動く。 バッテリーの劣化を心配すること無く電源を入れっぱなしにできるので、 バックアップ用回線として最適。

私の自宅LAN は、 メイン回線が NURO光だが、 困ったことに NURO光から提供されるルータ NSD-G1000TS が数週間に一度くらいの頻度でハングする (特に夏場。おそらく過熱が原因なのだろう)。 そこでスマート電源プラグ (Eco Plugs 社製 CT-065W) を使ってリモートから NSD-G1000TS の電源をオフ→オンして NSD-G1000TS を再起動できるようにした。

もちろん、 ハング時はメイン回線が使えないわけで、 スマート電源プラグはバックアップ回線 (つまり KD-249 の Wi-Fi) に接続した。 これでメイン回線がハングしても、 インターネット→バックアップ回線経由でスマート電源プラグを操作することで、 メイン回線を復旧させることができる。

とはいえ、 できれば自動で復旧するようにしたいし、 インターネット経由でスマート電源プラグを操作 (つまりクラウドに依存) するよりは自宅LAN 内だけでスマート電源プラグを操作したい。 そのためにはスマート電源プラグがバックアップ LAN 内で割当てられた IP アドレス (つまり KD-249 内蔵の DHCP サーバが割当てた IP アドレス) が必要になる。

KD-249 は Web 管理画面で接続中の端末の IP アドレスを一覧することができる。

KD-249 clients

一行目に表示されている ESP_123456 がスマート電源プラグ。 ここに表示される IP アドレスを取得すれば良い。 幸いこの管理画面は (暗号化無しの) http でアクセスできるので、 丸見えの http 通信を解析すればスクリプトでこの IP アドレスを取得することは容易だろう。

とはいえ、 この程度のスクリプトなら (無料版の) Gemini でも作れるのではないかと思って試してみた。 Gemini に
「KD-249 のクライアントのリストを得るにはどうすればいいですか?」 と聞いてみると、
Gemini 曰く:

カシムラ社製の車載用Wi-Fiルーター「KD-249」に接続されているクライアント(スマートフォンやタブレットなどの接続機器)のリスト(一覧)を確認するには、ルーターの設定画面(管理画面)にアクセスする必要があります。以下の手順で確認を行ってください。
  (後略)

そんなことは知ってるYo! と言いたくなる衝動を抑えて、 質問を言い直す:
「クライアントのIPアドレスを得るためのスクリプトを教えてください」
Gemini 答えて曰く:

カシムラ製のWi-Fiルーター(KD-249)の管理画面から、 現在接続されているクライアントのIPアドレスを自動で取得するための Pythonスクリプト(スクレイピングプログラム)をご紹介します。
このルーターは一般的なWeb管理画面 (一般的なモバイルルーターでは 192.168.100.1 や 192.168.0.1 などのWeb UI) を持っているため、プログラムからHTTPリクエストを送るか、 ブラウザを自動操作するライブラリ「Selenium」や「Playwright」を使用することで、 接続機器のIPアドレス一覧をテキストやCSVとして抽出できます。
ここでは、最も確実でページの読み込み待ちなどに対応しやすい Selenium を使用したスクリプト例を紹介します。
  (後略)

Selenium とは なんとまあ牛刀な…(*_*) と思って、 さらに質問を言い直す:
「ブラウザを起動するスクリプトなんですね。あまり重いのは好ましくないので、スクリプトだけで動作できるようにはできませんか?」
Gemini さらに答えて曰く:

ブラウザ(Selenium)を起動せず、 バックグラウンドでの軽量な通信(HTTPリクエスト) だけで接続クライアントの情報を取得する方法に変更しましょう。
一般的なルーターの管理画面は、 ブラウザから裏側で特定のAPI(JSONやXML、またはプレーンテキストを返すページ) に対してリクエストを送信しています。 その通信を模倣する形にスクリプトを書き換えることで、 ブラウザを起動せず、一瞬で、 かつ非常に軽い動作でIPアドレスを取得できるようになります。
カシムラ(KD-249) のベースとなっている一般的な車載ルーターやモバイルルーターの挙動に基づき、 軽量な requests ライブラリを使用したスクリプトのテンプレートを作成しました。
  (後略)

ようやくこちらの意図が伝わったが、 Gemini は KD-249 の仕様を知らないので一般的なルータのログイン手続きを行うスクリプトを提示した。 当然これでは動かないので、 原因を調べるためデバッグ用の情報を出力するようリクエストする:
「スクリプトが取得したレスポンスを表示するようにしてもらえますか?」

レスポンスの生のテキスト(HTML、JSON、XMLなど)やステータスコードをそのまま画面に表示して、中身を確認できるようにしたスクリプトです。
まずはこのスクリプトを実行し、ルーターからどのようなデータ(あるいはエラー画面)が返ってきているかを画面上で確認しましょう。
  (後略)

だんだんこちらの意図がスムーズに伝わるようになってきた。
「レスポンスは返ってきているので、ログインまで進めるスクリプトをお願いします」

カシムラ社製の車載ルーター(KD-249)のWeb管理画面(一般的なモバイルルーターに多い仕様)に対して、セッションを保持しながら自動でログイン処理を完了させ、ログイン後のレスポンスを確認するスクリプトを作成しました。
ログイン時に「JSON」や「フォームデータ」のどちらを要求されても対応できるように、よくある2つのパターンを試せる構造にしています。
ログイン進捗確認スクリプト
  (省略)
このスクリプトを実行すると、ルーターからログイン成否に関するメッセージ(例: {"result": "success"}、{"error": "..."}、あるいはHTMLのエラーページなど)が画面に表示されます。
実行してみて、画面に表示された「レスポンス内容」には具体的にどのような文字列が出力されましたか?
・ 成功っぽい文字列(success や {"result":"0"} など)が表示された
・ 404エラーや別のエラーが出た
これによって次のステップである「接続機器一覧(IPアドレス)のデータが入っているURLの特定とデータ抽出」へ進めますので、結果を教えてください。

手取り足取り教えてくれる感じが まどろっこしいが、 解析方法としては悪くないので素直にスクリプトを実行して結果を知らせる:
「JSON形式だと 404 ですね。フォーム形式だと {"result":"3"} です」

情報のご共有ありがとうございます!重要なヒントが得られました。
JSON形式の「404 (Not Found)」はURL自体が存在しないことを示していますが、フォーム形式(goform_set_cmd_process)の {"result":"3"} は、ルーターのAPIが存在し、リクエストを処理した結果として「エラーコード 3」を返している状態です。
モバイルルーターの goform 仕様において、結果コード 3 は一般的に「パスワードが間違っている(認証失敗)」、または「ログイン処理に必要なパラメータ(項目)が足りない」ことを意味します。
ルーター側に正しいログイン要求として認識させるため、パラメータを補正した次のスクリプトを試してみてください。
ログイン再試行スクリプト
よくある「暗号化されていないプレーンテキストでのパスワード送信」と「Base64でエンコードされたパスワード送信」の2つのパターン、および必須となりやすいセッションパラメータを追加しています。
  (スクリプトは省略)
このスクリプトを実行してみて、パターンA・Bそれぞれのレスポンス内容(再度 {"result":"3"} になるか、あるいは {"result":"0"} などの変化があるか)を教えていただけますか?
また、現在ログインに使用しているパスワードは、ルーター初期設定の「admin」などのままでしょうか?それともご自身で変更されたものでしょうか?(※実際のパスワードそのものは書き込まないようご注意ください)

提示されたスクリプトをそのまま実行してみると、

asao:~/tmp $ python test.py
--- パターンA (プレーンテキスト) で試行中 ---
レスポンス: {"result":"3"}

--- パターンB (Base64エンコード) で試行中 ---
レスポンス: {"result":"0"}
▶ パターンB でログインに成功した可能性があります!

この実行結果をそのまま Gemini に伝える:
「▶ パターンB でログインに成功した可能性があります!」

素晴らしいです!パターンB(Base64エンコード)で認証を突破できましたね。 ルーターがパスワードを簡易的に暗号化(エンコード)して送信する仕様になっていたのが原因でした。
ログインが成功した(セッションが確立された)ので、いよいよ最終目的である「接続中のクライアント一覧(IPアドレス等)のデータ」を引き抜く処理を追加します。
モバイルルーターの仕様上、ログイン直後に同じセッション(session)を使って goform_get_cmd_process というURLに対し、「ステーションリスト(接続機器一覧)をください」というリクエストを送ることでデータを取得できます。
以下のコードで、実際に接続されている機器のIPアドレスが取得できるか試してみましょう。
  (後略)

接続機器一覧の取得方法が統一されているわけもなく、 残念ながら提示されたスクリプトでは取得できなかった。

asao:~/tmp $ python test.py
ルーターにログイン中...
ログイン成功!接続デバイス一覧をリクエスト中...

[試行 1] パラメータ {'cmd': 'station_list', 'multi_data': '1'} でデータ取得中...
--- レスポンス内容 ---
{"station_list":""}

[試行 2] パラメータ {'cmd': 'client_list', 'multi_data': '1'} でデータ取得中...
--- レスポンス内容 ---
{"client_list":""}

[試行 3] パラメータ {'cmd': 'get_client_list'} でデータ取得中...
--- レスポンス内容 ---
{"get_client_list":""}

すべてのパラメータを試しましたが、クライアントデータが特定できませんでした。

これを Gemini に伝える:
「すべてのパラメータを試しましたが、クライアントデータが特定できませんでした。」

ログインは成功しているものの、データを要求するパラメータ(cmd)の名前がKD-249の内部仕様と一致していないようです。
このメーカー(カシムラ等)のルーターで使われているチップセット(ZTE製やRealtek製など)のWeb UIにおいて、接続中のクライアント一覧(Wi-Fiステーション)を取得する際によく使われる、より網羅的なパラメータ群を定義したスクリプトを用意しました。
また、今回はデータがJSONではなくプレーンテキストやカンマ区切りで返ってくる可能性も考慮し、空ではないレスポンスが返ってきたらその中身をすべて表示するようにしています。
拡張版パラメータ探索スクリプト
  (後略)

実行してみる:

asao:~/tmp $ python test.py
ログイン成功!各種パラメータでクライアントデータを探索します...

【候補 1 成功パラメータ】: {'cmd': 'user_lan_device_list', 'multi_data': '1'}
レスポンス文字数: 27 文字
--- レスポンス内容 ---
{"user_lan_device_list":""}
----------------------

【候補 2 成功パラメータ】: {'cmd': 'lan_device_list', 'multi_data': '1'}
レスポンス文字数: 22 文字
--- レスポンス内容 ---
{"lan_device_list":""}
----------------------

【候補 3 成功パラメータ】: {'cmd': 'station_list', 'multi_data': '1'}
レスポンス文字数: 19 文字
--- レスポンス内容 ---
{"station_list":""}
----------------------

【候補 4 成功パラメータ】: {'cmd': 'client_info', 'multi_data': '1'}
レスポンス文字数: 18 文字
--- レスポンス内容 ---
{"client_info":""}
----------------------

【候補 5 成功パラメータ】: {'cmd': 'dhcp_client_list', 'multi_data': '1'}
レスポンス文字数: 23 文字
--- レスポンス内容 ---
{"dhcp_client_list":""}
----------------------

【候補 6 成功パラメータ】: {'cmd': 'status_info', 'multi_data': '1'}
レスポンス文字数: 18 文字
--- レスポンス内容 ---
{"status_info":""}
----------------------

【候補 7 成功パラメータ】: {'cmd': 'get_router_status'}
レスポンス文字数: 24 文字
--- レスポンス内容 ---
{"get_router_status":""}
----------------------

【候補 8 成功パラメータ】: {'cmd': 'home_page_info', 'multi_data': '1'}
レスポンス文字数: 21 文字
--- レスポンス内容 ---
{"home_page_info":""}
----------------------

【候補 9 成功パラメータ】: {'cmd': 'wifi_wlan_client_list'}
レスポンス文字数: 28 文字
--- レスポンス内容 ---
{"wifi_wlan_client_list":""}
----------------------

【候補 10 成功パラメータ】: {'cmd': 'station_list'}
レスポンス文字数: 3758 文字
--- レスポンス内容 ---
{"station_list":[{"dev_type":"wifi","mac_addr":"00:E0:4C:AB:CD:EF","hostname":"takatsu.gcd.org","ip_addr":"192.168.0.101","duration":"1m:24s","rx":"11.44 MB","tx":"10.21 MB","timestamp":"2026-09-19 00:16:52","ip_type":"DHCP"},{"dev_type":"wifi","mac_addr":"00:E0:4C:56:78:9A","hostname":"asao.gcd.org","ip_addr":"192.168.0.102","duration":"6h:41m:30s","rx":"30.44 MB","tx":"14.82 MB","timestamp":"2026-09-19 00:16:52","ip_type":"DHCP"},{"dev_type":"wifi","mac_addr":"38:2B:78:12:34:56","hostname":"ESP_123456","ip_addr":"192.168.0.100","duration":"6h:16m:36s","rx":"2.80 MB","tx":"1.51 MB","timestamp":"2026-09-19 00:16:52","ip_type":"DHCP"},{"dev_type":"wifi","mac_addr":"00:E0:4C:AB:CD:EF","hostname":"takatsu.gcd.org","ip_addr":"192.168.0.101","duration":"1m:59s","rx":"8.43 MB","tx":"7.30 MB","timestamp":"2026-09-19 00:03:27","ip_type":"DHCP"},{"dev_type":"wifi","mac_addr":"00:E0:4C:AB:CD:EF","hostname":"takatsu.gcd.org","ip_addr":"192.168.0.101","duration":"1m:59s","rx":"8.09 MB","tx":"7.07 MB","timestamp":"2026-09-19 00:02:27","ip_type":"DHCP"},{"dev_type":"wifi","mac_addr":"00:E0:4C:AB:CD:EF","hostname":"takatsu.gcd.org","ip_addr":"192.168.0.101","duration":"1m:59s","rx":"11.36 MB","tx":"10.15 MB","timestamp":"2026-09-19 00:16:27","ip_type":"DHCP"},{"dev_type":"wifi","mac_addr":"00:E0:4C:AB:CD:EF","hostname":"takatsu.gcd.org","ip_addr":"192.168.0.101","duration":"1m:59s","rx":"11.17 MB","tx":"10.04 MB","timestamp":"2026-09-19 00:15:27","ip_type":"DHCP"},{"dev_type":"wifi","mac_addr":"00:E0:4C:AB:CD:EF","hostname":"takatsu.gcd.org","ip_addr":"192.168.0.101","duration":"1m:59s","rx":"10.98 MB","tx":"9.91 MB","timestamp":"2026-09-19 00:14:27","ip_type":"DHCP"},{"dev_type":"wifi","mac_addr":"00:E0:4C:AB:CD:EF","hostname":"takatsu.gcd.org","ip_addr":"192.168.0.101","duration":"1m:59s","rx":"10.79 MB","tx":"9.79 MB","timestamp":"2026-09-19 00:13:27","ip_type":"DHCP"},{"dev_type":"wifi","mac_addr":"00:E0:4C:AB:CD:EF","hostname":"takatsu.gcd.org","ip_addr":"192.168.0.101","dura
----------------------

おお、クライアント一覧の取得に成功した! 成功の喜びを Gemini と分かち合う:
「【候補 10 成功パラメータ】: {'cmd': 'station_list'}」

素晴らしいです!【候補 10】の {'cmd': 'station_list'}(multi_data なしの単体指定)でついにデータが返ってきましたね! ルーター内部から正確な接続デバイス情報が引き出せている状態です。
この station_list から返ってきたレスポンス内容(テキスト)には、各クライアントの IP アドレス、MAC アドレス、あるいはデバイス名(ホスト名)が、カンマ区切り(CSV 形式)やスラッシュ区切り、あるいは特定の記号で連結された文字列として並んでいるはずです。
最後に、この返ってきた生データを綺麗に解析(パース)して、IP アドレスの一覧を画面にリスト表示し、CSV ファイルとしても保存する「最終版スクリプト」を完成させましょう。
データの区切り文字(パース方法)を特定するため、【候補 10】の「--- レスポンス内容 ---」に表示されたテキスト(実際のデータ部分)を教えていただけますか?(※セキュリティのため、MAC アドレスなどの固有識別子は適当な文字に書き換えていただいて構いません)
データの構造に合わせてプログラムを書き換えますので、以下を教えてください。
・ {'cmd': 'station_list'} で返ってきた「レスポンス内容」の文字列(数行分)
・ IP アドレス以外に、デバイス名や MAC アドレスも一緒に抽出してリスト化したいですか?

最初の 2レコード分をコピペ:
「{"station_list":[{"dev_type":"wifi","mac_addr":"00:E0:4C:AB:CD:EF","hostname":"takatsu.gcd.org","ip_addr":"192.168.0.101","duration":"1m:24s","rx":"11.44 MB","tx":"10.21 MB","timestamp":"2026-09-19 00:16:52","ip_type":"DHCP"},{"dev_type":"wifi","mac_addr":"00:E0:4C:56:78:9A","hostname":"asao.gcd.org","ip_addr":"192.168.0.102","duration":"6h:41m:30s","rx":"30.44 MB","tx":"14.82 MB","timestamp":"2026-09-19 00:16:52","ip_type":"DHCP"},」

完全なJSON形式でデータが返ってきていることが確認できました!非常に綺麗な構造(キー名が ip_addr や hostname)なので、確実に正確な情報を抽出できます。
お使いのPython環境(Python 3.9)で動作し、ブラウザを起動することなく一瞬で接続中のクライアントのIPアドレス・ホスト名・MACアドレスの一覧を画面に表示し、CSVファイルにも保存する最終版のスクリプトが完成しました。
クライアントIPアドレス抽出スクリプト(完成版)
  (後略)

この後、 若干の修正を加えて完成したスクリプトがこちら:

#!/usr/bin/python
import os
import json
import base64
import warnings
import yaml
import requests
import pandas as pd

warnings.simplefilter('ignore', UserWarning)

CONFIG_FILE = "/usr/local/sbin/kd-249.yml"

def load_config():
    if not os.path.exists(CONFIG_FILE):
        print("[エラー] 設定ファイルが見つかりません。", file=os.sys.stderr)
        return None, None
    try:
        with open(CONFIG_FILE, "r", encoding="utf-8") as f:
            config = yaml.safe_load(f)
        return config["router"]["base_url"], config["router"]["password"]
    except Exception as e:
        print(f"[エラー] YAMLの読み込みに失敗しました: {e}", file=os.sys.stderr)
        return None, None

def get_client_ip_list_raw():
    router_base_url, password = load_config()
    if not router_base_url or not password:
        return

    login_url = f"{router_base_url}/goform/goform_set_cmd_process"
    data_url = f"{router_base_url}/goform/goform_get_cmd_process"

    session = requests.Session()
    session.headers.update({
        "User-Agent": "Mozilla/5.0",
        "Referer": f"{router_base_url}/index.html"
    })

    encoded_password = base64.b64encode(password.encode('utf-8')).decode('utf-8')
    login_data = {"goformId": "LOGIN", "password": encoded_password}
    
    try:
        login_res = session.post(login_url, data=login_data, timeout=5)
        if '"result":"0"' not in login_res.text and 'success' not in login_res.text.lower():
            print("[エラー] ルーターへのログインに失敗しました。", file=os.sys.stderr)
            return
            
        response = session.get(data_url, params={"cmd": "station_list"}, timeout=5)
        raw_text = response.text.strip()
        if raw_text.endswith(",]}"):
            raw_text = raw_text.replace(",]}", "]}")
        
        data = json.loads(raw_text)
        stations = data.get("station_list", [])
        
        client_list = []
        for station in stations:
            client_list.append({
                "IPアドレス": station.get("ip_addr"),
                "ホスト名": station.get("hostname"),
                "MACアドレス": station.get("mac_addr"),
                "接続時間": station.get("duration"),
                "通信種類": station.get("dev_type"),
                "受信量": station.get("rx"),
                "送信量": station.get("tx"),
                "タイムスタンプ": station.get("timestamp")
            })
            
        if client_list:
            df = pd.DataFrame(client_list)
            
            # 1. タイムスタンプを基準に昇順(古い順)にソート
            df = df.sort_values(by="タイムスタンプ", ascending=True)
            
            # 2. MACアドレスの重複を削除(ソート後の一番下=最も新しいタイムスタンプを残す)
            df = df.drop_duplicates(subset=["MACアドレス"], keep="last")
            
            # 【修正】Pandasのバージョン依存を解消するため lineterminator に変更
            output_csv = df.to_csv(index=False, header=False, lineterminator="\n").strip()
            print(output_csv)

    except Exception as e:
        # エラー原因を標準エラー出力(他スクリプトのパイプラインを汚さない場所)に出力します
        print(f"[システムエラー] {e}", file=os.sys.stderr)

if __name__ == "__main__":
    get_client_ip_list_raw()

このスクリプトを実行すると、 接続端末の一覧をカンマ区切り (.csv 型式) で出力してくれる。

asao:~ $ kd-249 
192.168.0.100,ESP_123456,38:2B:78:12:34:56,6h:20m:27s,wifi,3.14 MB,1.71 MB,2026-09-21 00:20:43
192.168.0.102,asao.gcd.org,00:E0:4C:56:78:9A,8h:45m:19s,wifi,46.99 MB,31.17 MB,2026-09-21 00:20:43
192.168.0.101,takatsu.gcd.org,00:E0:4C:AB:CD:EF,1h:32m:6s,wifi,391.89 MB,257.41 MB,2026-09-21 00:20:43

KD-249 ルーター接続設定を YAML 型式で /usr/local/sbin/kd-249.yml に保存しておく。 KD-249 は管理画面の URL が 192.168.0.1 固定なので、 既に 192.168.0.* を使っている環境だと致命的

# KD-249 ルーター接続設定
router:
  base_url: "http://192.168.0.1"
  password: "********"
Filed under: システム構築・運用,プログラミングと開発環境 — hiroaki_sengoku @ 13:24

No Comments »

No comments yet.

RSS feed for comments on this post. TrackBack URL

Leave a comment