TECH · RASPBERRY PI · CISCO

WiFi不安定が再発したのでRaspberry Pi + LINE + Cisco APで自動チャンネル変更システムを作りました

Cisco APのWiFi不安定が再発。毎回SSHで手動変更するのが面倒なので、Raspberry Pi 4BでAPスキャン→LINE承認→自動チャンネル変更する仕組みを作りました。paramiko・systemd・LINE v3・Cloudflare Tunnelのハマりどころを全部記録します。

tech 2026-04-19 35 min read by ちらりん
cover · 1024×1024

はじめに

前回、自宅のCisco自立型APが不安定になった話を書きました。Nest MiniとMeraki MR44の干渉が原因で、DFS帯(W53)への移行で解決したあの一件です。

その後また再発しました。 周囲のAP事情は流動的で、隣家や別室のWiFiが使うチャンネルは日々変わります。一度CH52に逃げても、別のAPが同じチャンネルに来れば話は振り出しです。

毎回SSHで show dot11 associations を叩いて、干渉を確認して、dot11 channel コマンドで変更する。面倒です。自動化しました。

この記事では、Raspberry Pi 4Bを電波監視センサーにして、LINEで承認するだけでCisco APのチャンネルが変わる仕組みを作った話を書きます。paramikoとCisco IOS 15.3の相性問題、systemdのPATH問題、LINE Webhookとルーティングの不一致など、同じ構成を作る人が必ず踏む罠を一通り共有します。


再発した問題

前回の記事で、Cisco AIR-CAP1702IのチャンネルをW53帯(CH52)に移しました。Nest MiniとMerakiの干渉を避けるための措置です。数週間は快適でした。

ところが、また遅くなりました。体感で「動画のロードが遅い」「SSHがもたつく」。AIR-CAP1702Iで show dot11 radio1 channel-width を叩くと、CH52のノイズフロアが上がっています。周囲のAPスキャン結果を見ると、CH52を使う別APが増えていました。

原因がわかっても、手順がつらい。

  1. PCからSSHで自宅AP(AIR-CAP1702I)にログイン
  2. show dot11 radio1 scan で周囲の混雑を確認
  3. 空いているチャンネル(CH56 / CH60 / CH64)を選択
  4. dot11 radio1 channel <ch> で変更
  5. write memory で保存

やることは毎回同じ。決まりきった手順は機械の仕事です。


解決アプローチの設計

目指すのは「気づいたら快適になっている」状態ですが、勝手にチャンネルを変えると体験が壊れます。オンライン会議中に切断されたら最悪です。

そこで、検知は自動・判断は人間というハイブリッド設計にしました。

システム構成

flowchart TD RPI["🍓 Raspberry Pi 4B\n(wlan0)"] SCAN["周囲のAP情報\niw dev wlan0 scan"] SCORE["スコア計算\nAP数×重み + 信号強度"] CHECK{閾値超え?} LINE["LINE Messaging API\nFlex Message送信"] USER["👤 ユーザー\nスマホで承認"] FLASK["Flask Webhook\nCloudflare Named Tunnel\nwifi.chillarin39.com"] AP["🔌 Cisco AIR-CAP1702I\ndot11 radio1 channel xx"] PGW["📊 Prometheus\nPushgateway"] RPI -->|"iw scan"| SCAN SCAN --> SCORE SCORE --> CHECK CHECK -->|"Yes"| LINE LINE -->|"Flex Message"| USER USER -->|"承認タップ"| FLASK FLASK -->|"Postback"| RPI RPI -->|"paramiko SSH"| AP RPI -->|"metrics push"| PGW

要素ごとの判断

  • スキャン: Raspberry Pi 4Bの内蔵WiFiで iw dev wlan0 scan を叩く。専用機材は不要
  • スコアリング: チャンネルごとに「AP台数 × 重み + 信号強度」で混雑度を算出
  • 承認UI: LINE Messaging API v3のFlex Message + Postbackでインタラクティブに
  • Webhook受信: Cloudflare Named Tunnelで固定URL(wifi.chillarin39.com)を発行。ルーターのポート開放不要
  • 変更対象: W53帯のCH52/56/60/64のみ。DFSレーダー検出のリスクは許容
  • メトリクス: Prometheus Pushgatewayへ送信して後でGrafana化できる余地を残す

「LINEで通知だけ」で終わらせず、ワンタップで承認→自動変更→完了通知、まで流すのが体験的に重要です。


ハマりポイントと解決策

ここからが本題です。構成図は綺麗ですが、実装中は何度も壁にぶつかりました。

1. Cisco IOS 15.3 と paramiko のSSH接続エラー

最初に立ちはだかったのがこれです。paramikoでCisco AIR-CAP1702I にSSHしようとすると、接続時点で切断されます。

snippet
paramiko.ssh_exception.SSHException: Incompatible ssh peer (no acceptable host key)

