zplt.net / ブログ

Raspberry Pi Pico WでWoLを実現した話

( 更新)

こんにちは、zpltです。

Raspberry Pi Pico Wを使って、外出先のスマホから家のPCを起動できるようにしてみた話です。電源を切った状態からでも、スマホのボタンを押すと1〜2分でPCが立ち上がるようになりました。いろいろつまずきながら何とか動いたので、記録として残しておきます。

やりたかったこと

家のPCには、自分用のWebツールをいくつか置いています。外出先からはTailscale(離れた場所の機器どうしを安全につなげるサービス)を使ってアクセスしているのですが、PCの電源が切れていると当然どれも使えません。かといって、ずっとつけっぱなしにしておくのは電気代がもったいないです。

Wake-on-LAN(WoL)という仕組みがあるということは知っていました。LANの中に「マジックパケット」という合図を流すと、電源が切れているPCでも起動できるというものです。

ただ、ここで問題がありました。合図を送るのは、家の中でずっと起きている別の機器でないといけません。PC本人は寝ているので、自分で自分を起こすことはできないからです。

どの機器を使うか

合図を送る係として、手持ちの機器でいくつか候補を考えました。

候補 結果
Raspberry Pi 最初はこれで作るつもりでしたが、手元のものが壊れていて起動しませんでした…
Arduino Mega 2560 R3 ネットワークの機能がついていませんでした。部品を足しても、外から安全に呼び出す方法が作れなさそうだったので断念
Raspberry Pi Pico W Wi-Fiがついていて、MicroPythonでHTTPSの通信もできるということがわかりました。電気もほとんど使わないので、これに決めました

ただ、Pico WではTailscaleが動きません。そこで「外のスマホからPico Wに直接つなぐ」のではなく、「スマホとPico Wの両方から、インターネット上の同じ場所にアクセスする」作戦にしました。

仕組み

その「インターネット上の同じ場所」として使ったのが、ntfy.shという無料の通知サービスです。チャンネル(トピック)に文字を書き込んだり、読み出したりできるので、伝言板のように使えます。

[スマホ] ──「wake」を書き込む──> [ntfy.sh 命令用チャンネル] <──10秒ごとに確認── [Pico W]
[スマホ] <──返事を読む───────── [ntfy.sh 返事用チャンネル] <──返事を書き込む── [Pico W]
                                                                                 │ マジックパケット
