DECOルーターをSeleniumで「ハック」して家族の在宅をリアルタイム検知

Raspberry Pi

スマートホームで「家族が帰宅したら照明をつける」「全員が外出したらエアコンをオフ」などを実現するには、家族の在宅状況をリアルタイムで把握することが不可欠です。Wi-Fiルーターは家中のデバイスが常に接続しているため、接続状況を取得できれば在宅状況を正確に判断できます。しかし我が家で使用しているTP-Link DECOメッシュWi-Fiルーターには公式のローカルAPIが存在しません。この記事では、selenium-wireを使ってブラウザの内部通信を傍受し、AES暗号化されたクライアントリストをリアルタイムで取得する方法を紹介します。

なぜこの機能を作ったのか?

家族の帰宅・外出をトリガーにしたスマートホーム自動化を実現したいと思っていました。「帰宅したらリビングの照明を自動でつける」「全員が外出したらエアコンをオフにする」「誰かが帰ってきたらGoogle Homeで『おかえり』とアナウンスする」などです。これらを実現するには家族のスマートフォンがWi-Fiに接続しているかどうかをリアルタイムで検知する必要があります。

DECOのWi-Fi接続情報がプログラムで取れれば、家族の帰宅・外出に合わせて何でも自動化できるのに!

TP-Link DECOはメッシュWi-Fiとして非常に優秀ですが、HomeKitやSmart Life(Tuya)のような公式のローカルAPIが提供されていません。「公式APIがないなら、ブラウザがDECOの管理画面と行っている内部通信を傍受すればいい!」という発想のもと、selenium-wireを使ったアプローチで解決しました。

実現したいこと

  • DECOルーターに接続中のデバイス一覧をリアルタイムで取得する
  • 取得したデータ(デバイス名・IPアドレス・通信速度など)をInfluxDBに継続的に記録する
  • 家族のスマートフォンがWi-Fiに接続しているかを5秒ごとに監視する
  • ping・ARP・Wi-Fi接続状況を組み合わせて帰宅・外出イベントを検出する

この記事でわかること

  • selenium-wireを使ったHTTPトラフィック傍受の方法
  • TP-Link DECO管理画面への自動ログイン(Seleniumによるパスワード入力)
  • localStorageからAES鍵・IVを取得する方法
  • AES-CBC暗号化されたAPIレスポンスをPythonで復号する方法(pycryptodome)
  • Base64+URLエンコードされたデバイス名をデコードする方法
  • 同一データ連続対策・セッション切れ対策の実装パターン
  • ping・ARP・Wi-Fi接続の3つを組み合わせた堅牢な在宅検知の仕組み

必要な準備と用意するもの

ハードウェア

ハードウェア
  • TP-Link DECOメッシュWi-Fiルーター(ブラウザで管理画面にアクセスできるもの)
  • 常時稼働できるPC(Ubuntu/Linux)— mini PCやRaspberry Pi 5など

ソフトウェア/サービス

ソフトウェア/サービス
  • Python 3.x
  • selenium-wire(SeleniumのHTTPトラフィック傍受拡張ライブラリ)
  • pycryptodome(AES暗号化・復号ライブラリ)
  • Chromiumブラウザ + ChromeDriver(ヘッドレスブラウザとして使用)
  • InfluxDB(取得したデータの記録用時系列データベース)

完成イメージ

プログラムが正常に動作すると、5秒ごとにDECOへの接続デバイス情報がInfluxDBに蓄積されていきます。Grafanaなどで可視化すると、家族のスマートフォンがWi-Fiに接続しているかどうかをリアルタイムで確認できます。

InfluxDBに記録される主なデータは以下の通りです。

  • measurement: client … DECOに接続している全デバイスの情報(デバイス名・IPアドレス・ダウンロード/アップロード速度・在宅確認対象フラグ)
  • measurement: family_device … 家族デバイスの在宅フラグ(is_lan_connected)— DECO接続・ping・ARPの3つを論理ORで統合
  • measurement: family_arrival_or_departure … 「帰宅」「外出」イベントの記録(11分間状態が安定してから判定)

システムの仕組み