別のエラーパターンでは no mutual signature algorithm も出ました。

原因: Cisco IOS 15.3はRSA鍵のSHA-1署名(ssh-rsa)しかサポートしていません。一方、最近のparamikoはデフォルトで rsa-sha2-512 / rsa-sha2-256 を優先し、SHA-1をネゴシエーションから外します。結果、共通アルゴリズムがなくて落ちます。

解決策: disabled_algorithms で新しい署名方式を無効化します。

python
import paramiko

client = paramiko.SSHClient()
client.set_missing_host_key_policy(paramiko.AutoAddPolicy())
client.connect(
    hostname="10.1.1.10",
    username="chillarin",
    password=password,
    disabled_algorithms={
        "pubkeys": ["rsa-sha2-512", "rsa-sha2-256"],
    },
    look_for_keys=False,
    allow_agent=False,
    timeout=10,
)

これでSHA-1にフォールバックして接続できます。古いIOS機器をparamikoで触るときの定石です。

2. systemd からは iw コマンドが見つからない

CLIで動いていたスクリプトを systemd unit 化したら、いきなり動かなくなりました。

snippet
FileNotFoundError: [Errno 2] No such file or directory: 'iw'

原因: systemdサービスに渡されるPATHは最小限で、/usr/sbin が含まれていないことがあります。iw/usr/sbin/iw にあるため見つけてもらえません。

解決策: フルパスで呼び出します。

python
subprocess.run(
    ["/usr/sbin/iw", "dev", "wlan0", "scan"],
    capture_output=True, text=True, timeout=30,
)

systemdのunitファイルで Environment=PATH=... を足しても良いのですが、呼び出し側でフルパスを書く方が事故が少ないと判断しました。

3. wlan0 が DOWN のままになる

systemdでサービスを起動しても、iw scan が毎回エラーを返します。ip link show wlan0 を見ると state DOWN

原因: Raspberry Pi OSの NetworkManager が wlan0 を unmanaged に設定していました。ルーター接続には有線を使っていたためWiFiは放置状態で、インターフェースが上がっていませんでした。

解決策: systemd unit の ExecStartPre で wlan0 を UP させます。

ini
[Service]
Type=simple
ExecStartPre=/usr/sbin/ip link set wlan0 up
ExecStart=/usr/bin/python3 /opt/wifi-autoswitch/scan.py
Restart=on-failure

サービス起動前に必ずインターフェースが上がる状態を担保します。

4. LINE Webhook が 404 を返す

Flaskアプリをデプロイして、Cloudflare Named Tunnelで公開して、LINE Developers ConsoleにWebhook URLを登録しました。検証ボタンを押すと 404 Not Found

原因: LINE Console側のURLは https://wifi.chillarin39.com/wh/callback にしていたのに、Flask側のルートは @app.route("/callback") になっていました。プレフィックス /wh/ がFlaskに届いていないだけなのに、気づくまで1時間溶かしました。

解決策: ルートをプレフィックス付きで統一します。

python
WEBHOOK_PATH = os.environ.get("LINE_WEBHOOK_PATH", "callback")

@app.route(f"/wh/{WEBHOOK_PATH}", methods=["POST"])
def webhook():
    signature = request.headers.get("X-Line-Signature", "")
    body = request.get_data(as_text=True)
    try:
        handler.handle(body, signature)
    except InvalidSignatureError:
        abort(400)
    return "OK"

環境変数で切り替えられるようにしておくと、Webhook URLの衝突も避けられます。

5. Pushgateway が 400 エラーを返す

Prometheus Pushgatewayにメトリクスをpushすると、途中から400が返るようになりました。

原因: チャンネルごとにメトリクスを生成するループの中で、毎回 # TYPE wifi_channel_score gauge を書いていたため、同一メトリクス名に対して複数の TYPE 宣言が並んでしまっていました。Pushgatewayは重複 TYPE を拒否します。

解決策: # TYPE 宣言をループの外に1回だけ出す。

python
lines = ["# TYPE wifi_channel_score gauge"]
for ch, score in channel_scores.items():
    lines.append(f'wifi_channel_score{{channel="{ch}"}} {score}')
payload = "\n".join(lines) + "\n"

requests.post(
    f"{PUSHGATEWAY_URL}/metrics/job/wifi_scan/instance/rpi4b",
    data=payload.encode(),
    timeout=5,
)

Prometheus exposition formatの基本ですが、ループ内でTYPEを書いてしまう事故は案外多いです。

6. cloudflared tunnel login が SSH切断で失敗する

Cloudflare Named Tunnelを発行するときに cloudflared tunnel login を実行する必要があります。ブラウザ認証のためURLが出るので、WSLからSSH接続した端末でバックグラウンドに回していたところ、毎回途中で死にました。

原因: & でバックグラウンド化しただけでは、親SSHが切れた瞬間にジョブも一緒に死にます。認証は数分待つ必要があるので、SSHセッションに依存させてはいけません。

