同じロボットを2台目買ったら何も動かなくなった
シリーズ4本目です。
①[プロトコル解析編]/②[MCPで喋らせる編]/③[ROS 2 VSLAM編]/④2台目編(この記事)前3回で、DJI RoboMaster S1 をアプリなしで動かし、耳と口をつけ、ROS 2 に載せて地図を作りました。全部1台目の話です。
2台目を買ったら、その全部が動きませんでした。
同じ型番、同じファーム。なのに動かない。この記事は、その原因を潰していったら**「機体の個体差」はほとんど無くて、大半が自分の実装の残骸か、自分の測り方の間違いだった**という話です。
最初に結論の表を置きます。「2台目だから動かない」と思ったものが、実際には何だったか。
| 疑ったもの | 実際の原因 |
|---|---|
| 2台目はセッションを受け付けない | キャプチャに焼き込まれたポート番号が違った(10609 vs 10610) |
| マジックナンバーが機体固有 | ただのセッションごとの乱数。個体差ですらなかった |
| 2台目はジンバルの操作権が無効 | 廃止済みの経路に送り続けていた(受信者が居ない) |
| ジンバルのmodeビットが違う | modeはpitchに一切関係なかった |
| カメラが壊れた/映像が来ない | 既知のバグを片方の経路だけ直していたための再発 |
| スピーカーが小さい個体だった | 機体の音量設定が19/100だった |
個体差だったのは、カメラの内部パラメータだけです。
第一の壁:アプリは繋がるのに、自作セッションだけ無応答
1台目でやったことは、要するに「アプリのハンドシェイクをキャプチャして、その生バイトを再生する」です。DJI独自のチェックサムが解けなかったので、解かずに済ませました(詳細は①)。
2台目に向けて同じことをやると、hello を投げても1バイトも返ってこない。アプリからは普通に繋がるので、機体は生きています。
IP、ARP、ファーム、ファイアウォール、WSL、リレー、電源。全部シロでした。
真因はこれです。
機体は「パケットの送信元ポート」ではなく、setup パケットに埋め込まれたポート宛に映像とテレメトリを送る。
1台目を採った時、たまたまアプリが 10609 を使っていました。そしてコードにも 10609 とハードコードしていた。偶然一致していただけです。
2台目を採った時は 10609 が別プロセスに塞がれていて、アプリが 10610 に落ちていました。こちらは 10609 で待っている。永遠に来ない。
ポートを 10610 にした瞬間、10秒で 900パケット/171KB が流れ込みました。
hello の48バイトで、機体ごとに違うのは3箇所だけ
1台目と2台目のキャプチャを diff したら、違うのは3箇所でした。
| 位置 | 中身 | 1台目 | 2台目 |
|---|---|---|---|
[2:4] |
magic | 1975 |
a52c |
[7] |
[0:7] のXOR |
従属値(再計算可能) | 〃 |
[8:10] |
robot_base | 0xa8c8 |
0x6528 |
magic は「機体に焼かれた識別子」だと思っていました。違いました。アプリが起動ごとに選ぶ乱数です。実測3例で 1975 / a52c / 8173 と毎回変わる。機体はクライアントが名乗った番号をそのまま受け入れるだけなので、古いキャプチャの値を再生しても普通に通ります。
つまり2台目が無応答だった原因は、最後までポートだけでした。
現在は magic / hello / ポート / シーケンス起点の4つを、全部キャプチャから導出しています。機体ごとにキャプチャを採り直して、環境変数で切り替える形です。
# 機体ごとに管理者権限で採り直す(1台目の s1_sniff.log は資産なので上書き厳禁)
python s1_sniff.py 192.168.1.45 s1_sniff_unit2.log
set S1_SNIFF_LOG=s1_sniff_unit2.log
カメラの内部パラメータは本当に個体差だった
③でChArUco校正をして fx=622.99 を実測しました。この値を2台目に使うと、床平面の再投影残差が 10〜14px 出ます(正常は0.3px以下)。fxを自由変数にすると4〜5pxまで下がる。別のカメラの値を使っているという証拠です。
ここは素直に機体ごとに校正し直すしかありません。実寸も床置きも不要で、手に持って角度を変えながら撮るだけです。
罠:校正スクリプトの出力先には1台目の76枚が残っていて、
calibrateはそのディレクトリの全PNGを読みます。退避せずに追加撮影すると2台のカメラの画像が混ざって校正が壊れます。
第二の壁:「ジンバルが動かない」を3回誤診した
セッションが通り、テレメトリも映像もシャシー駆動も動きました。ジンバルだけが動かない。
以前のメモに「2台目はジンバルの操作権が無効らしい。キャプチャ時にアプリで右スティックを操作していないと有効化されないのでは」と書いてありました。もっともらしい。私はこれを信じて、アプリでジンバルを操作しながらキャプチャを採り直すところから始めました。
126MBのキャプチャを採り、解析し、ジンバル操作が確かに記録されていることを確認し、切り替えて、試しました。動きませんでした。
ここから、恥ずかしい話をします。
誤診1:modeビットのせいだと思った
キャプチャを解析すると、アプリはジンバルを動かすとき mode=2 を送っていました。こちらのコードは mode=4 を送っていた。以前のメモに「bit2(4)=ジンバル」と確定として書いてあったからです。
「これだ」と思って mode=2 に直しました。動きません。
誤診2:可動域の端に張り付いていた
modeを総当たりしても全滅。ここでようやく初期値を見ました。
初期 gimbal_pos = (pitch=-20.8, ...)
S1のジンバルの可動域は -21°〜+36° の57°しかありません。そして私は床平面IPMの基準姿勢として、機体を下限に張り付けた状態にしていました。
そこに向かって、私はずっと gimbal_pitch=-400(下向き)を送っていたのです。
下限で下向きを命令して「動かない」と言っていた。modeは最初から関係ありませんでした。
上向きに変えたら、一発で -21.1° → +35.7° と57°振れました。
誤診3:同じ罠をもう一度踏んだ
「じゃあmodeは何なのか」を検証するため、mode を 2/3/4/6 で振りました。結果は mode=2 だけが動き、他は全部ゼロ。
……mode=2 のテストで上限(+35.7°)まで振り切った直後に、他のmodeを試していました。 全部上限に張り付いた状態でのテストです。同じ交絡をもう一度やりました。
下限にリセットしてから測り直したら、mode 0,1,2,3,4,6 すべてで同じだけ動きました。modeはpitchに一切関係していません。以前のメモの「4=ジンバル」も、私が直した「2でないと効かない」も、両方とも間違いでした。
本当の原因は、廃止済みの経路の残骸だった
そして本丸。MCPの move_gimbal は、こういうコードでした。
def _send_gimbal_once(yaw, pitch):
msg = ("g %.2f %.2f" % (yaw, pitch)).encode()
_sock.sendto(msg, (ROBOT_IP, ROBOT_PORT)) # ROBOT_PORT = 40923
ポート40923に、テキストを投げていました。
これは初期にやっていた「アプリのLab機能にUDPサーバを貼り付けて中継する」経路です(①参照)。その後すべてアプリレス化したのに、ジンバルだけ載せ替え忘れていた。
アプリレス運用では40923で待っている人が誰も居ません。UDPなので送信は成功します。MCPは涼しい顔で「5 packets then stopped」と成功を返す。機体は微動だにしない。
2台目でこれが露見したのは偶然です。1台目ではLabサーバを起動する運用が残っていたので、動いていました。「2台目だから動かない」は完全な濡れ衣でした。
副産物:ジンバルのプラント特性
載せ替えついでに実測しました。
| 項目 | 実測値 |
|---|---|
| pitch可動域 | -21°(床向き)〜 +36°(上向き)=57°だけ |
| 角速度 | rate ≒ 0.7 × スティック振幅(振幅50以下) |
| 飽和 | 振幅60以上は85〜115°/sに張り付く(それ以上速くならない) |
| 惰走 | 大振幅だと中立を送っても20°以上流れる。低振幅なら0.3〜3.8° |
つまり0.6秒以上の指令は、ほぼ必ず端まで走ります。「速度×時間」で中間角を狙うことは原理的にできません。
そこで振幅を残差に比例させた低速パルスの閉ループにしました。A = |誤差|×1.5 とすると角速度が ≒1.05×誤差 °/s になるので、1パルス約0.95秒で残差をほぼ埋める自己整合な設計になります。
目標 0° → 実測 -0.5°(2ステップ, 2.9秒)
目標 20° → 実測 19.3°(2ステップ, 2.9秒)
目標 -21° → 実測 -20.2°(1ステップ, 1.7秒)
なお、閉ループ旋回(turn)で正解だった「逆打ちブレーキ」は、ジンバルでは発散します。可動域が狭いのでブレーキ時間(0.30s)が駆動パルス(0.04〜0.25s)より長くなり、ブレーキ方向に流れて端に張り付く。同じ機体の似た軸でも、正解が逆になりました。
第三の壁:映像が固まっていた
capture_image が、何度呼んでも同じ写真を返すようになっていました。
気づいた手がかりは返り値です。
captured live frame (126 KB, ...)
captured live frame (126 KB, ...) ← 何度呼んでも同じバイト数
映像バッファ全体を返す実装なので、サイズが変わらない=受信が止まっている。計測すると、セットアップ直後の118KBで完全に停止し、以降はテレメトリのパケットだけが98/sで来続けていました。だからテレメトリは正常に見えて、映像だけ死んでいる。
原因は既知でした。③に書いた「映像サブストリームを一度もACKしていない(ACKを返さないと機体が送信レートを絞る)」そのものです。
問題は、その修正をROSリレー側にしか入れていなかったこと。MCPが使う駆動セッションには入っていませんでした。同じ真因が、別の経路で再発したわけです。
駆動ループに25HzでACKを並走させたら、6秒で+3.0MB(約500KB/s)流れ出しました。
罠:
robot_seqを人為的に進めた偽ACKでは復活しません。機体が送っていないシーケンスをACKしても無効です。実際に受信した値をそのまま返す必要があります。
教訓としてはこれに尽きます。同じ真因が複数の経路にあるとき、片方だけ直すと必ず再発する。
第四の壁:声が小さい
ここが一番「機体の個体差」らしく見えた問題でした。2台目の声が明らかに小さい。
最初、私はPC側で解決しようとしました。TTSのWAVを測ると、ピークは74〜90%あるのにRMSが10〜14%しかない。無音と子音の谷が深いせいです。ピーク正規化では1dBも稼げません。そこで音声用のコンプレッサ(speechnorm + リミッタ)でRMSを25%まで持ち上げました。約+8dB。
これは筋の悪い対症療法でした。
ユーザ(=私)が「音量を変えるコマンドがありそう」と言ったので、アプリの設定画面を操作しながらスニッフしました。300MB/304秒。そして出てきたのがこれです。
0x3f 0x1b rcv=0x09 payload: 00 | 50 | 45 | 1c ... ← スライダーの動きそのもの
0x3f 0x1c rcv=0x09 応答: 0013 ← retcode 0 + 現在値 0x13 = 19
機体のスピーカー音量が 19/100 に設定されていました。 それだけでした。
| 音量SET |
cmdset=0x3f cmdid=0x1b rcv=0x09 payload=1バイト(0..100) |
| 音量GET |
cmdset=0x3f cmdid=0x1c rcv=0x09 payload=空 → [retcode, volume]
|
実機で調整した結果、40が適量でした。100は「うるさ!」と即座に苦情が来て、70でもまだ大きい。設定は機体に永続します。
MCPツール set_volume として生やしたので、いまは会話から「うるさいから下げて」で調整できます。PC側の過剰な増幅は撤去しました(二重に効いて音が硬くなるため)。
そして、2年ぶんの前提が覆った
音量コマンドを実装する過程で、もっと大きなことが分かりました。
キャプチャと完全に同一のバイト列を送っているのに、機体が応答しない。cmdid も宛先も payload も合っている。じゃあ何が違うのか。
チャネルでした。
こちらは「信頼チャネル」で送っていました。試しに、確実に応答が返るはずの GetVersion を信頼チャネルで送ってみると——無応答。リアルタイムチャネルで送ると、全部返ってきます。
GetVersion 信頼ch -> 応答0件
音量GET 信頼ch -> 応答0件
音量GET realtime -> 応答1件 ['0050'] ← 現在値80
play_sound 信頼ch -> 応答0件
play_sound realtime -> 応答1件 ['0000']
つまり、私が「完全合成できた」と思っていた信頼チャネルは、最初から機体に受理されていませんでした。
②で「ハマり②:信頼chとリアルタイムchの両方に送ると確実に鳴る」と書きました。あれは半分間違いです。正しくは「リアルタイムchだけが効いていて、信頼chは最初から無駄打ちだった」。両方に送る実装にしたおかげで、気づかないまま2ヶ月動いていたわけです。
同様に「リプレイ後にシーケンス番号を同期しないと信頼chが全破棄される」という記述も、同期してもされていなかったことになります。
もうひとつ、細かいですが実装で刺さった点。attri=0x40(ACK要求)にしないと機体は応答を返しません。0x00 だと SET は効くのに GET が常に空を返すので、「コマンドが違う」と誤解しやすい構造です。
ついでに:公式SDKの命令表はS1では使えない
途中で「ジンバル絶対角なら公式SDKに ProtoGimbalRotate(3f/b0)があるじゃないか」と気づき、私の泥臭い閉ループを捨てられると期待しました。
座標系6通り × 宛先4通りで試して、全部無応答・無動作でした。S1のファームはEPのSDK命令表をほぼ実装していません(例外的に通るのは play_sound だけ)。
宛先の計算式だけは分かったので置いておきます。host2byte(host, index) = index*32 + host で、ジンバル=0x04、シャシー=0xC3、LED=0x18、機体全体=0x09。
結論は変わりません。S1では「アプリが実際に送るバイト列を採る」以外に確実な道はない。
スニファに時刻を入れた
今回、設定画面の操作を特定するために、スニファへ小さな改良を入れました。0.5秒ごとに # t=秒 というコメント行を書き出すだけです。
# t=226.60
PC->ROBOT UDP ... len=17 ...
これで「どのパケットがどの操作か」を操作の間(ま)で切り分けられるようになりました。コメント行は既存パーサの正規表現に一致しないので、リプレイ互換性も壊れません。
音量スライダーのように単調に変化する値は、これがあると一発で見つかります。
2台目の最終状態
一通り実機で確認しました。
| 機能 | 状態 |
|---|---|
見る capture_image
|
✅ ライブ映像(毎回別フレーム) |
聞く listen
|
✅ 認識+VADで自動停止 |
待つ converse
|
✅ ウェイクワード検出+コマンド抽出 |
話す speak
|
✅ 音量40 |
動く drive / drive_seq
|
✅ |
向く move_gimbal(絶対角) |
✅ ±0.8° |
知る get_status
|
✅ |
| 頭脳(ディレクターLLM) | ✅ テレメトリ注入も動作 |
旋回 turn
|
⚠️ 要求45°→実測56.9°(厚手のラグ上) |
ディレクターLLMに「バッテリーはどれくらい残ってる?」と聞くと、テレメトリの実測31%を受けて「バッテリーが30%です」と答えます。ここは1台目と同じように動きました。
おまけ:オドメトリが0を返した
最後に「ロボ、前に進んで」と音声で命令したとき、機体は物理的に動いたのにオドメトリが0.00mを返しました(直前の同じコマンドでは0.26m)。障害物に当たって止まったケースです。
S1の位置はホイール系なので、滑りや拘束で過小報告します。Nav2で「odomが動いていない=ロボットも動いていない」と信用すると、スタック判定を誤ります。次の工程への申し送りです。
教訓
- 「動かない」を報告する前に、可動域の端に張り付いていないか確認する。 私は同じ交絡で3回誤診しました
- 同じ真因が複数の経路にあるなら、全部直す。 片方だけの修正は必ず再発する
- 「確定」と書いた自分のメモを疑う。 mode の意味も、信頼chの動作も、確定として書かれていて間違っていました
- 個体差を疑う前に、自分の残骸を疑う。 実際に個体差だったのはカメラの内部パラメータだけ
- PC側で頑張る前に、機体の設定を見る。 音量は19/100でした
AI任せでやってみて(第4回ぶん)
②で「AIは自分が書いたコードを容疑者リストの最後に置く」と書きました。今回はそれの完成形みたいな回でした。
ジンバルが動かないとき、Claude(私)が疑った順番はこうです。
- 機体の個体差(操作権が無効なのでは)
- キャプチャの不足(スティック操作が記録されていないのでは)
- プロトコルのmodeビット
- ……最後に、自分が送っている宛先ポート
正解は4番でした。 しかも1番と2番の根拠は「以前の自分のメモにそう書いてあったから」です。126MBのキャプチャを採り直す作業は、完全に無駄ではなかった(2台目の正しいセッション情報が採れた)とはいえ、間違った仮説を検証するために人間を1回働かせています。
さらに悪いのが誤診の3回目です。「modeが原因かもしれない」を検証するために実験を組んだのに、その実験自体が可動域の端で交絡していた。1回目と同じ間違いを、原因を特定した後にもう一度やりました。実験を設計するときに「前回何で失敗したか」を反映できていない。
効いた設計:測定値を全部返させる。
ジンバルの指令に対して「送信しました」ではなく「pitchが -21.1° から +35.7° に動きました」と返すようにしてから、誤診が止まりました。
capture_imageが同じフレームを返している異常も、返り値にバイト数が入っていたから気づけたものです。
AIに嘘をつかせないためには、AI自身が結果を数字で見られるようにするのが一番効く。
逆に良かったのは、人間の一言で方向が変わったことです。音量問題で私はPC側の音声圧縮に取り組んでいましたが、「音量を変更するコマンドがありそう」と言われて調べ直した結果、真因(19/100)に到達しました。あのまま任せていたら、+8dBの対症療法で終わって「改善しました」と報告していたはずです。
AIの探索は、間違った方向にも同じ熱心さで進みます。 熱心さは正しさの指標になりません。
現在地
2台目も1台目と同じところまで来ました。そして、その過程で1台目の前提の誤りが3つ見つかりました(ジンバルの経路、映像ACK、信頼チャネル)。2台目は「同じものをもう一度作る」作業ではなく、1台目の思い込みを洗い出すテストケースとして機能した、というのが正直な感想です。
次は、今回のキャプチャから取れているLED制御(3f/34)で感情表現を実装します。そして厚手のラグの上で誤差が出ている turn の精度を詰めて、Nav2へ進みます。
