技術

Tapoの見守りカメラをPiPで視聴できるようにした

自宅に置いてあるTP-Link Tapoの見守りカメラ(C200)を、もっと気軽に見られるようにしたかった。困っていたのは、Tapoの公式アプリにはPicture-in-Picture(PiP)が無いこと。アプリをバックグラウンドに回すと再生が止まってしまうので、カメラを見ながら他の操作をする、ということができない。

だったらブラウザでWebRTC配信して、PiP対応の自前ページを作ればいいじゃないか、というのが今回のモチベーション。録画も動体検知もいらない、LAN内(と出先からTailscale経由)で1〜2人が見るだけの規模でいい。

カメラはDHCPで動かしているので、家のルーターの都合でIPが変わることがある。変わったら手動で設定し直す運用は絶対に忘れるので、IPが変わっても自動で追従する仕組みも自作した。

構成は go2rtc (既製のWebRTC配信サーバー) + 自作のIP追従サイドカー(Go)の2コンテナ。今回はハマりどころが多かったので、備忘録として残しておく。

構成のおおまかな方針

                 [ Tapo C200 カメラ (DHCP, IPが変わる) ]
                    ▲                          │
       ARP / ping+arp                         RTSP
  (MACから現在IPを解決)                    (go2rtcが取得)
                    │                          ▼
     [ sidecar (Go) ] ───────────────▶ [ go2rtc (Docker) ]
                       GET/PATCH               │
                      /api/streams            WebRTC
                   (死活確認・ソース更新)          ▼
                                    [ Chrome (PiP対応ページ) ]
                                      LAN内 / Tailscale経由
  • 映像配信は go2rtc に任せる。手を入れず、落ちても配信自体は継続させたい
  • カメラへの接続はTapoアプリの「詳細設定 > カメラアカウント」で設定したローカルRTSPアカウントを使う(rtsp://user:pass@IP:554/stream1)
  • IP追従は別コンテナのサイドカーが go2rtc のHTTP APIを監視し、不通が続いたときだけMACアドレスから現在のIPを探して更新する
  • 開発機はMac(Docker Desktop)、将来的にRaspberry Piなどに移行予定

音声は繋いでみるまで半信半疑だったが、G.711 PCMA/8000Hzで飛んでくることが分かった。WebRTCはG.711(PCMU/PCMA)をmandatory-to-implementコーデックとして規定しているので、トランスコード設定は何も書かずに素通りしてブラウザで再生できた。

ハマりどころ1: Docker Desktop for Macではhost networkingが使えない

go2rtcのWebRTCはICE candidateとして到達可能なIPをブラウザに教える必要がある。LAN内での利用なら、一番シンプルなのは network_mode: host でホストのIPをそのまま使う方法だが、Docker公式のissueにある通り、Docker Desktop for Macは(Windowsも)host networkingを正式サポートしていない。中身はLinux VM上で動いているので、コンテナのネットワーク名前空間はMacの実NICと直結していない。

なので、bridgeモード+ポート固定公開(ports:)+ webrtc.candidates にMacのLAN IPを明記する方式にした。この設定はRaspberry Pi移行後もそのまま使えるので、むしろ可搬性の面では都合が良かった。

webrtc:
  listen: ":8555"
  candidates:
    - "${HOST_LAN_IP}:8555"

go2rtc.yaml${VAR} / ${VAR:デフォルト値} という記法で環境変数を展開できるので、Mac用の値とPi用の値を切り替えるのに設定ファイル自体は1つで済んだ。

ハマりどころ2: 自前の視聴ページでPiPボタンを作る

go2rtc同梱のstream.htmlは動作確認用のシンプルなビューワーで、PiPボタンは付いていない(同梱のvideo-rtc.jsを実際に読んで確認した)。なので api.static_dir で自前のHTMLを配信することにした。ただしWebRTCのシグナリング部分を自分で書くのは筋が悪いので、go2rtc公式の video-rtc.js をvendoringして再利用し、その上にUIだけ被せる形にした。

ポイントは2つ。

  1. Chromeの自動再生ポリシー(参考)で、音声付きの自動再生はブロックされる。video-rtc.jsは再生に失敗したら自動でmutedにして再試行する作りになっていたので、初期表示はミュート、ボタンでミュート解除という導線にした
  2. requestPictureInPicture() はユーザー操作イベントハンドラの中で同期的に呼ぶ必要がある。awaitを挟んだ後に呼ぶとNotAllowedErrorになるので、ボタンのクリックハンドラの中で即座に呼ぶ必要がある
pipBtn.addEventListener('click', () => {
  player.video.requestPictureInPicture();
});

IP追従サイドカー(Go)を自作する

go2rtc本体には手を入れたくないので、別コンテナのGoプログラムを書いた。設計はシンプルにした。

  1. POLL_INTERVAL(既定30秒)ごとに go2rtc の GET /api/streams?src=<name>&video=all&audio=all を叩く。このクエリ付きエンドポイントはgo2rtcに実際にRTSP接続を試させるので、確実な死活判定になる(クエリなしのGET /api/streamsはキャッシュされた状態を返すだけで、実際には繋ぎに行かない)
  2. 連続FAILURE_THRESHOLD回(既定3回)失敗して初めて「本当に落ちている」と判断し、復旧処理に入る。1〜2回の一時的な失敗で動くと誤検知でストリームを不要に貼り直してしまう
  3. MACアドレスから現在のIPを解決し、直前に適用したIPと違っていたら PATCH /api/streams?name=<name>&src=<新URL> でソースを更新する

更新にはPUTではなくPATCHを使っている。PUTはStreamオブジェクトを丸ごと新規作成する処理を呼ぶため、既存の視聴者(consumer)が古いオブジェクトに取り残されてしまう可能性がある。PATCHは同一オブジェクトのソースURLだけ書き換えるので、視聴中の接続を維持できる。これは公式のOpenAPI定義だけでは分かりにくく、実際のGoコード(internal/streams/api.go)を読んで確認した。

MAC→IPの解決はOSで実装を分けた

ARPは本質的にL2(イーサネットフレーム)の仕組みなので、Docker Desktop for Macのようにコンテナが物理LANのブロードキャストドメインに参加できない環境では、コンテナ内でARPを送っても届かない。network_mode: hostですら、Mac上ではLinux VM内の仮想NICを指すだけで、Macの本当のWi-FiやEthernetには繋がらない。

本番想定のRaspberry Pi(Linux)では `mdlayher/arp` を使って生ARPリクエストをサブネット内の候補IPへブロードキャストし、応答のMACアドレスを比較する方式にした。CAP_NET_RAWが必要だが、Dockerのデフォルトcapabilityセットにはroot実行のコンテナなら最初から入っている。

Macでの開発時は、そもそもコンテナ化を諦めて、サイドカーだけネイティブ(go run)で動かすことにした。macOS版のARP解決は ping でサブネット全体をスイープしてOSのARPキャッシュを温め、arp -n <ip> で読み出す方式にしている(mdlayher/arpはAF_PACKET依存でLinux専用のため、そもそも使えない)。

実装中に踏んだ実バグ

macOS版のping+arpスイープを、並列度32・デフォルトのSCAN_TIMEOUT 3秒で書いたところ、254ホストある/24サブネットの全件スイープが全然時間内に終わらなかった。ping1回のタイムアウトが1秒だとすると、並列32だと254/32≒8ラウンド、つまり最悪8秒近くかかる。実機で試して初めて発覚するタイプのバグで、並列度を256(サブネット全体を一斉に投げられる数)に上げ、デフォルトのタイムアウトも5秒に緩めて解決した。3秒程度のタイムアウトは「余裕を持たせたつもり」でも、実際にサブネットを舐める処理の実測値を見ないと分からない。

実際に動かしてみる

Mac上でgo2rtcを起動し、ブラウザで映像・音声・PiPを確認。サイドカーをネイティブで動かして、わざと存在しないストリーム名を指定して障害を再現したところ、

stream probe failed (1回目、2回目、3回目)
camera IP changed, stream source updated

という流れが実際のカメラに対して約1.6秒で完走した。実機の192.168.1.0/24サブネットに対してping+arpのスイープが本当に機能していることが確認できて、地味に一番嬉しかった瞬間だった。

おまけ: Tailscaleで外出先からも見られるようにした

webrtc.candidatesはリストなので、優先度順に複数書ける。LANのIPに加えて、MacのTailscale IPも1行足すだけで、外出先(モバイル回線、Wi-Fiオフ)からも同じページで視聴できた。ICEのネゴシエーションが到達可能な方を勝手に選んでくれるので、コード側の分岐は一切不要だった。

candidates:
  - "${HOST_LAN_IP}:8555"
  - "${TAILSCALE_IP:127.0.0.1}:8555"

Tailscaleは認証済みのプライベートオーバーレイ網なので、「外部公開はしない」という当初の方針とも矛盾しない。

見送ったもの: スマホでの自動PiP

AndroidのChromeでバックグラウンドに回したときに自動でPiPになってほしかったが、調べるとChromeの「Automatic Picture-in-Picture」は現時点でデスクトップ版限定の機能だった。iOS SafariはPiP自体はv14以降フルサポートだが、バックグラウンド時の自動遷移についての一次情報は見つけられなかった。

Flutterでネイティブアプリ化すればiOSのAVPictureInPictureControllerで本物の自動PiPができるが、WebRTCの映像フレームをAVSampleBufferDisplayLayerに繋ぎ込むネイティブ実装が必要になり、カメラ1台・視聴者1〜2人という規模には見合わない。今回は手動PiPボタンでの運用に留めることにした。

まとめ

公式ドキュメントだけを読んで設計していたら、Docker Desktop for MacのARP制約で確実に詰んでいた。実機で動かして、ダメだったら理由を追って設計を変える、というサイクルを何度か回して初めてちゃんと動くところまで持っていけた。特にネットワーク周りは「ドキュメントに書いてあること」と「手元の環境で実際に起きること」のギャップが大きいので、早めに実機で確認するに越したことはない。

参考リンク