解決策: screen で detach してから実行します。

bash
screen -dmS cf-login bash -c 'cloudflared tunnel login > ~/cf_login.log 2>&1'

これでSSHを切ってもログインプロセスは生き続けます。~/cf_login.log に表示されるURLをあとでブラウザで開けば認証完了です。Tunnel運用の小ネタですが、同じ罠は Ansible 経由でのデプロイにも効きます。


完成したシステムの動作

全部つなげたあとの動きはこうなります。

  1. 5分ごとにスキャン: Raspberry Pi 4Bが iw scan で周囲のAPを収集
  2. スコア計算: チャンネルごとに「AP数 × 重み + 信号強度合計」で混雑度を出す
  3. 閾値超え検知: 現在チャンネルのスコアが25を超えたら通知条件
  4. LINE通知: Flex Messageで「現在CH52: score 31 / 推奨CH60: score 8 / 変更しますか?」を送る
  5. 承認: ユーザーが「変更する」ボタンを押す
  6. SSH実行: Raspberry PiがparamikoでCisco APに入り、dot11 radio1 channel 60 を送信
  7. 完了通知: 変更結果をLINEに返す
  8. メトリクス蓄積: チャンネルごとのスコアをPushgatewayにpush

操作はスマホ1タップ。手動SSHしていた頃の手順のうち、人間が介在するのは「変更するかどうか」の判断だけになりました。

利用可能チャンネルはW53帯(CH52/56/60/64)に限定しています。W56帯も使えますが、気象レーダー検出時の60秒停波が実害につながるため、意図的に絞っています。スコアリング対象の母集団を小さくすることで判断ロジックもシンプルになる副次効果つきです。

Prometheusメトリクスは将来的にGrafanaダッシュボードにまとめる予定です。「どのチャンネルが時間帯ごとに空いているか」を可視化できれば、初期配置の判断材料にもなります。


AIが作っても、運用は人が見る

システムが動き始めてしばらく経ったころ、またLINEに変更通知が届くようになりました。ところがスマホでWiFiの様子を見ても、現在チャンネルは全く問題なく快適です。「あれ、おかしいな」と気づいて調べてみると、原因は意外なところにありました。

Raspberry Piが自宅APのSSIDまで「外部の混雑しているAP」としてスコアに加算していたのです。

我が家のAPは5GHzで TN NETOWRK2、2.4GHzで TN NETOWRK 2.4GHz という2つのSSIDを飛ばしています。自分自身のAPを周囲のAPと同列に扱えば、そのチャンネルは当然「混雑している」ことになります。自爆でした。

解決は単純で、設定ファイルに除外リストを追加するだけです。

yaml
# config.yaml
own_ssids:
  - "TN NETOWRK2"
  - "TN NETOWRK 2.4GHz"

スキャン結果をスコアリングする前に、own_ssids に含まれるSSIDは集計から外す。これだけで誤通知はピタッと止まりました。

ここで気づいたのは、AIと一緒にシステムを作っても、「実際の運用感覚」はやはり人間側が持っているということです。今回のケースでは、「スマホで見ると問題ないのに通知が来る」という違和感に人間が気づかなければ、そのまま誤ったチャンネル変更が繰り返されていたはずです。自動化の便利さに甘えて、通知が来るたびに条件反射で承認していたら、むしろ状態は悪化していたかもしれません。

自動化は強力です。ただ、定期的に「動いていること」だけでなく「正しく動いていること」を人間の目で確認する工程は、どれだけ自動化が進んでも残り続けるのだと実感した出来事でした。


まとめ

手動SSH運用からの解放感は予想以上でした。

  • 面倒なチャンネル変更作業がゼロになった
  • 「勝手に変わる」ことによる会議中の切断リスクをLINE承認フローで回避できた
  • Raspberry Pi 4B + Cloudflare無料プラン + LINE Messaging API(無料枠)で完結

この記事で共有したハマりどころは、どれも一度踏むと30分〜数時間溶かす類のものです。

  • Cisco IOS 15.xを自動化対象にするならparamikoの disabled_algorithms は必須
  • systemdから叩く外部コマンドはフルパスで書く
  • NetworkManager管理下のインターフェースは起動前にUPしておく
  • LINE WebhookのパスはFlask側と一字一句合わせる
  • Pushgatewayの # TYPE 宣言はループの外に1回だけ
  • cloudflared tunnel loginscreentmux で detach する

「気づいたら快適」を目指すインフラ自動化では、検知と判断を分けるのがコツだと改めて感じました。判断を人間に残すことで、暴走のリスクを下げつつ、面倒な手順だけ機械に任せられます。

次の課題は、Grafanaダッシュボードでチャンネル混雑のヒートマップを作ることです。その話はまた別の記事で書きます。

· · ·

コメント