1階: ルーター ))) Wi-Fi ((( 2階: Wi-Fi ブリッジ ──LANケーブル── [PC]

スマホが「wake」と書き込むと、Pico Wがそれに気づいてPCに合図を送ります。スマホもPico Wも、自分からntfy.shにアクセスしに行くだけなので、ルーターの設定(ポート開放など)は何も変えなくて済みました。

使ったもの

項目 内容
マザーボード MSI MPG X570 GAMING EDGE WIFI
有線LAN マザーボードについているRealtek PCIe GbE
OS Windows 11
合図を送る係 Raspberry Pi Pico W(MicroPython v1.29.0)
Wi-Fiブリッジ Buffalo WSR-3000AX4P(WBモード)
伝言板 ntfy.sh

PC側の設定

やってみると、WoLは「合図を送る側」より「受け取る側(PC)」の設定のほうが大変だということがわかりました。必要だった設定はこちらです。

BIOS

Windows

LANの設定

電源を切っている間もLANがちゃんとつながったままになるように、省電力の機能をいくつか切りました。管理者として開いたPowerShellで、次のコマンドを実行します。

$d = 'Realtek PCIe GbE Family Controller'
# 電源を切ったときに通信速度を落とさない(10 Mbps に落ちると、つながらない機器があるらしい)
Set-NetAdapterAdvancedProperty -InterfaceDescription $d -RegistryKeyword WolShutdownLinkSpeed -RegistryValue 2
# 省電力型イーサネット(EEE)とグリーンイーサネットをオフにする
Set-NetAdapterAdvancedProperty -InterfaceDescription $d -RegistryKeyword '*EEE' -RegistryValue 0
Set-NetAdapterAdvancedProperty -InterfaceDescription $d -RegistryKeyword EnableGreenEthernet -RegistryValue 0

うまく設定できているかは、PCの電源を切ったあとも、LANポートのランプが点いたままかで確認できます。ランプが消えてしまうなら、LANに電気が来ていないので、合図を送っても起きてくれません。

1階と2階問題

もうひとつの壁は、ルーターが1階、PCが2階にあることでした。PCは普段Wi-Fiでつないでいるのですが、調べてみると、PCのWi-Fi(Intel AC 3168)は電源を切った状態からの起動に対応していないということがわかりました。WoLには有線LANが必要です。

とはいえ、1階から2階までLANケーブルを引くのは大変です。困っていたところ、使っていないBuffaloのWi-Fiルーター(WSR-3000AX4P)が手元にあるのを思い出しました。説明書を読んでみると、このルーターにはWBモードがあって、1階のルーターとは無線でつながりつつ、自分のLANポートを有線として使えるということがわかりました。

これを2階のPCの横に置いて、PCとLANケーブルでつなぎました。PCからは有線LANにつながっているように見えるので、合図もちゃんと届きます。

Pico Wのプログラム

Pico WのプログラムはMicroPythonで書きました。やっていることはシンプルで、こんな流れになっています。

  1. Wi-Fiにつないで、時計を合わせる
  2. ntfy.shの命令用チャンネルを10秒ごとに確認する
  3. 「wake」が来ていたらPCに合図を送り、PCが起動したかを最大3分見守る
  4. 結果を返事用チャンネルに書き込む

ここからは、ポイントになる部分をコードと一緒に紹介します(Wi-Fiのパスワードなどの設定はconfig.pyという別のファイルに分けています)。

マジックパケットを送る

マジックパケットは、「0xFFを6個」のあとに「PCのMACアドレスを16回」並べただけの、102バイトのデータです。思っていたよりずっと単純で驚きました。これを、LANの全員に届くアドレス(ブロードキャストアドレス)に向けて送ります。届かなかったときのために、よく使われるポート9番と7番の両方に、3回ずつ送っています。

def send_magic_packet():
    raw = bytes(int(x, 16) for x in config.PC_MAC.split(":"))
    packet = b"\xff" * 6 + raw * 16
    s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    try:
        s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
        for _ in range(3):
            for port in (9, 7):
                s.sendto(packet, (config.BROADCAST, port))  # 例: 192.168.0.255
            time.sleep_ms(100)
    finally:
        s.close()

ntfy.shを確認する

ntfy.shは、URLにpoll=1を付けてアクセスすると、たまっているメッセージを返してくれます。sinceに「この時刻(またはこのメッセージ)より後」を指定できるので、まだ読んでいないメッセージだけを受け取れます。返ってくるデータは1行に1メッセージなので、1行ずつ読んでいきます。

def poll(since):
    r = requests.get("%s/json?poll=1&since=%s" % (TOPIC_URL, since), timeout=20)
    try:
        out = []
        for line in r.text.split("\n"):
            if not line.strip():
                continue
            m = json.loads(line)
            if m.get("event") == "message":
                out.append(m)
        return out
    finally:
        r.close()

ntfy.shにはずっとつなぎっぱなしにする方法もあるのですが、Wi-Fiが一瞬切れたときに、つながっているのかいないのか分からなくなりそうだったので、10秒ごとに確認しに行く方法にしました。これなら、1回失敗しても次の確認で元に戻ります。

昔のメッセージに反応しないようにする

ntfy.shはメッセージをしばらく残しておいてくれます。便利なのですが、Pico Wの電源を入れた瞬間に昔の「wake」を読んでしまうと、勝手にPCが起動してしまいます。そこで、まずインターネットで時計を合わせて(NTP)、「電源を入れた時刻より後」のメッセージだけを読むようにしました。時計の小さなずれを考えて、5秒だけさかのぼっています。

since = None
for _ in range(5):
    try:
        ntptime.settime()
        since = str(time.time() - 5)  # 時計のずれの分だけ少しさかのぼる
        break
    except Exception:
        time.sleep(2)
if since is None:
    since = "10s"  # 時計が合わせられなかったら「直近 10 秒」から

命令を実行する

「wake」が来たら、まずPCがもう起きていないかを確認して、寝ていたら合図を送ります。そのあとは10秒ごとにPCにアクセスしてみて、返事があったら「起動した」と判断します。PCはWi-Fiと有線の2つのアドレスを持っているので、どちらかが返事をすればOKにしています。

def pc_is_up():
    for _ in range(2):  # 1 回の失敗で「止まっている」と勘違いしないように 2 周する
        for host in config.PC_HOSTS:
            s = socket.socket()
            try:
                s.settimeout(3)
                s.connect(socket.getaddrinfo(host, 7861)[0][-1])
                return True
            except Exception:
                pass
            finally:
                s.close()
    return False
def handle(cmd):
    if cmd == "wake":
        if pc_is_up():
            publish("PC はすでに起動しています")
            return
        send_magic_packet()
        publish("起動の合図を送りました")
        start = time.time()
        while time.time() - start < BOOT_WAIT_SEC:  # 最大 3 分
            time.sleep(10)
            beat()  # あとで出てくる見張り役に「生きてるよ」と伝える
            if pc_is_up():
                publish("PC が起動しました(%d秒)" % (time.time() - start))
                return
        publish("PC の起動を確認できませんでした")
    elif cmd == "status":
        publish("PC は起動中です" if pc_is_up() else "PC は停止しています")

最初はボタンを何回も押してしまって、同じ命令がたくさん届き、「すでに起動しています」が何通も返ってきてしまいました。そこで、まとめて届いた同じ命令は1回だけ実行して、PCの起動を待っている間に届いた分は捨てるようにしました。

msgs = poll(since)
if msgs:
    since = msgs[-1]["id"]
    cmds = [(m.get("message") or "").strip().lower() for m in msgs]
    for cmd in ("wake", "status"):
        if cmd in cmds:
            handle(cmd)
            late = poll(since)  # 待っている間に届いた分は捨てる
            if late:
                since = late[-1]["id"]
            break

Wi-Fiにつなぐ

Wi-Fiブリッジを通すと、Pico WがIPアドレスをもらえるまでに1分以上かかることがありました。wlan.status()の値が2(Wi-Fiにはつながったけど、まだIPアドレスをもらえていない状態)の間は最大3分待って、それ以外で1分たったらつなぎ直すようにしています。また、つながった直後は通信が失敗しやすかったので、3秒待ってから使い始めます。

def wifi_connect():
    if wlan.isconnected():
        return True
    wlan.active(True)
    wlan.config(pm=0xa11140)  # 省電力モードを切って、反応をよくする
    wlan.disconnect()
    time.sleep(1)
    wlan.connect(config.WIFI_SSID, config.WIFI_PASSWORD)
    for i in range(360):  # 0.5 秒 × 360 回 = 最大 3 分
        if i >= 120 and wlan.status() != 2:  # 1 分たっても IP 待ちでなければつなぎ直す
            break
        beat()
        if wlan.isconnected():
            time.sleep(3)  # つながった直後は少し待つ
            return True
        time.sleep(0.5)
    return False

固まったら自動で再起動する

Wi-Fiが切れたタイミングによっては、プログラムが通信の途中で止まったまま戻ってこないことがありました。置きっぱなしで使うので、これは困ります。そこで、ウォッチドッグ(見張り役)を作りました。プログラムが進むたびにbeat()で「生きてるよ」と記録しておき、タイマーで10秒ごとに確認して、2分以上記録が止まっていたらPico Wを再起動します。

STALL_MS = 120000
_last_beat = time.ticks_ms()
def beat():
    global _last_beat
    _last_beat = time.ticks_ms()
def _watchdog(_):
    if time.ticks_diff(time.ticks_ms(), _last_beat) > STALL_MS:
        machine.reset()
machine.Timer(period=10000, mode=machine.Timer.PERIODIC, callback=_watchdog)

Pico Wには本物の(ハードウェアの)ウォッチドッグもあるのですが、最長でも8秒くらいしか待てないので、時間のかかるHTTPSの通信と相性が悪そうでした。また、一度動かすと止められないので、プログラムを書き換えるときにも困りそうです。なので今回は、タイマーを使った自作の見張り役にしました。

スマホ側

スマホ側は、電源ボタンが1つだけのページを作って、GitHub Pagesに置きました。ntfy.shはブラウザから直接アクセスできる(CORSに対応している)ので、サーバーを用意しなくても作れました。

命令を送って、返事を受け取る

ボタンを押すと、命令用チャンネルに「wake」を送ります。返事用チャンネルはEventSource(サーバーから届いたものをリアルタイムで受け取れる仕組み)で受け取って、Pico Wの返事をそのままページに表示しています。ページを開いたときには「status」を送って、PCが起動中か停止中かを表示します。

const NTFY = "https://ntfy.sh/";
async function send(cmd) {
  const r = await fetch(NTFY + TOPIC, { method: "POST", body: cmd }).catch(() => null);
  return !!(r && r.ok);
}
// 返事用チャンネルを受け取り続ける(切れても自動でつなぎ直してくれる)
const es = new EventSource(NTFY + TOPIC + "-status/sse");
es.onmessage = (e) => {
  const m = JSON.parse(e.data);
  if (m.event === "message") onReply(m); // 「起動中」「停止中」などの表示を切り替える
};

チャンネル名の渡し方

ページは誰でも見られる場所に置いているので、チャンネル名はページの中には書いていません。代わりに、URLの#より後ろに書いて渡すようにしました。#より後ろはサーバーに送られないので、ページを見られても大丈夫です。

const fromHash = decodeURIComponent(location.hash.slice(1));
const TOPIC = /^[\w-]{12,64}$/.test(fromHash) ? fromHash : localStorage.getItem("topic");

試してみたところ、iPhoneのホーム画面に追加したページは、ブラウザとは保存場所が別になるということがわかりました。そのため、#付きのURLのままホーム画面に追加して使っています。

つまずいたところ

Wi-Fiにつながった直後の通信が失敗する

Pico Wは、Wi-Fiにつながったと表示された直後に通信を始めると、名前の解決に失敗したり(OSError -2)、HTTPSの接続が途中で切れたり(OSError 103)することがよくありました。つながってから数秒待つようにしたら、落ち着きました。

Wi-Fiブリッジを通すとIPアドレスをもらうのが遅い

最初は60秒たったらWi-Fiにつなぎ直すようにしていたのですが、IPアドレスをもらっている途中で毎回やり直してしまっていました。ログを見てやっと気づきました。IPアドレスを待っている間は最大3分待つようにして、解決しました。

テストのタイミングで無視される

「受信待ちになりました」というログを見た瞬間にテストの命令を送ると、なぜか無視されました。調べてみると、ntfy.shの時計では、Pico Wが待ち始める1秒前のメッセージになっていたのが原因だということがわかりました。先ほどの「5秒さかのぼる」処理は、これがきっかけで入れたものです。

まとめ

ほとんどお金をかけずに、外出先から家のPCを起動できるようになりました。使ったのはPico Wと、使っていなかったWi-Fiルーターと、ntfy.shだけです。やってみてわかったことをまとめると、こんな感じです。

止まっていても押せる電源ボタンがあるだけで、PCを気軽に切っておけるようになりました。思っていたよりずっと便利です。

この記事を共有

X でポスト