はじめに
TP-Link Tapo C220は、首振りに対応した屋内向けのネットワークカメラです。
Tapoアプリで「カメラのアカウント」を作ると、RTSPとONVIFで外部から接続できるようになります。
筆者は、このカメラをTapoアプリやクラウドを通さず、LANの中から自分のプログラムで操作したいと考えました。
そこで、RTSPとONVIF(Profile S)で何ができて何ができないかを実機で調べ、標準の経路で足りない機能は、Tapo独自の経路で補えるかを確かめました。
結果はPythonのライブラリにまとめ、ブラウザから一通り操作できる小さなWebUIも作っています。
- リポジトリ:skrtk98/tapo-c220
- 調査の詳細:GitHub Wiki
実測の値、生の応答の形、試してうまくいかなかったことはWikiにまとめました。
この記事では、ライブラリの使い方よりも、調査で分かったカメラの振る舞いを中心に書きます。
Tapo C220を自作の録画システムやホームオートメーションにつなぐときの判断材料として使えるよう、できなかったことと制約もあわせて書きます。
Tapo独自の経路(443番と8800番)は公開された仕様ではなく、TP-Linkが保証する使い方でもありません。
ファームウェアの更新で使えなくなる可能性があります。
この記事の内容は、筆者が所有する1台の個体で確かめた結果です。
調べた個体と環境
調べたのは、ハードウェア2.0、ファームウェア1.3.1 Build 260515のTapo C220です。
カメラとPCは同じLANにつなぎ、PC側はLinuxとPython 3.12、ffmpegを使いました。
カメラで開いていたポートは、443、554、2020、8800の4つです。
4つの経路と認証の違い
調べた結果、カメラを操作する経路は、標準の2つとTapo独自の2つに分かれました。
| 分類 | 経路 | ポート | 通信の形 | 認証 | 使いみち |
|---|---|---|---|---|---|
| 標準 | RTSP | 554 | RTPを流し続ける | カメラアカウント(Digest) | 映像と音声、録音と録画、静止画 |
| 標準 | ONVIF | 2020 | SOAPを1往復ずつ | カメラアカウント(WS-Security) | 首振り、検知イベント |
| Tapo独自 | 制御API | 443 | 暗号化したJSONを1往復ずつ | Tapoアカウント(ユーザーはadmin) | 設定、録画の一覧、検知の記録 |
| Tapo独自 | ストリーム | 8800 | 1本の接続に流し続ける | Tapoアカウント(接続ごとにDigest) | スピーカー、録画の取り出し |
認証に使うアカウントが、経路によって異なります。
RTSPとONVIFは、Tapoアプリで作ったカメラアカウントで認証します。
一方、Tapo独自の経路は、Tapoアプリにログインするときのクラウドのアカウントのパスワードを使います。
ユーザー名は、どちらのTapo独自の経路でも admin です。
機能ごとに、どの経路で実現できたかをまとめると次のようになります。
| 機能 | 経路 | 結果 |
|---|---|---|
| 映像と音声のストリーミング | RTSP | できる |
| 録音と録画 | RTSPをffmpegで保存 | できる |
| 静止画 | RTSPから1枚切り出す | できる(ONVIFのGetSnapshotUriは無い) |
| 首振り | ONVIF PTZ | できる(絶対、相対、連続、停止、プリセット) |
| 検知イベントの受信 | ONVIFのイベント | できる(カメラから通知させる形のみ。PullPointは不可) |
| スピーカーからの再生 | Tapo独自のストリーム | できる(RTSPとONVIFでは不可) |
| microSDの録画の一覧と取り出し | Tapo独自の制御APIとストリーム | できる(RTSPとONVIFでは不可。等速でしか届かない) |
| 設定の読み書き | Tapo独自の制御API | できる |
| 警報をすぐに鳴らす | Tapo独自の制御API | 確かな方法は見つからない。スピーカーへサイレンの音を送って代える |
以降では、標準の経路から順に、実機で分かった振る舞いを書きます。
RTSPで分かったこと
ストリームの種類
RTSPのURLは rtsp://ユーザー:パスワード@ホスト:554/stream1 の形です。
| パス | 映像 | 音声 |
|---|---|---|
stream1 |
H.264 High 2560×1440 | G.711 A-law 8kHz モノラル |
stream2 |
H.264 High 640×360 | G.711 A-law 8kHz モノラル |
stream8 |
MJPEG 640×360 | なし |
ONVIFは音声のサンプリング周波数を16kHzと答えますが、SDPも実際に届く音声も8kHzでした。
stream8 は、ONVIFのprofile_3に対応するMJPEGのストリームです。
ただし、5秒間に届いた約300枚のうち、別の画像は5枚だけでした。
同じ画像を送り直し続けるため、帯域は約25Mbpsになります。
また、ChromiumではこのJPEGが灰色にしか描画されなかったため、筆者はこのストリームを使っていません。
同時接続と音声だけの受信
同時に受けられるRTSPの接続は3本までです。
4本目は接続時に拒否されます。
同じストリームを3本開いた場合も、別々のストリームを開いた場合も同じでした。
音声のトラックだけをSETUPすると、何も届かずに時間切れになります。
ffmpegの -allowed_media_types audio を指定した場合がこれにあたります。
録音したいときも、映像ごと受け取ってから映像を捨てる必要があります。
RTCPの時刻が交互にずれる
このカメラで最も手間がかかったのが、RTCPの時刻のずれです。
RTPのタイムスタンプ自体は正しく進み、パケットも滑らかに届きます。
30fpsで、到着間隔の中央値は33ms、最大でも80ms弱でした。
pingは2ms前後で、パケットの損失もありません。
しかし、約0.5秒ごとに届くRTCPのSender Reportに載ったNTP時刻が、交互に約±0.38秒ずれていました。
ffmpeg(libav)はSender Reportをもとに再生時刻(pts)を補正するため、ptsが約0.35秒戻っては約0.42秒飛ぶ動きを毎秒繰り返します。
この時刻をそのまま使うと、次の症状が出ました。
- WebRTCで配信すると、ブラウザが映像を毎秒止める(20秒間に20回、合計7秒)
- 同じくWebRTCで、音声の約43%が補間で埋められる
- MP4に録画すると、映像が毎秒つかえる
対策として、ffmpegで保存するときは、パケットが届いた時刻をタイムスタンプに使います。
ffmpeg -rtsp_transport tcp -use_wallclock_as_timestamps 1 \
-i "rtsp://user:pass@192.168.x.x:554/stream1" \
-c copy out.mp4
WebRTCで配信するときは、タイムスタンプを自分で付け直しました。
音声は届いたサンプル数から、映像は飛びを通常の進み幅に置き換えて求めます。
対策後は、映像の停止が0回、音声の補間が0%で、30fpsで再生できました。
カメラへ音声を送るトラックは無い
ONVIFのバックチャネル(カメラへ音声を送る仕組み)を使えば、RTSPでスピーカーを鳴らせる機種もあります。
Tapo C220では、Require: www.onvif.org/ver20/backchannel を付けてDESCRIBEしても、SDPには映像と音声(受信)の2本しか含まれませんでした。
スピーカーを鳴らすには、後述するTapo独自の経路が必要です。
ONVIFで分かったこと
サービスと対応しない操作
ONVIFの入口は http://ホスト:2020/onvif/service です。
認証はWS-SecurityのUsernameToken(PasswordDigest)だけに対応していました。
対応するサービスは、device、media(ver10)、events、analytics、imaging、ptz、deviceIOの7つで、版はいずれも2.20です。
プロファイルは profile_1(stream1)、profile_2(stream2)、profile_3(stream8)の3つで、どれにもPTZが付いています。
次の操作には ter:ActionNotSupported が返ります。
- GetSnapshotUri(静止画のURL取得)
- GotoHomePosition(ホーム位置への移動)
また、GetAudioOutputsとGetAudioDecoderConfigurationsの結果は空でした。
deviceIOの能力の問い合わせには AudioOutputs="1" と答えますが、実際に使える音声出力はありません。
PTZの向きと位置
PTZの位置、移動量、速度は、どれも-1〜1に正規化した値で扱います。
ズームは無く、プリセットは8件まで保存できます。
注意が必要なのは、panの正負の向きが、速度と位置で逆になっていることです。
ContinuousMoveでは、panの速度を正にすると画面が右へ動きます。
一方、GetStatus、AbsoluteMove、RelativeMoveの位置では、panが正の方向は左を向く方向です。
tiltは、速度と位置のどちらも正が上でした。
さらに、画像の上下反転をオンにしてカメラを天井に逆さに取り付けると、首振りの向きは上下左右とも逆になります。
実機では、上へのstepで部屋の下を向き、左へのstepで部屋の右を向きました。
画面の動きと操作の向きを合わせるには、上下反転の設定を読み、向きを入れ替える処理が必要です。
ContinuousMoveのTimeout
ContinuousMoveのTimeoutには、整数の秒しか指定できません。
PT0.5S のような小数を含む値を送ると、カメラのHTTPサーバーが壊れた応答(ステータス500)を返しました。
筆者のライブラリでは、秒を切り上げて送っています。
Timeoutを省くと、カメラの既定値である180秒まで動き続けます。
WebUIではTimeoutを5秒にし、ボタンを押している間は3秒ごとに指示を送り直すことにしました。
動き終わりの判定
移動の指示には、すぐに応答が返ります。
GetStatusで得られる位置は動いている途中も変わりますが、MoveStatusは常にUNKNOWNのままです。
そのため、動き終えたかどうかは、位置が変わらなくなったことで判断します。
動いている途中に次の移動を送ると、前の移動は打ち切られます。
範囲外の位置を指定したAbsoluteMoveは、ter:InvalidPosition で拒否されました。
検知イベントはPullPointで受け取れない
ONVIFのイベントには、クライアントが取りに行くPullPointと、カメラが通知してくるWS-BaseNotificationの2つの方式があります。
Tapo C220はPullPointに対応していると答え、CreatePullPointSubscriptionで購読も作れます。
しかし、PullMessagesを送ると、カメラは何も返さずに接続を切りました。
一方、WS-BaseNotificationのSubscribeで、こちらが待ち受けるHTTPのURLを渡すと、カメラがそのURLへNotifyをPOSTしてきます。
この方式では、カメラからPCへの着信をファイアウォールで許可する必要があります。
コンテナの中で待ち受ける場合は、待ち受けるアドレスと、カメラに伝える宛先のURLを分けて指定します。
Dockerのポート公開(iptablesのDNAT)であれば、送信元はカメラのIPアドレスのまま届きます。
一方、docker-proxyが中継する構成では送信元が変わるため、カメラ以外からの接続を拒否する処理を入れていると弾かれます。
届いたイベントの振る舞いは次のとおりです。
- 動体(Motion)、人物(People)、いたずら(Tamper)、ライン横断(LineCross)、TPSmartEventのトピックがある
- 検知している間は真を約1秒ごとに送り、収まると偽を1回送る
- 通知に付く時刻(UtcTime)は当てにならない。人物の検知で、最初の時刻のまま変わらなかった
実機で届くことを確かめたのは、動体と人物だけです。
いたずら、ライン横断、TPSmartEventは起こせていません。
また、首振りの直後の動体検知は、届くときと届かないときがありました。
Tapo独自の経路で補ったこと
RTSPとONVIFでは、スピーカーの再生、microSDの録画、カメラの設定が扱えません。
これらは、Tapoアプリが使っている独自の経路で実現しました。
スピーカーから音を鳴らす
スピーカーは、8800番の双方向音声の経路を使って鳴らします。
手順は、go2rtc の pkg/tapo の実装を参考にしました。
- 8800番の
POST /streamをDigest認証する。ユーザー名はadmin、パスワードはTapoアカウントのパスワードのSHA256を大文字の16進で表したもの -
{"params":{"talk":{"mode":"aec"},"method":"get"}}を送り、session_idを受け取る - G.711 A-law(8kHz)の音声をMPEG-TSに包み、20msずつ実時間の速さでmultipartとして送る
パスワードは、カメラアカウントのものではない点に注意が必要です。
この経路では、カメラから受信の確認が返りません。
筆者は、テスト音を鳴らしながらカメラのマイクで録音し、再生した時刻に音量が上がることで鳴っていることを確かめました。
送った音声が届いていないことに気づけるよう、ライブラリでは次の2つの場合をエラーにしています。
- カメラが接続を閉じた
- 送ったのにカメラが受け取っていない音声が、3秒分を超えた
2つ目は、TCPの確認応答が返っていないデータ量をOSに問い合わせて判定します(Linuxのみ)。
カメラが止まっても、OSの送信バッファが埋まるまでは書き込みが成功し続けるため、書き込みの成否だけでは数分間気づけません。
通常は0.05秒分ほどしか溜まらないことを、20秒の再生で確かめました。
ただし、分かるのは「カメラが受け取ったか」までです。
受け取った音を実際に鳴らしたかどうかは、この経路では分かりません。
microSDの録画を一覧して取り出す
ONVIFには録画の検索と再生のサービス(Profile G)が無く、RTSPでも録画を再生できません。
そこで、一覧には443番の制御API、取り出しには8800番のストリームを使いました。
一覧の取得は、ログインの手順が込み入っているため、pytapo に任せています。
録画のある日付、1日の中の録画区間(開始と終了の時刻、種別)、カードの容量と空きが取得できます。
種別には1と2があり、1は長く連続した区間、2は短い区間として返ります。
筆者は、1を常時録画、2を検知による録画と推定して表示しています。
取り出しでは、スピーカーと同じDigest認証のあと、時刻を指定したplaybackの要求を送ります。
すると、AES-CBCで暗号化されたMPEG-TSが届きます。
鍵は、認証の応答に含まれるnonceとパスワードのハッシュから作ります。
届く映像はH.264 2560×1440、音声はG.711 µ-law 16kHzです。
この音声のストリーム種別は 0x91 で、ffmpegはこの種別を認識しません。
そのため、音声はMPEG-TSから自分で取り出し、MP4に入れ直しています。
取り出しには、次の制約がありました。
- ファイルの転送ではなく「再生」なので、等速でしか届かない。20秒分の取り出しに20.8秒かかった
- 2倍以上の速度を指定するとデータは速く届くが、音声が来なくなり、映像の時刻の進み方も変わる
- 終了時刻を指定しても守られず流れ続けるので、映像の時刻(PTS)が必要な長さに達したらこちらから切る
- 録画の無い時刻を指定すると、映像は届かず終了の通知(
finished)だけが返る - 録画の区間の切れ目では
finishedは届かず、録画の無い時間を飛ばして次の区間へ続く。finishedが届くのは、録画の最新の端まで流したときだった
最も扱いにくかったのは、取り出している間に区間の一覧が取れなくなることです。
一覧の取得に必要なIDの発行が USER_ID_FULL(-71101)で拒否され、先に取得したIDも無効になります。
取り出しの client_id にそのIDを使うと一覧は取れるようになりますが、今度は取り出しのほうが止まりました。
両立させる方法は見つからなかったため、ライブラリでは最後に取得した一覧を覚えておき、取り出している間はそれを返しています。
なお、録画のある日付、検知の記録、カードの状態は、取り出している間も取得できました。
また、取り出しとスピーカーの再生は、同じ8800番でも同時に使えます。
カメラの設定を読み書きする
画質、昼夜の切り替え、検知、警報、音量などの設定は、443番の制御APIで読み書きできました。
ライブラリでは、pytapoの上に設定ごとのメソッドを用意しています。
from tapo_c220 import TapoC220
async with TapoC220() as cam:
s = cam.settings
await s.set_day_night("auto") # auto / on(夜、赤外線)/ off(昼、カラー)
await s.set_image(brightness=55) # 明るさ、コントラスト、彩度、シャープネス(0〜100)
await s.set_detection("person", True) # 動体、人物、ペット、車両、泣き声など10種類
await s.set_alarm(enabled=True, sound=False) # 検知したときの警報(音、光、音量)
await s.set_privacy(True) # プライバシーモード
書き込みは、実機で値を変えて読み直し、元の値に戻すことで確かめました。
対象は、LED、通知、昼夜、画質の4項目、レンズ歪みの補正、電源周波数、OSDの曜日表示、スピーカーとマイクの音量、マイクのミュート、検知の10種類、自動追尾、上書き録画、警報の音と光と音量、プライバシーモードです。
録画の設定、上下反転、定期再起動は、現在と同じ値を書いて拒否されないことだけを確かめています。
再起動、巡回、キャリブレーションは呼んでいません。
pytapoのままでは動かなかった操作もあります。
- 警報の設定は、pytapoの
setAlarmの書き方(method: set)では拒否される。setAlertConfigで書く必要があった - ファームウェアの自動更新の設定は、認証のエラー(-40209)で書き込めなかった
警報をすぐに鳴らす確かな方法は見つからなかった
検知したときに鳴る警報を、こちらの指示ですぐに鳴らす確かな方法は見つかりませんでした。
pytapoの startManualAlarm(manual_msg_alarm)、新しいファームウェア向けとされる manual_alarm、setSirenStatus を試しましたが、どれも「そのメソッドは無い」(-40210)が返りました。
推測した名前も約50個試しましたが、結果は同じでした。
唯一、doManualAlarm に {"action":"start"} を渡したときだけは、成功(error 0)が返りました。
ただし、音だけ鳴らす設定で2.5秒間鳴らし、カメラのマイクで録音した音量からは、鳴ったとは判断できませんでした。
光が点いたかどうかは見ていません。
実機の前で目と耳で確かめるまでは、この方法を使わないことにしました。
代わりに、サイレンの音をPC側で作り、スピーカーの経路で鳴らすことにしました。
音の大きさはスピーカーの音量の設定で決まり、カメラの光は点きません。
長時間動かしたときの振る舞い
ライブ映像とmicroSDの録画の再生を、同時に40分間流し続けました。
途中で止まらず、録画区間の切れ目をまたいでも再生が続くことを確かめています。
このとき、サーバーのメモリが毎分約0.7MBずつ増え続けました。
原因は、RTSPの受信に使っていたaiortcの MediaPlayer でした。
自前のスレッドでPyAVを使って読む形に替えると、8分間で+0.6MBにとどまり、増え続けなくなりました。
数時間の連続運転は試していません。
また、ffmpegで録画している途中にWebUIのサーバーを強制終了すると、ffmpegのプロセスが残ることがありました。
残ったプロセスがRTSPの接続を占有し続けるため、3本の上限に早く達します。
Linuxでは setpriv --pdeathsig を通してffmpegを起動し、親プロセスが終了したらffmpegも止まるようにしました。
作ったライブラリとWebUI
調査の結果は、Pythonのライブラリ tapo_c220 にまとめ、skrtk98/tapo-c220 で公開しています。
RTSP、ONVIF、Tapo独自の制御API、Tapo独自のストリームの4つの経路ごとに窓口となるモジュールを置き、その上に設定、スピーカー、microSD、検知イベントの機能を並べています。
from tapo_c220 import TapoC220
async with TapoC220() as cam: # 接続先と認証情報は .env から読む
jpeg = await cam.snapshot() # 静止画(2560×1440)
await cam.detect_inverted() # 上下反転の設定を読み、首振りの向きを合わせる
await cam.step("up", 0.05) # 決まった量だけ首を振る
await cam.play("hello.mp3") # カメラのスピーカーで鳴らす
sd = cam.sdcard
days = await sd.days() # 録画のある日付
segments = await sd.segments(days[-1]) # その日の録画区間
await sd.export(segments[-1].start, 60, "clip.mp4") # 60秒分をMP4に保存(約60秒かかる)
async for event in cam.events(): # 検知イベント(変わり目だけを返す)
print(event.kind, event.active)
使用例として、FastAPIで作ったWebUIも用意しました。
ライブ映像はWebRTCで配信しています。
カメラのH.264とG.711 A-lawはWebRTCの標準のコーデックなので、変換せずにRTPへ載せ替えています。
microSDの録画も、時刻を選んでWebRTCで再生できるようにしました。
録画の音声(µ-law 16kHz)はWebRTCの標準の形式ではないため、PCMに戻してからOpusに変換しています。
WebUIにはHTTP Basic認証を付けました。
ループバック以外のアドレスで待ち受けるときは、パスワードを指定しないと起動しません。
ほかのサイトからのPOSTなど、OriginヘッダがHostヘッダと異なる要求も拒否します。
許可するHost名を指定した場合は、それ以外のHostヘッダの要求(DNSリバインディング)も拒否します。
使いどころと注意点
向いている場面
- LANの中だけでTapo C220の映像を録画、配信したい場合
- 首振りや検知イベントを、自作のプログラムやホームオートメーションから使いたい場合
- スピーカーやmicroSDの録画を、Tapoアプリを開かずに扱いたい場合
このうち、映像の受信、首振り、検知イベントは標準のRTSPとONVIFだけで実現しています。
避けたい場面
- Tapo独自の経路に頼る本番運用:スピーカー、microSDの録画、設定は非公式の経路です。ファームウェアの更新で動かなくなる前提で、壊れても困らない用途に限ってください。
- 別の機種や別のファームウェア:この記事の結果は、1台のTapo C220(ハードウェア2.0、ファームウェア1.3.1)で確かめたものです。他の機種で同じ振る舞いをするとは限りません。
- 平文のHTTPでのLAN外からの利用:WebUIのBasic認証のパスワードは、平文のHTTPでは盗聴できます。WebUIはTapoアカウントのパスワードも扱うため、LANの外から使う場合はTLSの逆プロキシを前に置いてください。
まとめ
Tapo C220は、RTSPとONVIFで映像、音声、首振り、検知イベントまでは扱えました。
ただし、RTCPの時刻のずれ、panの正負の逆転、PullPointが応答しないことなど、仕様どおりに動かない箇所がいくつかありました。
スピーカー、microSDの録画、設定はRTSPとONVIFでは扱えず、Tapo独自の443番と8800番の経路で補いました。
こちらは動作はするものの、等速でしか取り出せない、取り出し中に一覧が取れない、警報をすぐに鳴らす確かな方法が無いといった制約が残っています。