システム全体のデータフローは次のとおりです。

  1. headless ChromiumでDECOルーターの管理画面(192.168.xx.1)にアクセスし、パスワードでログイン
  2. ログイン後、ブラウザのlocalStorageから AES鍵とIVencryptorAES)を取得
  3. selenium-wireがインターセプトしたリクエストURLからセッショントークン(stok)を取得
  4. DECOが定期送信するclient?form=client_list APIのレスポンスを傍受し、AES-CBCで復号
  5. デバイス名をBase64+URLデコードして可読名に変換し、家族デバイスと照合
  6. 全デバイス情報をInfluxDB(measurement: client)に書き込み
  7. 別プログラム(family_device_status_record.py)がping・ARP・DECO接続の3つを組み合わせて在宅判定し、「帰宅」「外出」イベントをInfluxDBに記録

DECOはAPIレスポンスをAES-CBC方式で暗号化しています。そのため単にHTTPリクエストを傍受するだけではデータを読み取れず、AES鍵とIVを取得した上で復号する必要があります。

実装のポイント

① selenium-wireで通常のSeleniumを置き換える

通常のSeleniumはブラウザ操作を自動化できますが、HTTPレスポンスのボディを取得する機能はありません。selenium-wireはSeleniumを拡張し、ブラウザが行ったすべてのHTTPリクエストとレスポンスをPythonから参照できるライブラリです。from selenium import webdriverfrom seleniumwire import webdriver に変えるだけで利用でき、既存のSeleniumコードと完全互換です。

# 通常のSelenium(レスポンスボディは取得不可)
# from selenium import webdriver

# selenium-wire(ドロップイン置き換え — レスポンスボディも参照可能)
from seleniumwire import webdriver

selenium-wireはSeleniumと完全互換のAPIを持っており、既存のSeleniumコードをほぼそのまま流用できます。

② localStorageからAES鍵を取得する

DECOの管理画面はログイン後にAES鍵とIVをブラウザのlocalStorageに保存します。execute_script()でこの値を取得することで、暗号化されたAPIレスポンスを復号するための鍵が手に入ります。localStorageのencryptorAESキーには k=<鍵>&i=<IV> という形式で格納されています。

localStorageへの書き込みはログイン処理が完了してから行われます。execute_scriptで即座に取得しようとするとNoneが返ることがあるため、値がセットされるまでポーリングして待機する関数を用意しています。

③ AES-CBCで暗号化されたレスポンスを復号する

DECOのAPIレスポンスはAES-CBC方式でBase64エンコードされています。pycryptodomeの AES.new(key, AES.MODE_CBC, iv) で復号し、末尾のPKCS7パディングを除去することで元のJSONデータが得られます。

④ デバイス名のBase64+URLデコード

DECOのAPIが返すデバイス名はBase64エンコードされており、さらにURL(パーセント)エンコードされた文字が含まれる場合があります。日本語のデバイス名(例:「タロウのiPhone」)を正しく取得するには2段階のデコードが必要です。

⑤ 同一データ連続対策と再ログイン戦略

selenium-wireは傍受済みのリクエストをメモリ上にキャッシュし続けます。ページをリフレッシュせずに同じデータが返り続ける場合はキャッシュが古くなっているサインです。同一データが3回連続したら再ログインを実行します。また、最後の成功から5分間データが更新されなければ同様に再ログインします。

事前準備

ChromiumとChromedriverのインストール

sudo apt-get update
sudo apt-get install -y chromium-browser chromium-chromedriver

Pythonライブラリのインストール

pip install selenium-wire pycryptodome

pycryptoではなくpycryptodomeをインストールしてください。pycryptoはメンテナンスが終了しておりセキュリティ上の問題があります。両方インストールすると競合する場合もあります。

DECOの管理画面へのアクセス確認

ブラウザで http://192.168.xx.1/webpages/index.html(DECOのIPアドレスは機種によって異なります)にアクセスし、管理者パスワードでログインできることを確認してください。

実装方法

インポートと設定

import json, re, base64, urllib.parse, time
from seleniumwire import webdriver
from selenium.webdriver.chrome.service import Service
from selenium.webdriver.chrome.options import Options
from selenium.webdriver.common.by import By
from selenium.webdriver.common.keys import Keys
from Crypto.Cipher import AES
from selenium.webdriver.support.ui import WebDriverWait
from selenium.webdriver.support import expected_conditions as EC
CHROMEDRIVER_PATH = "/usr/bin/chromedriver"
DECO_URL = "http://192.168.xx.1/webpages/index.html"
PASSWORD = "<your-deco-password>"
SAME_THRESHOLD = 3  # 同一データが何回続いたら再ログインするか

Seleniumのヘッドレスオプション設定

options = Options()
options.add_argument("--headless")
options.add_argument("--no-sandbox")
options.add_argument("--disable-dev-shm-usage")
options.add_argument("--window-size=1920,1080")
options.add_argument("--force-device-scale-factor=2")
options.add_argument("--disable-gpu")
options.add_argument("--disable-software-rasterizer")
options.add_argument("--no-zygote")          # Chromiumゾンビプロセス対策
options.add_argument("--single-process")
options.add_argument("--disable-extensions")
options.add_argument("--disable-background-networking")
options.add_argument("--disable-sync")
options.add_argument("--metrics-recording-only")

--no-zygote--single-process は、Linux環境でChromiumプロセスがゾンビプロセスとして残り続ける問題の対策です。これらがないと長時間稼働でリソースが枯渇することがあります。

ユーティリティ関数

def wait_for_localstorage(driver, key, timeout=10):
    """localStorageに値がセットされるまでポーリングして待機する"""
    end = time.time() + timeout
    while time.time() < end:
        val = driver.execute_script(f"return localStorage.getItem('{key}');")
        if val:
            return val
        time.sleep(0.5)
    return None
def deco_decrypt(data_base64, key_str, iv_str):
    """AES-CBCで暗号化されたBase64データを復号してJSONとして返す"""
    key = key_str.encode('utf-8')
    iv  = iv_str.encode('utf-8')
    data_bytes = base64.b64decode(data_base64)
    cipher = AES.new(key, AES.MODE_CBC, iv)
    decrypted_bytes = cipher.decrypt(data_bytes)
    # PKCS7 パディング除去
    pad_len = decrypted_bytes[-1]
    decrypted_bytes = decrypted_bytes[:-pad_len]
    return json.loads(decrypted_bytes.decode('utf-8'))
def decode_name(name_str):
    """Base64+URLエンコードされたデバイス名を可読な文字列にデコードする"""
    try:
        decoded = base64.b64decode(name_str).decode('utf-8')
        decoded = urllib.parse.unquote(decoded)
        return decoded
    except Exception:
        return name_str  # デコード失敗時は元の文字列をそのまま返す

ログイン処理(deco_login関数)

def deco_login(service, options):
    driver = webdriver.Chrome(service=service, options=options)
    driver.get(DECO_URL)
    # パスワード入力欄が表示されるまで待機してパスワードを入力
    pw_input = WebDriverWait(driver, 10).until(
        EC.presence_of_element_located((By.CSS_SELECTOR, "input.password-text"))
    )
    pw_input.clear()
    pw_input.send_keys(PASSWORD)
    pw_input.send_keys(Keys.ENTER)
    time.sleep(5)  # ログイン完了を待機
    # AES鍵とIVをlocalStorageから取得(値がセットされるまでポーリング)
    encryptorAES = wait_for_localstorage(driver, "encryptorAES")
    if not encryptorAES:
        raise RuntimeError("encryptorAES取得失敗(ログイン未完了)")
    # 形式: k=1234567890123456&i=1234567890123456
    m = re.search(r'k=(\d+)&i=(\d+)', encryptorAES)
    key_str = m.group(1)
    iv_str  = m.group(2)
    # stokセッショントークンをインターセプトしたURLから抽出
    stok = None
    timeout = time.time() + 10
    while time.time() < timeout:
        for request in driver.requests:
            if request.response and "stok=" in request.url:
                m2 = re.search(r"stok=([^/]+)", request.url)
                if m2:
                    stok_val = m2.group(1)
                    if stok_val and stok_val != "login":
                        stok = stok_val
                        break
        if stok:
            break
        time.sleep(0.5)
    if stok is None:
        raise RuntimeError("stok が取得できませんでした")
    return driver, key_str, iv_str

メイン監視ループ(main_loop関数)

def main_loop():
    service = Service(CHROMEDRIVER_PATH)
    driver, key_str, iv_str = deco_login(service, options)
    prev_json_str = None
    same_count = 0
    last_update = time.time()
    try:
        while True:
            # selenium-wireが傍受したリクエストから最新のclient_listレスポンスを取得
            latest_req = None
            for req in reversed(driver.requests):
                if req.response and "client?form=client_list" in req.url:
                    latest_req = req
                    break
            if latest_req:
                body = latest_req.response.body.decode()
                # JSONではないレスポンスはスキップ
                if not body or not body.strip().startswith("{"):
                    driver.requests.clear()
                    time.sleep(5)
                    continue
                resp_json = json.loads(body)
                if "data" not in resp_json:
                    raise RuntimeError("セッション切れの可能性")
                # AES-CBCで復号
                decrypted_json = deco_decrypt(resp_json["data"], key_str, iv_str)
                # ---- 同一データ連続対策 ----
                current_json_str = json.dumps(decrypted_json, sort_keys=True)
                if current_json_str == prev_json_str:
                    same_count += 1
                else:
                    same_count = 0
                prev_json_str = current_json_str
                if same_count >= SAME_THRESHOLD:
                    # キャッシュが古くなっているため再ログイン
                    driver.quit()
                    driver, key_str, iv_str = deco_login(service, options)
                    prev_json_str = None
                    same_count = 0
                    continue
                # クライアントリストを処理してInfluxDBへ書き込み
                client_list = decrypted_json.get("result", {}).get("client_list", [])
                clients = []
                for client in client_list:
                    if "name" in client and client["name"]:
                        decoded_name = decode_name(client["name"])
                        client["name"] = decoded_name
                        client["owner"] = family_devices_map.get(decoded_name)
                        client["is_presence_device"] = decoded_name in family_devices_map
                    clients.append(client)
                influx.insert_connected_deco_device_data(__file__, clients)
                last_update = time.time()
            driver.requests.clear()  # 傍受済みリクエストをクリア(メモリ対策)
            time.sleep(5)
            # 5分間データ更新がなければ再ログインのためにループを抜ける
            if time.time() - last_update > 300:
                break
    finally:
        if driver:
            driver.quit()
# 外側の再ログインループ — 例外や5分タイムアウト時に自動で再実行
while True:
    try:
        main_loop()
    except Exception:
        time.sleep(5)

コードの解説

なぜ直接HTTPリクエストではなくSeleniumを使うのか

DECOの管理APIはセッショントークン(stok)AES鍵の両方が必要です。stokはログイン後のHTTPリクエストURLに埋め込まれており、AES鍵はJavaScriptが実行された後にlocalStorageに書き込まれます。これらはブラウザ(JavaScript)が実行されて初めて生成される情報のため、curlなどの単純なHTTPクライアントでは取得できません。Seleniumを使ってブラウザを丸ごとエミュレートすることで、JavaScriptの実行結果も含めて全情報が取得できます。

encryptorAESとstokの役割

encryptorAESはAPIレスポンスの復号に使うAES-CBCの鍵とIVです。stokはDECOが発行するセッショントークンで、ログイン後のすべてのAPIリクエストURLに含まれています(例:/stok=XXXXXXXX/ds)。selenium-wireがインターセプトしたリクエスト一覧から stok= を含むURLを探し、正規表現で値を抽出しています。

傍受するAPIエンドポイント

DECOの管理画面は一定間隔で client?form=client_list エンドポイントにリクエストを送り、接続デバイス一覧を取得して画面に表示しています。このリクエストを driver.requests から探すことで、管理画面の「クライアントリスト」と同じデータをPythonから取得できます。

デバイス名のエンコーディング

DECOのAPIが返すデバイス名はBase64エンコードされており、内部にURL(パーセント)エンコードされた文字が含まれていることがあります。例えば日本語の「タロウのiPhone」は次のようにエンコードされています。

  1. 「タロウのiPhone」→ URLエンコード → %E3%82%BF%E3%83%AD%E3%82%A6%E3%81%AEiPhone
  2. URLエンコード済み文字列 → Base64エンコード → APIが返す文字列

そのためBase64デコード → URLデコードの順で2段階のデコードが必要です。

在宅判定プログラムとのデータ連携

このプログラムがInfluxDBに書き込んだclientデータは、別の在宅判定プログラム(family_device_status_record.py)から読み取られます。在宅判定プログラムは①DECO接続確認・②ping疎通確認・③ARP確認の3つを並列で実行し、いずれか一つでも確認できれば「在宅」と判断します。

iPhoneはスリープ中はpingに応答しませんが、Wi-Fiには接続し続けています。AndroidはWi-Fi接続が不安定な場合があります。複数の方法を組み合わせることで誤検知を大幅に減らすことができます。

# family_device_status_record.py より(在宅判定ロジックの抜粋)
# ①DECO接続確認(is_deco_connected): このプログラムが書き込んだデータを参照
# ②ping疎通確認(is_ping_reachable): 並列でpingを実行
# ③ARP確認(is_arp_found): ARPテーブルにIPが存在するか確認
ping_ok  = fd.get("is_ping_reachable", False)
deco_ok  = fd.get("is_deco_connected", False)
arp_ok   = fd.get("is_arp_found", False)
# 3つのうちいずれか1つでも確認できれば在宅と判定
is_connected = ping_ok or deco_ok or arp_ok

さらに、11分間状態が安定して変化がなく、かつ前回記録した「帰宅」「外出」状態と現在の状態が異なる場合にのみイベントを発火します。これにより電波の一時的な途切れや再接続による誤検知を防いでいます。

動作確認

手動実行テスト

python3 /home/<username>/Projects/smarthome/core/record/lan/lan_status_record_from_deco.py

正常に動作していると、5秒ごとに以下のようなログが出力されます。

クライアントリストを監視中...
最新のクライアントリストリクエストを取得: http://192.168.xx.1/stok=XXXXXXXX/ds?...
復号されたJSON: {'error_code': 0, 'result': {'client_list': [...]}}
同一データ連続カウント: 0
[{'name': 'タロウのiPhone', 'ip': '192.168.xx.xx', ...}, ...]

InfluxDBでデータを確認

InfluxDB管理画面またはCLIで client measurementにデータが書き込まれていることを確認します。

# InfluxDB 2.x CLI で確認
influx query 'from(bucket: "smarthome") |> range(start: -1m) |> filter(fn: (r) => r._measurement == "client")'

systemdサービスとして常時起動する

手動実行で動作確認できたらsystemdサービスとして登録し、OS起動時に自動で開始されるよう設定します。

# /etc/systemd/system/deco-lan-recorder.service
[Unit]
Description=DECO LAN Status Recorder
After=network.target
[Service]
ExecStart=/usr/bin/python3 /home/<username>/Projects/smarthome/core/record/lan/lan_status_record_from_deco.py
WorkingDirectory=/home/<username>/Projects/smarthome
Restart=always
RestartSec=5
User=<username>
[Install]
WantedBy=multi-user.target
sudo systemctl daemon-reload
sudo systemctl enable deco-lan-recorder
sudo systemctl start deco-lan-recorder
sudo systemctl status deco-lan-recorder

まとめ

公式ローカルAPIを持たないTP-Link DECOルーターから、selenium-wireによるHTTPトラフィック傍受とAES-CBC復号を組み合わせることで、接続デバイスのリアルタイム情報を取得する仕組みを紹介しました。

  • selenium-wireを使えばブラウザの内部通信を傍受でき、通常のHTTPクライアントでは取得できない情報にアクセスできる
  • AES鍵はlocalStorageから取得でき、pycryptodomeで復号できる
  • デバイス名はBase64+URLデコードの2段階処理で可読化できる
  • 同一データ連続検出と5分間タイムアウトで安定した長時間稼働を実現できる
  • DECO接続・ping・ARPの3方向チェックを組み合わせることで、誤検知の少ない堅牢な在宅判定ができる

このデータをベースに「帰宅時の照明自動点灯」「外出時のエアコン自動オフ」「Google Homeによる帰宅アナウンス」など、さまざまなスマートホーム自動化が実現できます。

タイトルとURLをコピーしました