commit 32a42803fea52e2b745d1fb51aa69232fe06a4f7
Author: Eiichi Shimazu <studio@adaptdesign.jp>
Date:   Sat Nov 7 14:21:21 2026 +0900

    daemon に電流などを読むだけの read_servo を足し (tools/pi. Pi へは tools/daemon_add_read_servo.sh で置く. まだ置いていない), 関節と爪の電流と角度を読める限り速く読んで KIOXIA の diag/servo_current_{日付}/ に残す tools/servo_current_probe.py を作る (印のキー・--with-pad で gamepad_teleop を同じプロセスで動かしパッドの命令の鍵の待ちを残す). 学習のテストの偽物のチェックポイントの assets 名を作るモデルの学習データに合わせる (lab_train_fakes.sh・test_lab_train.sh. 本番の流れは変えない) (依頼 109. codex 3 回)
    
    Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

diff --git a/docs/review_request_20261107_read_servo.md b/docs/review_request_20261107_read_servo.md
new file mode 100644
index 0000000..81e4d34
--- /dev/null
+++ b/docs/review_request_20261107_read_servo.md
@@ -0,0 +1,30 @@
+# コードレビュー依頼：daemon に電流を読むだけの命令 read_servo を足す（2026-11-07 依頼 109）
+
+mechArm 270-Pi の関節と爪の電流が読めるか・何 Hz で読めるかを測るための準備。Pi の daemon（tools/pi/mecharm_daemon.py。Pi の Python 3.6・pymycobot 2.9.6 の MyCobot で動く。TCP 5555 で 1 本ずつ受ける）に、読むだけの命令を 1 つ足した。まだ Pi には置いていない（置くのは tools/daemon_add_read_servo.sh。エイイチが流す）。
+
+## 守りたいこと（ここを重く見てください）
+- アームと爪に何も送らないこと（pymycobot の読む関数だけを呼ぶ）
+- 今までの命令（ping・send_angles・send_angle・get_angles・get_angles_once・send_angles_fast・set_gripper*・get_gripper_value・is_gripper_moving・set_fresh_mode・stop）の動きを 1 文字も変えないこと
+- 読めないときに daemon が落ちない・止まらないこと（1 件ずつ処理するので、長くかかるとゲームパッドの指令が遅れる）
+- Python 3.6 で動くこと
+
+## したこと
+- `tools/pi/mecharm_daemon.py`: `handle_servo_read()` と `_servo_read_one()` を足し、`handle_command` の unknown cmd の else の直前に `elif cmd == "read_servo": return handle_servo_read(cmd_obj)` を足した
+  - 頼まれた項目（angles・currents は既定で読む / status・gripper・regs は頼んだときだけ）を 1 回ずつ呼ぶ。読み直さない・待たない
+  - 例外や関数がないときは value None と error（ok=True のまま。get_angles_once と同じ考え）
+  - regs は get_servo_data(servo_id, addr)。servo_id 1〜7（7 = 爪）・addr 0〜100・32 個まで。外れていれば何も読まずに ok=False
+  - raw=True のとき、インスタンスの `_read` を一時的に包んで返事のバイト列を写し、finally で `del _mc._read` で外す（2.9.6 の MyCobot は `_read = read` をクラスに持つ）
+- `tools/daemon_add_read_servo.sh`: Pi の今の daemon を持ち帰り KIOXIA に写しを残し、11-05 の版（sha256 806336b6…。git HEAD の tools/pi/mecharm_daemon.py）と同じときだけ新しい版を置いて systemd で起動し直し、ping・get_angles・read_servo を確かめる。違えば何も変えずに止まる
+- `tools/test_daemon_read_servo.py`（新）: 偽物の MyCobot で 10 件（送る関数が呼ばれない・順番・読めないとき・範囲外は何も読まない・raw のあと `_read` が戻る・今までの命令の返事が同じ）
+- 参考（daemon ではない Mac 側。見てもらえれば助かるが主ではない）: `tools/servo_current_probe.py`（read_servo を繰り返し呼んで CSV に残す。--with-pad で gamepad_teleop を同じプロセスで動かし、policy_recorder.install_daemon_lock の鍵を gamepad_teleop の送り口を読む前にかける）、`tools/test_servo_current_probe.py`
+
+差分: `git diff -- tools/pi/mecharm_daemon.py` と、新しいファイル `tools/daemon_add_read_servo.sh`・`tools/test_daemon_read_servo.py`・`tools/servo_current_probe.py`・`tools/test_servo_current_probe.py`。pymycobot 2.9.6 のソースは /tmp/pmc_x/pymycobot/（generate.py の get_servo_currents・get_servo_status・get_servo_data、mycobot.py の _mesg、common.py の read・_process_received）。
+
+## 見てほしいこと
+1. 読むだけになっているか（get_servo_currents / get_servo_status / get_gripper_value / get_servo_data が 2.9.6 で何かを動かす命令にならないか）
+2. 今までの命令に影響がないか
+3. `_read` の包みと外し方が安全か（例外のとき・2.9.6 の read の中身との組み合わせ）
+4. 読めない項目があるとき 1 回の read_servo が最長どれだけかかるか（2.9.6 の read は返事がないと 0.1 秒待つ）。ゲームパッドと一緒に回すときの問題
+5. daemon_add_read_servo.sh の確かめ方・戻し方に抜けがないか
+
+指摘は「必須 / 推奨 / 据え置きでよい」に分けてください。ファイルは変えないでください。
diff --git a/docs/review_request_20261107_read_servo_r2.md b/docs/review_request_20261107_read_servo_r2.md
new file mode 100644
index 0000000..d78eea7
--- /dev/null
+++ b/docs/review_request_20261107_read_servo_r2.md
@@ -0,0 +1,19 @@
+# コードレビュー依頼 R2：daemon の read_servo（2026-11-07 依頼 109）
+
+R1（docs/review_result_20261107_read_servo_codex.md）の指摘を反映した。前提・守りたいことは docs/review_request_20261107_read_servo.md と同じ（アームと爪に駆動の命令を送らない・今までの命令を変えない・daemon が止まらない・Python 3.6）。
+
+## R1 の指摘への対応
+| # | 分類 | 指摘 | 反映 | 対応 | 次に見てほしい点 |
+| --- | --- | --- | --- | --- | --- |
+| 1 | 必須 | 始めの一括読み取り (32 個) が 3 秒を超えうる・その間ゲームパッドの stop も待つ | 反映 | servo_current_probe.py: 始めの 1 回を FIRST_CHUNK=4 個ずつの read_servo に分けた (1 回目 = 角度・電流・状態・爪、あとは番地 4 個ずつ × 7)。--with-pad のときは毎回読む数 (角度・電流・状態・爪・番地の合計) を PAD_MAX_READS=4 までにし、超えたら記録を作る前に止める。daemon の説明に待ちの目安を書いた | 分けた 1 回の最長 (4 × 約 0.2 秒) とゲームパッドの命令の待ちの見込みが妥当か |
+| 2 | 必須 | 返事なしの [] / -1 を成功値として返す | 反映 | daemon: リストを返す関数 (angles・currents・status) は []・None、1 つの値の関数 (gripper・regs) は -1 なら value None・error "no reply"・returned に元の値。probe: やりとりの失敗 (n_error) と項目が読めなかった回 (n_item_error) を分けて数え、往復の時間は項目が読めなくても数える | -1 を「返事なし」とみなすこと (番地の値として -1 は来ない前提。2.9.6 の _process_single) |
+| 3 | 推奨 | regs の int() 丸め | 反映 | 長さ 2 の list/tuple で、要素が int (bool は除く) のときだけ受ける。1.9・True・"1"・dict は ok=False で何も読まない | 抜けがないか |
+| 4 | 推奨 | get_servo_data がないときの理由 | 反映 | regs_detail に error "get_servo_data is not available ..." | — |
+| 5 | 推奨 | 導入の確かめで返事の形も見る | 反映 | daemon_add_read_servo.sh: items が 4 つそろうこと・角度が 6 個読めること (空なら 0.2 秒あけて 5 回まで頼み直す) を確かめ、電流・状態・爪の値は出すだけ (読めなくてもよい) | — |
+| 6 | 推奨 | mv のあとに ssh が切れたときの戻し方・すでに新版のときの確かめ | 反映 | 構文の確かめと mv を分け、mv から先は失敗したら戻し方を出す。すでに新版なら置き直さず起動し直さず、返事の確かめだけ流す | — |
+| 7 | 据え置き | 読むだけ・今までの命令・_read の戻し方・導入の基本手順・3.6 | — | R1 で問題なしとされた | — |
+
+テスト: tools/test_daemon_read_servo.py 12 件・tools/test_servo_current_probe.py 10 件 (どちらも通る)。
+差分: `git diff -- tools/pi/mecharm_daemon.py` と新しいファイル tools/daemon_add_read_servo.sh・tools/test_daemon_read_servo.py・tools/servo_current_probe.py・tools/test_servo_current_probe.py。
+
+指摘は「必須 / 推奨 / 据え置きでよい」に分けてください。ファイルは変えないでください。
diff --git a/docs/review_request_20261107_read_servo_r3.md b/docs/review_request_20261107_read_servo_r3.md
new file mode 100644
index 0000000..3a5683f
--- /dev/null
+++ b/docs/review_request_20261107_read_servo_r3.md
@@ -0,0 +1,17 @@
+# コードレビュー依頼 R3：daemon の read_servo（2026-11-07 依頼 109）
+
+R2（docs/review_result_20261107_read_servo_codex_r2.md）の指摘を反映した。前提は docs/review_request_20261107_read_servo.md と同じ。
+
+## R2 の指摘への対応
+| # | 分類 | 指摘 | 反映 | 対応 | 次に見てほしい点 |
+| --- | --- | --- | --- | --- | --- |
+| 1 | 必須 | 始めの 1 回を on_frame の中で読むと、その間 gamepad_teleop が停止 (L2+R2)・切断・窓を閉じるを見ない | 反映 | servo_current_probe.py run_with_pad: 始めの 1 回は gamepad_teleop.main を呼ぶ前に、vsp.get_daemon_socket() で作ったつながりで読む (鍵の中・パッドの命令には数えない)。gamepad_teleop.main は同じつながりをそのまま使う (get_daemon_socket はつながりがあればそれを返す)。読めなければつながりを閉じて終わる。on_frame は読み取りのスレッドを始めるだけ。テストで「偽物の main が呼ばれた時点で first_read.json があり、read_servo が 8 回済んでいる」を確かめる | 窓を開く前に読むことで抜けがないか (つながりの持ち主・終わるときの閉じ方) |
+| 2 | 推奨 | regs={}・""・0・False が空として通る | 反映 | regs がない / None のときだけ空。それ以外は list/tuple でなければ ok=False (テストに足した) | — |
+| 3 | 推奨 | -1 を返事なしと断定する前提 | 反映 | daemon の説明に「番地の値は 1 バイトで返る前提・この機体で未確認・ffff だと見分けられない・raw で確かめる」と書いた。始めの 1 回は raw=True でバイト列を残す | — |
+| 4 | 推奨 | 「1 秒以内」は目安 | 反映 | 説明を「目安 0.4〜0.8 秒 (シリアルの待ちだけの見込み。上限の保証ではない)」にした | — |
+| 5 | 据え置き | 正規化・集計・PAD_MAX_READS・導入スクリプト・読むだけ・既存命令・_read・3.6 | — | R2 で妥当とされた | — |
+
+テスト: tools/test_daemon_read_servo.py 12 件・tools/test_servo_current_probe.py 10 件 (通る)。
+差分: `git diff -- tools/pi/mecharm_daemon.py` と新しいファイル tools/daemon_add_read_servo.sh・tools/test_daemon_read_servo.py・tools/servo_current_probe.py・tools/test_servo_current_probe.py。gamepad_teleop.py の main・run_loop は tools/gamepad_teleop.py。
+
+指摘は「必須 / 推奨 / 据え置きでよい」に分けてください。ファイルは変えないでください。
diff --git a/docs/review_result_20261107_read_servo_codex.md b/docs/review_result_20261107_read_servo_codex.md
new file mode 100644
index 0000000..5a7d38d
--- /dev/null
+++ b/docs/review_result_20261107_read_servo_codex.md
@@ -0,0 +1,100 @@
+# codex レビュー結果 R1：daemon の read_servo（2026-11-07 依頼 109）
+
+依頼文: docs/review_request_20261107_read_servo.md（codex exec -s read-only）
+
+**現状は、ゲームパッドとの併用前に修正が必要です。** 読み取り専用であること、既存コマンドの差分がないことは確認できました。ファイルは変更していません。
+
+**必須**
+
+1. **一括読み取りが3秒タイムアウトを超えます。**  
+   [servo_current_probe.py:380](/Users/shimazueiichi/r2r2r-scripts/tools/servo_current_probe.py:380) の初回読み取りは、通常項目4件＋レジスタ28件＝32回です。全件無応答なら約3.2秒＋処理時間となり、設定の3秒を超えます。初回記録前に終了する可能性があります。
+
+   また、[mecharm_daemon.py:144](/Users/shimazueiichi/r2r2r-scripts/tools/pi/mecharm_daemon.py:144) は要求全体を中断せず処理するため、その間はゲームパッドの `stop` も待たされます。初回診断は操作開始前に行い、併用中は読み取り件数を制限するか、複数RPCに分割して操作指令を通せるようにしてください。タイムアウト延長だけでは操作遅延は解消しません。
+
+   無応答時の目安は以下です。
+
+   | 要求 | 読み取り回数 | 待ち時間の目安 |
+   |---|---:|---:|
+   | 既定 | 2 | 約0.2秒 |
+   | 初回診断 | 32 | 約3.2秒 |
+   | 最大要求 | 36 | 約3.6秒 |
+
+   **これは厳密な最長時間ではありません。** 2.9.6 の `read()` は期限確認後に最大0.1秒のシリアル読み取りへ入るため、部分受信では1呼び出しが約0.2秒近くまで伸び得ます。さらに `_write()` の `write/flush` には、この追加実装による時間制限がありません。
+
+2. **「読めない」を成功値として返し、計測結果が誤解を招きます。**  
+   [mecharm_daemon.py:97](/Users/shimazueiichi/r2r2r-scripts/tools/pi/mecharm_daemon.py:97) は、ライブラリの戻り値をそのまま採用します。2.9.6 では無応答時に currents/status は `[]`、gripper/regs は `-1` になり得ますが、`error` が付きません。計測側もこれを正常な往復として集計します。
+
+   項目ごとの無応答値を `value=None`＋`error` に正規化し、必要なら元の値を別フィールドに残してください。`ok=True` は維持できます。空電流 `[]` がエラーなしで返ることは再現しました。
+
+**推奨**
+
+- **regsの入力を厳密に検証する。**  
+  [mecharm_daemon.py:123](/Users/shimazueiichi/r2r2r-scripts/tools/pi/mecharm_daemon.py:123) の `int()` により、`[[1.9, 60.9]]` が `[1, 60]` に変換され、実際に読み取りへ進みます。各要素を「長さ2の配列・整数・範囲内」として検証してください。
+
+- **get_servo_data が存在しない場合も理由を返す。**  
+  [mecharm_daemon.py:155](/Users/shimazueiichi/r2r2r-scripts/tools/pi/mecharm_daemon.py:155) は `None` だけを返し、ほかの項目と違って関数未対応の `error` が残りません。
+
+- **導入確認は `ok` に加えて返事の構造を確認する。**  
+  [daemon_add_read_servo.sh:71](/Users/shimazueiichi/r2r2r-scripts/tools/daemon_add_read_servo.sh:71) は、全項目が例外でも成功扱いです。電流未対応は許容しつつ、期待した `items` が存在し、角度取得が成立していることなどを確認すると確実です。
+
+- **入れ替え時の通信断にも復旧案内を出す。**  
+  [daemon_add_read_servo.sh:53](/Users/shimazueiichi/r2r2r-scripts/tools/daemon_add_read_servo.sh:53) は、Piで `mv` 成功後にSSHが切れても「変えていません」と表示し得ます。この経路でも復旧コマンドを案内してください。既に新版だった場合の早期終了も、稼働確認は省略されています。
+
+**据え置きでよい**
+
+- **読み取り専用性**：指定された2.9.6のソースでは、各関数は取得用プロトコル命令を送るだけで、駆動・設定関数は呼びません。「何も送らない」は厳密には「駆動命令を送らない」です。
+- **既存命令**：git差分は関数・定数と `read_servo` 分岐の追加だけで、既存分岐は変更されていません。
+- **`_read` の復元**：指定環境ではクラスに `_read = read` があるため、一時ラップ後に `del _mc._read` で戻す方式は成立します。取得関数内の例外でも `finally` が動きます。
+- **導入の基本手順**：旧版SHAはHEADと一致しました。バックアップ、旧版照合、Piでの構文確認、一時名からの入れ替えは妥当です。復旧は自動ではなく手動です。
+- **Python 3.6**：追加コードの構文互換性を確認しました。
+
+検証はdaemonの偽物テスト10件が全件成功、シェル構文チェックも成功しました。Piへの接続・転送・実機操作は行っていません。計測側テストは一時ファイルを書き込むため、読み取り専用環境では実行していません。
+tokens used
+56,597
+**現状は、ゲームパッドとの併用前に修正が必要です。** 読み取り専用であること、既存コマンドの差分がないことは確認できました。ファイルは変更していません。
+
+**必須**
+
+1. **一括読み取りが3秒タイムアウトを超えます。**  
+   [servo_current_probe.py:380](/Users/shimazueiichi/r2r2r-scripts/tools/servo_current_probe.py:380) の初回読み取りは、通常項目4件＋レジスタ28件＝32回です。全件無応答なら約3.2秒＋処理時間となり、設定の3秒を超えます。初回記録前に終了する可能性があります。
+
+   また、[mecharm_daemon.py:144](/Users/shimazueiichi/r2r2r-scripts/tools/pi/mecharm_daemon.py:144) は要求全体を中断せず処理するため、その間はゲームパッドの `stop` も待たされます。初回診断は操作開始前に行い、併用中は読み取り件数を制限するか、複数RPCに分割して操作指令を通せるようにしてください。タイムアウト延長だけでは操作遅延は解消しません。
+
+   無応答時の目安は以下です。
+
+   | 要求 | 読み取り回数 | 待ち時間の目安 |
+   |---|---:|---:|
+   | 既定 | 2 | 約0.2秒 |
+   | 初回診断 | 32 | 約3.2秒 |
+   | 最大要求 | 36 | 約3.6秒 |
+
+   **これは厳密な最長時間ではありません。** 2.9.6 の `read()` は期限確認後に最大0.1秒のシリアル読み取りへ入るため、部分受信では1呼び出しが約0.2秒近くまで伸び得ます。さらに `_write()` の `write/flush` には、この追加実装による時間制限がありません。
+
+2. **「読めない」を成功値として返し、計測結果が誤解を招きます。**  
+   [mecharm_daemon.py:97](/Users/shimazueiichi/r2r2r-scripts/tools/pi/mecharm_daemon.py:97) は、ライブラリの戻り値をそのまま採用します。2.9.6 では無応答時に currents/status は `[]`、gripper/regs は `-1` になり得ますが、`error` が付きません。計測側もこれを正常な往復として集計します。
+
+   項目ごとの無応答値を `value=None`＋`error` に正規化し、必要なら元の値を別フィールドに残してください。`ok=True` は維持できます。空電流 `[]` がエラーなしで返ることは再現しました。
+
+**推奨**
+
+- **regsの入力を厳密に検証する。**  
+  [mecharm_daemon.py:123](/Users/shimazueiichi/r2r2r-scripts/tools/pi/mecharm_daemon.py:123) の `int()` により、`[[1.9, 60.9]]` が `[1, 60]` に変換され、実際に読み取りへ進みます。各要素を「長さ2の配列・整数・範囲内」として検証してください。
+
+- **get_servo_data が存在しない場合も理由を返す。**  
+  [mecharm_daemon.py:155](/Users/shimazueiichi/r2r2r-scripts/tools/pi/mecharm_daemon.py:155) は `None` だけを返し、ほかの項目と違って関数未対応の `error` が残りません。
+
+- **導入確認は `ok` に加えて返事の構造を確認する。**  
+  [daemon_add_read_servo.sh:71](/Users/shimazueiichi/r2r2r-scripts/tools/daemon_add_read_servo.sh:71) は、全項目が例外でも成功扱いです。電流未対応は許容しつつ、期待した `items` が存在し、角度取得が成立していることなどを確認すると確実です。
+
+- **入れ替え時の通信断にも復旧案内を出す。**  
+  [daemon_add_read_servo.sh:53](/Users/shimazueiichi/r2r2r-scripts/tools/daemon_add_read_servo.sh:53) は、Piで `mv` 成功後にSSHが切れても「変えていません」と表示し得ます。この経路でも復旧コマンドを案内してください。既に新版だった場合の早期終了も、稼働確認は省略されています。
+
+**据え置きでよい**
+
+- **読み取り専用性**：指定された2.9.6のソースでは、各関数は取得用プロトコル命令を送るだけで、駆動・設定関数は呼びません。「何も送らない」は厳密には「駆動命令を送らない」です。
+- **既存命令**：git差分は関数・定数と `read_servo` 分岐の追加だけで、既存分岐は変更されていません。
+- **`_read` の復元**：指定環境ではクラスに `_read = read` があるため、一時ラップ後に `del _mc._read` で戻す方式は成立します。取得関数内の例外でも `finally` が動きます。
+- **導入の基本手順**：旧版SHAはHEADと一致しました。バックアップ、旧版照合、Piでの構文確認、一時名からの入れ替えは妥当です。復旧は自動ではなく手動です。
+- **Python 3.6**：追加コードの構文互換性を確認しました。
+
+検証はdaemonの偽物テスト10件が全件成功、シェル構文チェックも成功しました。Piへの接続・転送・実機操作は行っていません。計測側テストは一時ファイルを書き込むため、読み取り専用環境では実行していません。
diff --git a/docs/review_result_20261107_read_servo_codex_r2.md b/docs/review_result_20261107_read_servo_codex_r2.md
new file mode 100644
index 0000000..2e7787a
--- /dev/null
+++ b/docs/review_result_20261107_read_servo_codex_r2.md
@@ -0,0 +1,43 @@
+# codex レビュー結果 R2：daemon の read_servo（2026-11-07 依頼 109）
+
+依頼文: docs/review_request_20261107_read_servo_r2.md（codex exec -s read-only）
+
+**R1の修正は概ね反映されていますが、初回診断中の停止入力について1点、修正が必要です。** ファイルは変更していません。
+
+**必須**
+
+1. **初回読み取りの分割だけでは、ゲームパッドの停止入力を処理できません。**
+
+   [servo_current_probe.py:530](/Users/shimazueiichi/r2r2r-scripts/tools/servo_current_probe.py:530) は、`on_frame()` の中で初回診断8回分を同期実行しています。[gamepad_teleop.py:1398](/Users/shimazueiichi/r2r2r-scripts/tools/gamepad_teleop.py:1398) は、そのコールバックが戻ってからイベントと停止トリガーを読みます。
+
+   このため、RPC単位では4項目に分かれていても、初回診断全体が終わるまで停止入力・切断・ウィンドウ終了を処理できません。全32項目が無応答なら約3.2秒、部分受信では約6.4秒近く＋処理時間になる見込みです。
+
+   初回診断を操作受付開始前の準備として明確に分離するか、別スレッドまたはフレームごとの分割実行にしてください。現在のテストはRPCの件数だけを確認しており、この入力停止を検出できません。
+
+**推奨**
+
+1. **空の不正な`regs`も検証してください。**
+
+   [mecharm_daemon.py:127](/Users/shimazueiichi/r2r2r-scripts/tools/pi/mecharm_daemon.py:127) の `cmd_obj.get("regs") or []` により、`{}`、`""`、`False`、`0` が空配列になります。実際に`regs={}`で`ok=True`となり、既定の角度・電流読み取りが実行されることを再現しました。
+
+   「不正な指定なら何も読まずに`ok=False`」を満たすには、未指定・許容する`None`と、それ以外を分けて検証してください。各ペアの長さ・整数・bool除外・範囲の検証は妥当です。
+
+2. **`-1`を無応答と断定できる条件を残してください。**
+
+   [mecharm_daemon.py:173](/Users/shimazueiichi/r2r2r-scripts/tools/pi/mecharm_daemon.py:173) の処理について、2.9.6の`_process_single()`は空データを`-1`にします。一方、受信データが2バイトの`ffff`でも、符号付きデコードによって`-1`になります。ローカルの2.9.6ソースで再現しました。
+
+   想定どおり1バイトの番地値なら`ff`は255になり、今回の扱いで問題ありません。ただし、ライブラリの実装だけでは「`-1`は必ず無応答」とは証明できません。実機の応答形式を確認するまでは、この前提を明記してください。
+
+3. **「1秒以内」は保証ではなく目安にしてください。**
+
+   [servo_current_probe.py:399](/Users/shimazueiichi/r2r2r-scripts/tools/servo_current_probe.py:399) の説明は断定的です。4項目で約0.8秒はシリアル受信待ちの目安であり、書き込み・flush・通信・OSの遅延を含む厳密な上限ではありません。通常計測でも、停止指令は進行中の読み取り完了を待ちます。
+
+**据え置きでよい**
+
+- `[]`・`None`・`-1`の正規化と、通信失敗／項目失敗を分けた集計は、上記の前提内で妥当です。
+- `PAD_MAX_READS=4`を記録作成前に検証する配置は妥当です。
+- `get_servo_data`未対応時の理由が追加されています。
+- 導入スクリプトの返事の構造確認、角度取得の再試行、入れ替え後の復旧案内、既導入時の確認は、R1の指摘を反映しています。
+- 指定された2.9.6ソースでは取得用命令だけを呼びます。既存コマンドの分岐は変更されていません。指定環境での`_read`復元方式とPython 3.6構文互換性にも、新たな問題は見つかりませんでした。
+
+検証はdaemonの偽物テスト**12件成功**、シェル構文チェック成功です。計測側テストは一時ファイルを作るため、読み取り専用環境では実行していません。Pi接続・転送・実機操作は行っていません。
diff --git a/docs/review_result_20261107_read_servo_codex_r3.md b/docs/review_result_20261107_read_servo_codex_r3.md
new file mode 100644
index 0000000..b928903
--- /dev/null
+++ b/docs/review_result_20261107_read_servo_codex_r3.md
@@ -0,0 +1,32 @@
+# codex レビュー結果 R3：daemon の read_servo（2026-11-07 依頼 109）
+
+依頼文: docs/review_request_20261107_read_servo_r3.md（codex exec -s read-only）
+
+R2の必須指摘は解消されています。初回診断を操作受付前に移す対応は妥当です。**必須の追加指摘はありませんが、接続の後始末に推奨事項が1点あります。** ファイルは変更していません。
+
+**必須**
+
+なし。
+
+**推奨**
+
+1. **初回診断後、gamepad側へ接続を引き継ぐ前に終了した場合も後始末してください。**
+
+   [servo_current_probe.py:531](/Users/shimazueiichi/r2r2r-scripts/tools/servo_current_probe.py:531) で開いた接続は、初回診断の失敗時には閉じられます。一方、診断成功後に`TermKeys.__enter__()`や、`gt.main()`の冒頭の記録フォルダ作成などで失敗すると、gamepad側の接続解放処理に到達しません。
+
+   また、初回診断の失敗時には、登録済みRPC observerと`pad_rpc.csv`の後始末も実行されません。通常の単独起動ではプロセス終了で回収されますが、同一プロセスから呼ぶ場合は接続やobserverが残ります。
+
+   `run_with_pad()`全体を覆う後始末を設け、引き継ぎ前の失敗でも接続・observer・CSVを解放することを推奨します。テストには「初回診断失敗」と「診断成功後、gamepad初期化前の失敗」を加えると確認できます。
+
+**据え置きでよい**
+
+- 初回診断は`gt.main()`より前に完了し、`on_frame()`では計測スレッドを開始するため、R2の入力停止問題は解消されています。
+- `get_daemon_socket()`は既存接続を再利用するため、通常起動時の接続引き継ぎは妥当です。
+- 初回診断の失敗時に接続を閉じる処理と、診断をパッドのRPC集計から除外する処理は妥当です。
+- 不正な空の`regs`の拒否、`-1`の解釈に関する前提の明記、待ち時間を目安とする説明は、R2の指摘を反映しています。
+- daemonの既存コマンド分岐、読み取り専用の呼び出し、指定された2.9.6環境での`_read`復元方式について、追加の問題は見つかりませんでした。
+
+検証はdaemonの偽物テスト**12件成功**、導入スクリプトのシェル構文チェック成功です。計測側10件は一時ファイルを作成するため、読み取り専用環境では実行していません。Piへの接続・転送・実機操作は行っていません。
+
+## 反映（Claude Code）
+- 推奨 1 を反映: run_with_pad の始めの 1 回から gamepad_teleop.main までを 1 つの try/finally で包み、どこで終わっても読み取りのスレッド・観察（rpc observer）・pad_rpc.csv を片付ける。gamepad_teleop の窓まで行かなかったときだけ、この道具がつながりを閉じる（窓まで行ったあとは gamepad_teleop.main の閉じ方のまま）。テスト test_cleanup_before_handover（始めの 1 回の失敗・gamepad_teleop の初めの失敗）を足した
diff --git a/lab_train_fakes.sh b/lab_train_fakes.sh
index 87a5d9f..8b8e861 100644
--- a/lab_train_fakes.sh
+++ b/lab_train_fakes.sh
@@ -69,13 +69,18 @@ POLL_SEC=${LAB_DRY_POLL_SEC:-1}
 NOTIFY_TITLE="$NOTIFY_TITLE (ドライラン)"
 
 # 偽物のチェックポイント (Pod 側の一覧と同じ中身を Hugging Face / scp で取ったことにする)
-FAKE_CKPT_FILES=$'_CHECKPOINT_METADATA\t426\nassets/local/mecharm_redblock_box_teach_v1_trim2_corr_v3/norm_stats.json\t2120\nparams/_METADATA\t33052\nparams/d/0123abcd\t1048576\nparams/manifest.ocdbt\t118'
-fake_make_ckpt() {          # fake_make_ckpt フォルダ [ずらす] → FAKE_CKPT_FILES のとおりのファイルを作る
+# assets の名前は作るモデルの学習データ (DATA_NAME. corr: ..._corr_v3 / corr2: ..._corr_v4b / corr3: ..._corr_v5).
+# --stop は記録のモデルで use_model し直すので, 呼んだときの DATA_NAME で作る (依頼 109 D)
+fake_ckpt_files() {
+    printf '_CHECKPOINT_METADATA\t426\nassets/local/%s/norm_stats.json\t2120\nparams/_METADATA\t33052\nparams/d/0123abcd\t1048576\nparams/manifest.ocdbt\t118\n' \
+        "${DATA_NAME:-mecharm_redblock_box_teach_v1_trim2_corr_v3}"
+}
+fake_make_ckpt() {          # fake_make_ckpt フォルダ [ずらす] → fake_ckpt_files のとおりのファイルを作る
     local d="$1" p n
     while IFS=$'\t' read -r p n; do
         [ "${2:-}" = shift ] && [ "$p" = params/manifest.ocdbt ] && n=$((n + 1))
         mkdir -p "$d/$(dirname "$p")"; head -c "$n" /dev/zero > "$d/$p"
-    done <<<"$FAKE_CKPT_FILES"
+    done < <(fake_ckpt_files)
 }
 fake_n() { echo x >> "$LAB_DRY_ROOT/fake/$1.n"; wc -l < "$LAB_DRY_ROOT/fake/$1.n" | tr -d ' '; }
 
@@ -138,11 +143,11 @@ pod_ssh() {
             esac ;;
         *": lt=list_ckpt"*|*": lt=ls_all"*)
             if [ "$LAB_FAKE_SCENARIO" = list_partial ] && [[ "$*" == *"lt=list_ckpt"* ]]; then
-                sed 's/^/F\t/' <<<"$FAKE_CKPT_FILES" | head -2; echo "find: './params/d': Input/output error"; return 1
+                sed 's/^/F\t/' < <(fake_ckpt_files) | head -2; echo "find: './params/d': Input/output error"; return 1
             fi
             if [ -f "$LAB_DRY_ROOT/fake/status.n" ] && [ "$LAB_FAKE_SCENARIO" != crash ] && [ "$LAB_FAKE_SCENARIO" != list_empty ] && [ "$LAB_FAKE_SCENARIO" != killed ] \
                && [ "$LAB_FAKE_SCENARIO" != timeout ] && [ "$LAB_FAKE_SCENARIO" != stall ]; then
-                sed 's/^/F\t/' <<<"$FAKE_CKPT_FILES"
+                sed 's/^/F\t/' < <(fake_ckpt_files)
             fi ;;
         *"hf_upload_ckpt.py"*)
             if [ "$LAB_FAKE_SCENARIO" = hf_fail ]; then echo "HFUP error HfHubHTTPError: 401 Client Error"; return 6; fi
diff --git a/logs/20261107_1420.md b/logs/20261107_1420.md
new file mode 100644
index 0000000..77de8e4
--- /dev/null
+++ b/logs/20261107_1420.md
@@ -0,0 +1,38 @@
+# 2026-11-07 依頼 109：mechArm の関節と爪の電流が読めるかを確かめ、測る道具を作る
+
+## A. 読めるか（ソースで確かめた。アームは動かしていない）
+Pi は今日つながらなかった（192.168.2.1:22 がタイムアウト）。なので、Pi と同じ pymycobot 2.9.6 を手元に取り寄せ（/tmp/pmc_x。読むだけ）、ソースで確かめた。あわせて 11-03 の servo_check の結果（diag/servo_check_20261103_071326）を見た。
+
+- 関節の電流 `get_servo_currents()`（命令 0xE2）: 2.9.6 にある。説明は「mycobot 350 だけ」「0〜3250 mA」。返事の 12 バイトを 2 バイトずつ符号つきで読んで 6 個の数にする。返事がないと 0.1 秒待って `[]`。返事が 6 バイト（1 関節 1 バイト）の形なら、間違って 3 個の数になる。いまの pymycobot 4.0.5 の MechArm270 には、この関数がない（350 などにだけある）→ 270 のファームが答えるかは分からない。実機で確かめる
+- 異常の印 `get_servo_status()`（0xE4）: 2.9.6 にある。[電圧, センサー, 温度, 電流, 角度, 過負荷]（0 = 正常）。11-03 はこの機体で `[0, 0, 0]`（6 バイトを 2 バイトずつ読んだ形）
+- 同じ番号の仲間 get_servo_temps は 11-03 に `[6425, 5654, 6421]` = 0x1919・0x1616・0x1915。1 関節 1 バイトの 6 バイト（25,25,22,22,25,21 ℃）を 2.9.6 が 2 バイトずつ読み違えている。電流も 1 バイトずつで返ると読み違える → 道具では生のバイト列も残す
+- `get_servo_data(id, 番地)`（0x53）: 1 つの番地を 1 回で読む。11-03 は番地 62 = 72〜74（0.1 V 単位で 7.4 V）・63 = 21〜26（℃）・65 = 0。Feetech STS の並びに合う → 番地 69/70（今の電流。STS3215 なら 1 = 6.5 mA）と 60/61（負荷。0.1% 単位・bit10 が向き）も読める見込み。爪は id 7（4.x の is_torque_gripper が get_servo_data(7, 1) を使う）。2.9.6 は 1 バイトずつしか読まないので、下位と上位を別々に読む
+- 爪: 6 関節と同じ get_servo_currents には入らない（6 個だけ）。爪の電流は id 7 の番地 69/70 を読むしかない（未確認）。保護電流（GetGripperProtectCurrent 0x37。1〜500）は 4.x の MechArm270 にだけあり、2.9.6 にはない。0x37 は別の機種では JOG_INCREMENT_BASE_COORD（動かす命令）と同じ番号なので、生の 0x37 は送らない（決めてほしいこと）
+- daemon（tools/pi/mecharm_daemon.py）には電流を読む命令がなかった → 読むだけの `read_servo` を足した（下）
+- ぶつかり: daemon は 1 本ずつしかつながりを受けない（listen(1)・1 本を処理し終えるまで次を受けない）。別のプロセスから同時に読むことはできない。ゲームパッドと一緒に測るときは、同じプロセスで動かして鍵を共有する（run_policy_client の p と同じ形）
+- 気づいたこと（直していない）: gamepad_teleop を単独で動かすと、送り口 `daemon_request` を鍵をかける前に読む（load_daemon_modules のあとに install_daemon_lock）。gamepad_teleop の send_angle・stop・get_angles は鍵を通らない。測る道具は、先に鍵をかけてから読ませるので、この問題はない
+
+## B. 作ったもの
+- `tools/pi/mecharm_daemon.py`: `read_servo` を足した（純追記。消した行 0）。angles・currents は既定で読み、status・gripper・regs（[id, 番地] 32 個まで。id 1〜7・番地 0〜100）は頼んだときだけ読む。読み直さない。読めないときは value None・error（"no reply" は 2.9.6 が返す [] / -1）。raw=True で返事のバイト列
+- `tools/daemon_add_read_servo.sh`: Pi の daemon を持ち帰って KIOXIA の archive/ に写しを残す。11-05 の版（sha256 806336b6…）のときだけ置き換えて起動し直す。そのあと ping・get_angles・read_servo（角度が 6 個読めること）を確かめる。失敗したら戻すコマンドを出す
+- `tools/servo_current_probe.py`: 読むだけ。始めに全部（電流・状態・爪の値・番地 sts の 28 個・バイト列）を 4 個ずつ 8 回で読んで first_read.json に残す。そのあと角度と電流を詰めて読み、samples.csv に書く（Mac の往復・Pi の中の時間・項目ごとの ms）。印はキー 1〜6・0、p で読むのを止める/再開。`--with-pad` は gamepad_teleop を同じプロセスで動かし、パッドの命令ごとに鍵の待ち（wait_ms）を pad_rpc.csv に書く（読んでいる間と止めた間を比べられる）。パッドと一緒のときは 1 回に読む数を 4 までにした。出力は `diag/servo_current_{日付}/{時刻}_{solo|pad}/`。KIOXIA がなければ止める
+- テスト: tools/test_daemon_read_servo.py（12 件）・tools/test_servo_current_probe.py（11 件）
+- codex: 3 回（docs/review_request_20261107_read_servo{,_r2,_r3}.md → docs/review_result_20261107_read_servo_codex{,_r2,_r3}.md）。R1 必須 2（始めの一括が 3 秒を超える・返事なしを成功の値にする）、R2 必須 1（始めの 1 回を窓の中で読むと停止を見ない）を反映。R3 は必須なし。推奨 1（窓まで行かずに終わったときの片付け）も反映した
+
+## 速さの見込み（実機では測っていない）
+- 11-06 の記録（pi05_20261106_183926）: send_angle の rpc（送る + 角度を 1 回読む）0.032 秒・set_gripper_value（返事を読まない）0.013 秒 → 1 回読むのは 15〜20 ms ほど
+- read_servo（角度 + 電流）: 両方答えれば 1 回 30〜45 ms → 20〜30 Hz。電流が答えなければ、毎回 0.1 秒待つ → 7 Hz ほど（そのときは --no-currents で番地を読む。爪の 69/70 なら角度 + 2 個で 15〜20 Hz の見込み）
+- ゲームパッド: 命令は、読んでいる最中の 1 回が終わるまで鍵を待つ。最大で 1 回分（答えれば約 40 ms、答えない項目があれば 1 つにつき 0.1〜0.2 秒）。記録（run_policy_client の 10 Hz の角度）に足すなら、角度と電流を 1 回の read_servo で読めば、問い合わせの数は今と同じ
+
+## D. 学習のテストの偽物の assets
+- lab_train_fakes.sh: 偽物のチェックポイントの assets の名前を、作るモデルの学習データ（DATA_NAME）にした（corr: ..._corr_v3 / corr2: ..._corr_v4b / corr3: ..._corr_v5）。--stop でモデルを読み直したあとの名前で作るよう、呼んだときに作る関数にした。本番の流れ（lab_train.sh・lab_lib.sh）は変えていない
+- test_lab_train.sh: 12b・12c に assets の名前の確かめを 2 件足した → OK 216 / NG 0（足した 2 件を含む）
+
+## テスト
+- test_lab_train.sh 216/0・test_lab_start.sh 153/0
+- pi05/test_*.py 9 本・test_provenance.py・tools/test_servo_check_pi.py・place_modules/tests 4 本・新しい 2 本: すべて OK
+- tools/test_gamepad_teleop.py: 90 件中 1 件 NG（test_daemon_arm_step_not_moved_returns_and_resends の「1 秒より短く」が 1.03〜1.04 秒）。このテストが読むファイルには触っていない。HEAD の写しでも試そうとしたが、git にないファイル（auto_detect_cameras.py）がないので動かせなかった
+- experiments/test_top_cam_dual_open.py は実機カメラを使う確かめなので流していない
+
+## 変えたファイル
+tools/pi/mecharm_daemon.py・lab_train_fakes.sh・test_lab_train.sh（変更）、tools/daemon_add_read_servo.sh・tools/servo_current_probe.py・tools/test_daemon_read_servo.py・tools/test_servo_current_probe.py・docs/review_request_20261107_read_servo*.md（3）・docs/review_result_20261107_read_servo_codex*.md（3）・logs/20261107_1420.md（新規）
diff --git a/test_lab_train.sh b/test_lab_train.sh
index 4bf9c55..d0f84fd 100755
--- a/test_lab_train.sh
+++ b/test_lab_train.sh
@@ -204,6 +204,7 @@ P2="$TD/train_provenance.json"
 check "由来: 入力は corr_v4b と corr の 1499 (KIOXIA の原本・指紋・Hugging Face の元)" '[ "$(jq -r ".inputs[0].id" "$P2")" = mecharm_redblock_box_teach_v1_trim2_corr_v4b ] && [ "$(jq -r ".inputs[1] | [.kind,.id,.path,.hf_repo,.pod_path] | join(\" \")" "$P2")" = "checkpoint pi05_mecharm_trim_corr_lora_v1/1499 checkpoints/pi05_mecharm_trim_corr_lora_v1/1499 PixelstoMotors/pi05-mecharm-trim-corr-lora-v1 /workspace/ckpt/corr_v1_1499/params" ] && [ "$(jq -r ".inputs[1].fingerprint" "$P2")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/checkpoints/pi05_mecharm_trim_corr_lora_v1/1499")" ]'
 check "由来: 設定・出力・チェックポイント側と Hugging Face にも同じもの" '[ "$(jq -r ".config | [.name,.exp_name,.init_params,.data_name] | join(\" \")" "$P2")" = "pi05_mecharm_trim_corr_v4_lora trim_corr_v4 /workspace/ckpt/corr_v1_1499/params mecharm_redblock_box_teach_v1_trim2_corr_v4b" ] && [ "$(jq -r ".outputs[0] | [.path,.hf_repo] | join(\" \")" "$P2")" = "checkpoints/pi05_mecharm_trim_corr_lora_v2/1499 PixelstoMotors/pi05-mecharm-trim-corr-lora-v2" ] && cmp -s "$P2" "$CK2/1499/train_provenance.json" && has calls.log "hf upload PixelstoMotors/pi05-mecharm-trim-corr-lora-v2 train_provenance.json"'
 check "由来: argv に --model corr2" 'jq -e ".producer.argv | index(\"corr2\")" "$P2" >/dev/null'
+check "偽物のチェックポイントの assets は corr_v4b の名前 (依頼 109 D)" '[ -f "$CK2/1499/assets/local/mecharm_redblock_box_teach_v1_trim2_corr_v4b/norm_stats.json" ] && [ ! -e "$CK2/1499/assets/local/mecharm_redblock_box_teach_v1_trim2_corr_v3" ]'
 rm -rf "$T"
 T=$(mktemp -d); mkdir -p "$T/data/lab/LogT/train" "$T/fake"
 jq -n '{id:"dryrunpod0001", kind:"train", model:"corr2", config:"pi05_mecharm_trim_corr_v4_lora", gpu:"NVIDIA A40", costPerHr:0.44, created_epoch:1793449178, created_at:"2026-10-31 21:19:38", exp:"trim_corr_v4", train_started_epoch:1793449300}' > "$T/data/lab/LogT/train/lab_pod.json"
@@ -226,6 +227,7 @@ check "記録 lab_pod.json に model・設定・exp・init" '[ "$(jq -r "[.model
 P3="$TD/train_provenance.json"
 check "由来: 入力は corr_v5 と corr2 の 1499 (KIOXIA の原本・指紋・Hugging Face の元)" '[ "$(jq -r ".inputs[0].id" "$P3")" = mecharm_redblock_box_teach_v1_trim2_corr_v5 ] && [ "$(jq -r ".inputs[1] | [.kind,.id,.path,.hf_repo,.pod_path] | join(\" \")" "$P3")" = "checkpoint pi05_mecharm_trim_corr_lora_v2/1499 checkpoints/pi05_mecharm_trim_corr_lora_v2/1499 PixelstoMotors/pi05-mecharm-trim-corr-lora-v2 /workspace/ckpt/corr_v2_1499/params" ] && [ "$(jq -r ".inputs[1].fingerprint" "$P3")" = "$(python3 "$HERE/provenance.py" fingerprint "$T/data/checkpoints/pi05_mecharm_trim_corr_lora_v2/1499")" ]'
 check "由来: 設定・出力・チェックポイント側と Hugging Face にも同じもの" '[ "$(jq -r ".config | [.name,.exp_name,.init_params,.data_name] | join(\" \")" "$P3")" = "pi05_mecharm_trim_corr_v5_lora trim_corr_v5 /workspace/ckpt/corr_v2_1499/params mecharm_redblock_box_teach_v1_trim2_corr_v5" ] && [ "$(jq -r ".outputs[0] | [.path,.hf_repo] | join(\" \")" "$P3")" = "checkpoints/pi05_mecharm_trim_corr_lora_v3/1499 PixelstoMotors/pi05-mecharm-trim-corr-lora-v3" ] && cmp -s "$P3" "$CK3/1499/train_provenance.json" && has calls.log "hf upload PixelstoMotors/pi05-mecharm-trim-corr-lora-v3 train_provenance.json"'
+check "偽物のチェックポイントの assets は corr_v5 の名前 (依頼 109 D)" '[ -f "$CK3/1499/assets/local/mecharm_redblock_box_teach_v1_trim2_corr_v5/norm_stats.json" ] && [ ! -e "$CK3/1499/assets/local/mecharm_redblock_box_teach_v1_trim2_corr_v3" ]'
 rm -rf "$T"
 T=$(mktemp -d); mkdir -p "$T/data/lab/LogT/train" "$T/fake"
 jq -n '{id:"dryrunpod0001", kind:"train", model:"corr3", config:"pi05_mecharm_trim_corr_v5_lora", gpu:"NVIDIA A40", costPerHr:0.44, created_epoch:1793449178, created_at:"2026-10-31 21:19:38", exp:"trim_corr_v5", train_started_epoch:1793449300}' > "$T/data/lab/LogT/train/lab_pod.json"
diff --git a/tools/daemon_add_read_servo.sh b/tools/daemon_add_read_servo.sh
new file mode 100755
index 0000000..10462cf
--- /dev/null
+++ b/tools/daemon_add_read_servo.sh
@@ -0,0 +1,95 @@
+#!/bin/bash
+# daemon_add_read_servo.sh — Pi の mecharm_daemon.py に「read_servo」(電流などを読むだけ) を足した版を置いて起動し直す (2026-11-07 依頼 109)
+#
+#   tools/daemon_add_read_servo.sh
+#
+# なぜ: 関節と爪の電流が読めるか・どれだけ速く読めるかを tools/servo_current_probe.py で測るため.
+#       今の daemon には電流を読む命令がない. daemon がシリアルを握っているので, 別のプロセスからポートを開かない.
+# 足す命令: {"cmd": "read_servo", ...} → get_angles / get_servo_currents / get_servo_status / get_gripper_value /
+#       get_servo_data を頼まれた分だけ 1 回ずつ呼ぶ (読むだけ. アームにも爪にも送らない).
+#       ほかの命令は 1 文字も変えない (unknown cmd の else の直前に足し, 関数を 2 つ足すだけ).
+# 流れ:
+#   1. Pi で今動いている /home/ubuntu/mecharm_daemon.py を Mac に持ち帰り, KIOXIA の archive/ に写しを残す
+#   2. 持ち帰ったものが 11-05 の版 (tools/daemon_fast_read.sh のあと. sha256 806336b6…) なら tools/pi/mecharm_daemon.py を置く.
+#      すでに tools/pi/mecharm_daemon.py と同じなら置かない. どちらでもなければ止まる (Pi で誰かが変えている)
+#   3. Pi へ一時名で送ってから入れ替え, systemd で起動し直し, ping・get_angles・read_servo が返事をするか確かめる
+#      うまくいかなければ, 元に戻すコマンドを出して止まる
+# 流す前に: ゲームパッドなど daemon を使うものを止め, アームを HOME など安全な姿勢にしておく
+#           (daemon を起動し直すと MyCobot の初期化が走る)
+set -u
+PI=ubuntu@192.168.2.1
+KEY="$HOME/.ssh/id_rsa_pi"
+SSH=(ssh -n -i "$KEY" -o ConnectTimeout=5 "$PI")
+UNIT=mecharm-daemon.service
+HERE="$(cd "$(dirname "$0")" && pwd)"
+NEW="$HERE/pi/mecharm_daemon.py"
+BEFORE_SHA=806336b6e55e3d4e6d7cf8941f88741cf28b604cef43addb941694e574212c81   # 11-05 の版 (c7992ca の tools/pi/mecharm_daemon.py)
+DATA_ROOT=/Volumes/KIOXIA/r2r2r-data
+[ -d "$DATA_ROOT/archive" ] || { echo "ABORT: $DATA_ROOT/archive がない (KIOXIA がマウントされていない?)"; exit 1; }
+grep -q 'cmd == "read_servo"' "$NEW" || { echo "ABORT: $NEW に read_servo がない"; exit 1; }
+python3 -c "import sys; compile(open(sys.argv[1], encoding='utf-8').read(), 'mecharm_daemon.py', 'exec')" "$NEW" || { echo "ABORT: $NEW の構文が通らない"; exit 1; }
+STAMP=$(date +%Y%m%d_%H%M%S)
+BK="$DATA_ROOT/archive/mecharm_daemon_premerge_readservo_$STAMP"
+mkdir "$BK" || exit 1
+WORK=$(mktemp -d)
+
+scp -i "$KEY" -q -o ConnectTimeout=5 "$PI:/home/ubuntu/mecharm_daemon.py" "$WORK/before.py" || { echo "ABORT: Pi から持ち帰れない"; exit 1; }
+cp "$WORK/before.py" "$BK/mecharm_daemon.py" || { echo "ABORT: 写しを残せない (Pi の daemon は変えていません)"; exit 1; }
+echo "今の daemon の写し: $BK/mecharm_daemon.py"
+SHA=$(shasum -a 256 "$WORK/before.py" | cut -d' ' -f1)
+NEW_SHA=$(shasum -a 256 "$NEW" | cut -d' ' -f1)
+RESTORE="scp -i $KEY $BK/mecharm_daemon.py $PI:/home/ubuntu/mecharm_daemon.py && ssh -i $KEY $PI sudo systemctl restart $UNIT"
+fail() { echo "ABORT: $1"; echo "元に戻すには: $RESTORE"; exit 1; }
+ALREADY=no
+if [ "$SHA" = "$NEW_SHA" ]; then
+    echo "Pi の daemon はもう read_servo の版です。置き直さず, 起動し直さず, 返事だけ確かめます"
+    ALREADY=yes
+fi
+[ "$ALREADY" = yes ] || [ "$SHA" = "$BEFORE_SHA" ] || { echo "ABORT: Pi の daemon が 11-05 の版でも新しい版でもない (sha256 $SHA). Pi の daemon は変えていません"; exit 1; }
+if [ "$ALREADY" = no ]; then
+cp "$NEW" "$BK/mecharm_daemon_with_read_servo.py" || exit 1
+echo "--- 差分"
+diff "$WORK/before.py" "$NEW"
+
+scp -i "$KEY" -q -o ConnectTimeout=5 "$NEW" "$PI:/home/ubuntu/mecharm_daemon.py.new" || { echo "ABORT: Pi へ送れない (Pi の daemon は変えていません)"; exit 1; }
+"${SSH[@]}" "python3 -m py_compile /home/ubuntu/mecharm_daemon.py.new" \
+    || { echo "ABORT: Pi で構文が通らない (Pi の daemon は変えていません)"; exit 1; }
+# mv のあとに ssh が切れても入れ替わっているかもしれないので, ここからは戻し方を出す (codex R1 推奨)
+"${SSH[@]}" "mv /home/ubuntu/mecharm_daemon.py.new /home/ubuntu/mecharm_daemon.py" \
+    || fail "Pi で入れ替えられない (入れ替わったかは分からない. 起動し直していないので, 動いている daemon は前の版のまま)"
+"${SSH[@]}" "sudo systemctl restart $UNIT" || fail "systemctl restart が失敗した"
+UP=no
+for i in 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15; do
+    sleep 2
+    "${SSH[@]}" "ss -ltn | grep -q ':5555 '" && { UP=yes; break; }
+done
+[ "$UP" = yes ] || fail "30 秒たっても 5555 が開かない"
+fi
+python3 - <<'PY'
+import json, socket
+s = socket.create_connection(("192.168.2.1", 5555), timeout=5)
+f = s.makefile("rwb")
+import time
+for c in ({"cmd": "ping"}, {"cmd": "get_angles"},
+          {"cmd": "read_servo", "status": True, "gripper": True, "raw": True}):
+    for attempt in range(5):        # read_servo は読み直さないので, 角度が空で返ったら 5 回まで頼み直す
+        f.write((json.dumps(c) + "\n").encode()); f.flush()
+        r = json.loads(f.readline().decode())
+        a = (r.get("items", {}).get("angles") or {}).get("value") if c["cmd"] == "read_servo" else None
+        if c["cmd"] != "read_servo" or (isinstance(a, list) and len(a) == 6):
+            break
+        time.sleep(0.2)
+    print(c["cmd"], "→", r)
+    assert r.get("ok"), c["cmd"] + " が ok を返さない"
+    if c["cmd"] == "read_servo":
+        # 返事の形を確かめる (電流は読めなくてもよい. 読めるかを測るのがこの先の仕事. codex R1 推奨)
+        items = r.get("items", {})
+        assert set(items) == {"angles", "currents", "status", "gripper_value"}, "read_servo の items が足りない"
+        a = items["angles"].get("value")
+        assert isinstance(a, list) and len(a) == 6, "read_servo で角度が読めない: " + repr(items["angles"])
+        for k in ("currents", "status", "gripper_value"):
+            print("  ", k, "=", items[k].get("value"), "" if not items[k].get("error") else "(" + items[k]["error"] + ")")
+s.close()
+PY
+[ $? = 0 ] || fail "ping か get_angles か read_servo が ok を返さない"
+echo "できました。記録: $BK"
diff --git a/tools/pi/mecharm_daemon.py b/tools/pi/mecharm_daemon.py
index 6df4b30..2e9f172 100644
--- a/tools/pi/mecharm_daemon.py
+++ b/tools/pi/mecharm_daemon.py
@@ -64,6 +64,127 @@ def read_angles_once_for_log():
     return a if isinstance(a, (list, tuple)) and len(a) == 6 else None
 
 
+# ---- 2026-11-07 依頼 109: 電流などを読むだけの命令 read_servo (純追記) ----------------------
+# アームと爪には何も送らない. pymycobot の読む関数 (get_angles / get_servo_currents / get_servo_status /
+# get_gripper_value / get_servo_data) だけを, 頼まれた順に 1 回ずつ呼ぶ. 読み直さない・待たない.
+# 読めなかった項目は値 None と error を返す (ok=True のまま. get_angles_once と同じ. 呼び出し側が例外にしないため).
+#   2.9.6 は返事がないとき 例外にせず, リストを返す関数は [] を, 1 つの値を返す関数は -1 を返す.
+#   これも読めなかったとして value None・error "no reply"・returned (返ってきた値そのまま) にする (codex R1 必須 2).
+#   前提 (この機体で確かめていない. codex R2): 番地の値は 1 バイトで返る (ff は 255 になり -1 にならない).
+#   2 バイトの ffff が返る形だと符号つきで -1 になり, 返事なしと見分けられない. raw=True のバイト列で確かめる.
+#   2.9.6 の read は返事がないと 1 回 0.1 秒ほど待つ (途中まで返ると 0.2 秒近く). 頼む数を増やすと, その分
+#   daemon がほかの命令 (ゲームパッドの stop も) を受けるのが遅れる. 呼び出し側で数を絞る (codex R1 必須 1).
+# 頼み方がおかしいとき (regs の形・番号の範囲) だけ ok=False で, そのときは何も読まない.
+#   {"cmd": "read_servo", "angles": true, "currents": true, "status": false, "gripper": false,
+#    "regs": [[servo_id, addr], ...], "raw": false}
+#   angles / currents は既定で読む. status / gripper / regs は頼んだときだけ.
+#   regs: get_servo_data(servo_id, addr) を 1 つずつ (servo_id 1〜7. 7 = 爪. addr 0〜100. 32 個まで).
+#         この版の get_servo_data は番地を 1 つ渡すだけなので, 2 バイトの値は下位と上位の番地を別々に頼む.
+#   raw: 返ってきたバイト列 (16 進) も返す (形の分からない返事を確かめる用).
+# 返事: {"ok": true, "t": Pi の時刻, "total_ms": ..., "items": {名前: {"value": ..., "ms": ..., ("error"), ("raw")}},
+#        "regs": [[servo_id, addr, value, ms], ...]}
+SERVO_READ_MAX_REGS = 32
+
+
+def _servo_read_one(fn, args, want_raw, no_reply=None):
+    """読む関数を 1 回だけ呼ぶ. 返事のバイト列は _read を一時的に包んで写す (daemon は 1 件ずつなので混ざらない).
+    no_reply: 2.9.6 が返事のないときに返す値 ([] か -1). これが返ったら value None・error にする."""
+    item = {}
+    cap = []
+    if want_raw:
+        orig = _mc._read
+
+        def _cap_read(*a, **k):
+            d = orig(*a, **k)
+            cap.append(d)
+            return d
+        _mc._read = _cap_read
+    t0 = time.perf_counter()
+    try:
+        v = fn(*args)
+        if isinstance(v, tuple):
+            v = list(v)
+        if v is None or (no_reply is not None and v == no_reply):
+            item["value"] = None
+            item["error"] = "no reply"
+            item["returned"] = v
+        else:
+            item["value"] = v
+    except Exception as e:
+        item["value"] = None
+        item["error"] = f"{type(e).__name__}: {e}"
+    finally:
+        item["ms"] = round((time.perf_counter() - t0) * 1000.0, 2)
+        if want_raw:
+            del _mc._read           # インスタンスに置いた包みを外す (クラスの _read に戻る)
+            item["raw"] = [d.hex() if isinstance(d, (bytes, bytearray)) else repr(d) for d in cap]
+    try:
+        json.dumps(item["value"])
+    except (TypeError, ValueError):
+        item["value"] = repr(item["value"])[:200]
+    return item
+
+
+def handle_servo_read(cmd_obj):
+    regs = cmd_obj.get("regs")
+    if regs is None:                # 頼まないのは「ない」か None だけ ({}・""・0 は形がおかしいとして断る. codex R2)
+        regs = []
+    if not isinstance(regs, (list, tuple)) or len(regs) > SERVO_READ_MAX_REGS:
+        return {"ok": False, "error": f"regs must be a list of at most {SERVO_READ_MAX_REGS} [servo_id, addr]"}
+    pairs = []
+    for r in regs:
+        try:
+            if not isinstance(r, (list, tuple)) or len(r) != 2:
+                raise ValueError("length")
+            # 整数だけ (1.9 を 1 に丸めて読まない. True も数にしない. codex R1 推奨)
+            if not all(isinstance(x, int) and not isinstance(x, bool) for x in r):
+                raise TypeError("not int")
+            sid, addr = r
+        except (TypeError, ValueError):
+            return {"ok": False, "error": f"bad reg: {r!r}"}
+        if not (1 <= sid <= 7) or not (0 <= addr <= 100):
+            return {"ok": False, "error": f"reg out of range (servo_id 1..7, addr 0..100): {r!r}"}
+        pairs.append((sid, addr))
+    want_raw = bool(cmd_obj.get("raw", False))
+    plan = []
+    if cmd_obj.get("angles", True):
+        plan.append(("angles", "get_angles", []))
+    if cmd_obj.get("currents", True):
+        plan.append(("currents", "get_servo_currents", []))
+    if cmd_obj.get("status", False):
+        plan.append(("status", "get_servo_status", []))
+    if cmd_obj.get("gripper", False):
+        plan.append(("gripper_value", "get_gripper_value", -1))
+    t_start = time.perf_counter()
+    t_wall = time.time()
+    items = {}
+    for key, name, no_reply in plan:
+        fn = getattr(_mc, name, None)
+        if fn is None:
+            items[key] = {"value": None, "ms": 0.0, "error": f"{name} is not available in this pymycobot environment"}
+            continue
+        items[key] = _servo_read_one(fn, (), want_raw, no_reply)
+    out_regs = []
+    raw_regs = []
+    if pairs:
+        fn = getattr(_mc, "get_servo_data", None)
+        for sid, addr in pairs:
+            if fn is None:
+                out_regs.append([sid, addr, None, 0.0])
+                raw_regs.append({"servo_id": sid, "addr": addr, "raw": None,
+                                 "error": "get_servo_data is not available in this pymycobot environment"})
+                continue
+            it = _servo_read_one(fn, (sid, addr), want_raw, -1)
+            out_regs.append([sid, addr, it["value"], it["ms"]])
+            if want_raw or "error" in it:
+                raw_regs.append({"servo_id": sid, "addr": addr, "raw": it.get("raw"), "error": it.get("error")})
+    resp = {"ok": True, "t": t_wall, "total_ms": round((time.perf_counter() - t_start) * 1000.0, 2),
+            "items": items, "regs": out_regs}
+    if raw_regs:
+        resp["regs_detail"] = raw_regs
+    return resp
+
+
 def handle_command(cmd_obj):
     """コマンドを処理してレスポンス dict を返す。"""
     global _fresh_mode_state
@@ -259,6 +380,10 @@ def handle_command(cmd_obj):
             return {"ok": False, "error": f"stop failed: {res}"}
         return {"ok": True, "result": res}
 
+    # 2026-11-07 依頼 109: 関節と爪の電流を読む (読むだけ. 純追記). handle_servo_read の説明を参照.
+    elif cmd == "read_servo":
+        return handle_servo_read(cmd_obj)
+
     else:
         return {"ok": False, "error": f"unknown cmd: {cmd}"}
 
diff --git a/tools/servo_current_probe.py b/tools/servo_current_probe.py
new file mode 100755
index 0000000..4ee2c50
--- /dev/null
+++ b/tools/servo_current_probe.py
@@ -0,0 +1,626 @@
+#!/usr/bin/env python3
+"""servo_current_probe.py — mechArm の関節と爪の電流 (と同じ時刻の角度) を読める限り速く読み, CSV に残す (依頼 109. 2026-11-07)
+
+アームと爪には何も送らない. daemon の read_servo (tools/daemon_add_read_servo.sh で足す. 読むだけ) を繰り返し呼ぶだけ.
+daemon は 1 本ずつしかつながりを受けないので, ゲームパッドと一緒に測るときは --with-pad で
+tools/gamepad_teleop.py をこのプロセスの中で動かす (run_policy_client の p と同じ形. 送り方・守りは gamepad_teleop のまま).
+そのときは daemon とのやりとりに 1 つの鍵をかけ (policy_recorder.install_daemon_lock), ゲームパッドの命令が
+鍵を待った時間 (wait_ms) とやりとりの長さ (rpc_ms) を pad_rpc.csv に残す. p キーで読むのを止める/再開でき,
+読んでいる間と止めている間の wait_ms を比べられる.
+
+  python3 tools/servo_current_probe.py --seconds 10       # 静止で 10 秒だけ読む (アームは手で触らない)
+  python3 tools/servo_current_probe.py                    # q で終わるまで読む. 手でアームを押すなど
+  python3 tools/servo_current_probe.py --with-pad         # ゲームパッドで動かしながら読む (ウィンドウを閉じると終わる)
+  python3 tools/servo_current_probe.py --regs sts         # 電流の関数が読めないとき: サーボの番地 69/70 (電流) と 60/61 (負荷) も読む
+
+印 (ターミナルで 1 文字. --with-pad のときはゲームパッドの窓が手前でも受け付ける):
+  1 静止  2 爪を空で閉じた  3 つかんだ  4 引っ張った  5 離した  6 アームを押した  0 そのほか
+  p 読むのを止める/再開   q 終わる (--with-pad のときはウィンドウを閉じる)
+
+始めに 1 回だけ, 読めるものを全部 (電流・状態・爪の値・サーボの番地 sts の 28 個・返事のバイト列) 読んで
+first_read.json に残し, 画面に出す. 電流の関数が返す形はこの機体で確かめていない (2.9.6 の説明は「mycobot 350 だけ」).
+サーボの番地 (60/61 負荷・69/70 電流) は Feetech STS 想定の値で, この機体で確かめていない. 生の値をそのまま残す.
+
+出力: /Volumes/KIOXIA/r2r2r-data/diag/servo_current_{YYYYMMDD}/{HHMMSS}_{solo|pad}/
+  samples.csv     1 回の読み取りごとに 1 行 (Mac の時刻・往復・Pi の中の時間・角度・電流・印)
+  marks.csv       印 (時刻・キー・名前)
+  pad_rpc.csv     (--with-pad) ゲームパッドの命令 1 つごとに 鍵の待ち・やりとりの長さ・読むのを止めていたか
+  first_read.json 始めの 1 回 / summary.json 回数・Hz・往復の中央値など / servo_current_probe.py (使った写し)
+  ゲームパッドの押した記録は今までどおり runs/experiments/gamepad_teleop/teleop_*/ (summary.json に場所を書く)
+"""
+import argparse
+import csv
+import json
+import os
+import queue
+import select
+import shutil
+import socket
+import statistics
+import sys
+import threading
+import time
+from datetime import datetime
+from pathlib import Path
+
+DAEMON_HOST = "192.168.2.1"
+DAEMON_PORT = 5555
+RPC_TIMEOUT_SEC = 3.0
+SSD_MOUNT = "/Volumes/KIOXIA"
+OUT_ROOT = Path(SSD_MOUNT) / "r2r2r-data" / "diag"
+GAP_SOLO_SEC = 0.0            # 読み取りの間にあける時間 (ひとりのときは詰めて読む)
+GAP_PAD_SEC = 0.01            # ゲームパッドと一緒のとき. 鍵を手放してすぐ取り直さないよう少しあける
+STS_REGS = [(sid, addr) for sid in range(1, 8) for addr in (60, 61, 69, 70)]   # 7 = 爪
+# 1 回の read_servo で読む数の上限. 2.9.6 は返事がないと 1 つにつき 0.1〜0.2 秒待ち, その間 daemon は
+# ゲームパッドの命令 (stop も) を受けない (codex R1 必須 1). 始めの 1 回もこの数ずつに分けて頼む.
+FIRST_CHUNK = 4
+PAD_MAX_READS = 4             # --with-pad のとき 毎回読む数 (角度・電流・状態・爪・番地の合計) の上限
+MARKS = {"1": "static", "2": "close_empty", "3": "grasp", "4": "pull", "5": "release", "6": "push_arm", "0": "other"}
+SUMMARY_EVERY_SEC = 2.0
+
+
+def ensure_ssd_mounted():
+    if not os.path.ismount(SSD_MOUNT):
+        sys.exit("[ABORT] KIOXIA がマウントされていません。記録をローカルには逃がさず、ここで止めます。")
+
+
+# =============================================================================
+# daemon とのやりとり
+# =============================================================================
+
+class SoloClient:
+    """ひとりで daemon につなぐ (ゲームパッドなど, ほかに daemon を使うものを止めてから)."""
+
+    def __init__(self, host=DAEMON_HOST, port=DAEMON_PORT, timeout=RPC_TIMEOUT_SEC):
+        self.sock = socket.create_connection((host, port), timeout=timeout)
+        self.sock.settimeout(timeout)
+        self.buf = b""
+
+    def request(self, obj):
+        self.sock.sendall((json.dumps(obj) + "\n").encode("utf-8"))
+        while b"\n" not in self.buf:
+            d = self.sock.recv(65536)
+            if not d:
+                raise RuntimeError("daemon がつながりを閉じた")
+            self.buf += d
+        line, self.buf = self.buf.split(b"\n", 1)
+        return json.loads(line.decode("utf-8"))
+
+    def close(self):
+        try:
+            self.sock.close()
+        except Exception:
+            pass
+
+
+def read_cmd(currents=True, status=False, gripper=False, regs=(), raw=False):
+    return {"cmd": "read_servo", "angles": True, "currents": bool(currents), "status": bool(status),
+            "gripper": bool(gripper), "regs": [list(r) for r in regs], "raw": bool(raw)}
+
+
+def check_first(resp):
+    """始めの 1 回の返事を確かめる. daemon に read_servo がなければ止める (何も送っていない)."""
+    if not isinstance(resp, dict):
+        sys.exit(f"[ABORT] daemon の返事の形がおかしい: {resp!r}")
+    if not resp.get("ok"):
+        err = str(resp.get("error", ""))
+        if "unknown cmd" in err:
+            sys.exit("[ABORT] daemon に read_servo がありません。先に tools/daemon_add_read_servo.sh を流してください")
+        sys.exit(f"[ABORT] read_servo が ok を返しません: {err}")
+
+
+def describe_first(resp):
+    """始めの 1 回を人が読める形にする (画面と first_read.json の notes)."""
+    lines = []
+    items = resp.get("items", {})
+    for k in ("angles", "currents", "status", "gripper_value"):
+        it = items.get(k)
+        if it is None:
+            continue
+        s = f"{k:14s} {it.get('value')!r}  ({it.get('ms')} ms)"
+        if it.get("error"):
+            s += f"  error={it['error']}"
+        if it.get("raw"):
+            s += f"  raw={it['raw']}"
+        lines.append(s)
+    regs = resp.get("regs") or []
+    by_sid = {}
+    for sid, addr, val, ms in regs:
+        by_sid.setdefault(sid, []).append(f"{addr}:{'返事なし' if val is None else val}")
+    for sid in sorted(by_sid):
+        name = "爪" if sid == 7 else f"j{sid - 1}"
+        lines.append(f"servo {sid} ({name})  " + "  ".join(by_sid[sid]))
+    if regs:
+        lines.append(f"番地 1 つの読み取り: 中央値 {statistics.median(r[3] for r in regs):.1f} ms")
+    lines.append(f"Pi の中の合計 {resp.get('total_ms')} ms")
+    return lines
+
+
+# =============================================================================
+# 読み取りのスレッド
+# =============================================================================
+
+def flat_currents(v):
+    """電流の値を 6 列に. 6 個の数のリストでなければ None (生の形は currents_raw 列に残す)."""
+    if isinstance(v, list) and len(v) == 6 and all(isinstance(x, (int, float)) for x in v):
+        return v
+    return None
+
+
+SAMPLE_COLS = (["i", "t_mac_send", "t_mac_recv", "rtt_ms", "wait_ms", "t_pi", "pi_total_ms",
+                "angles_ms", "currents_ms"]
+               + [f"j{k}" for k in range(6)] + [f"cur{k}" for k in range(6)]
+               + ["currents_raw", "gripper_value", "status", "regs", "error", "mark"])
+
+
+class Prober:
+    def __init__(self, request_fn, out_dir, cmd, gap_sec, out=print, wait_of_last=None):
+        self.request_fn = request_fn
+        self.cmd = cmd
+        self.gap = gap_sec
+        self.out = out
+        self.wait_of_last = wait_of_last        # --with-pad: 直前のやりとりで鍵を待った秒 (同じスレッド)
+        self.stop_flag = threading.Event()
+        self.paused = threading.Event()
+        self.marks = queue.Queue()
+        self.mark_lock = threading.Lock()       # ターミナルとゲームパッドの窓の両方から呼ばれる
+        self.n = 0
+        self.n_err = 0                          # やりとりそのものの失敗 (つながらない・ok=False)
+        self.n_item_err = 0                     # やりとりはできたが, どれかの項目が読めなかった (返事なしなど)
+        self.n_cur_ok = 0
+        self.rtts = []
+        self.pi_totals = []
+        self.item_ms = {"angles": [], "currents": []}
+        self.t_first = self.t_last = None
+        self.f = open(Path(out_dir) / "samples.csv", "x", newline="", encoding="utf-8")
+        self.w = csv.writer(self.f)
+        self.w.writerow(SAMPLE_COLS)
+        self.mf = open(Path(out_dir) / "marks.csv", "x", newline="", encoding="utf-8")
+        self.mw = csv.writer(self.mf)
+        self.mw.writerow(["t_mac", "key", "name", "sample_i"])
+        self.thread = threading.Thread(target=self._loop, name="probe", daemon=True)
+
+    def mark(self, key):
+        name = MARKS.get(key)
+        if name is None:
+            return False
+        t = time.time()
+        with self.mark_lock:
+            if self.mf.closed:
+                return False
+            self.mw.writerow([f"{t:.6f}", key, name, self.n])
+            self.mf.flush()
+        self.marks.put(name)
+        self.out(f"[印] {key} {name}  ({datetime.fromtimestamp(t).strftime('%H:%M:%S.%f')[:-3]})")
+        return True
+
+    def toggle_pause(self):
+        if self.paused.is_set():
+            self.paused.clear()
+            self.out("[再開] 読みます")
+        else:
+            self.paused.set()
+            self.out("[止めた] 読むのを止めています (p で再開)")
+
+    def start(self):
+        self.thread.start()
+
+    def stop(self, join_sec=5.0):
+        self.stop_flag.set()
+        if self.thread.is_alive():
+            self.thread.join(join_sec)
+        with self.mark_lock:
+            for fh in (self.f, self.mf):
+                try:
+                    fh.close()
+                except Exception:
+                    pass
+
+    def _loop(self):
+        last_print = time.time()
+        while not self.stop_flag.is_set():
+            if self.paused.is_set():
+                time.sleep(0.05)
+                continue
+            mark = []
+            while not self.marks.empty():
+                mark.append(self.marks.get_nowait())
+            t0 = time.time()
+            resp, err = None, ""
+            try:
+                resp = self.request_fn(self.cmd)
+            except Exception as e:
+                err = f"{type(e).__name__}: {e}"
+            t1 = time.time()
+            wait = self.wait_of_last() if self.wait_of_last is not None else None
+            self.write_row(t0, t1, wait, resp, err, mark)
+            if t1 - last_print >= SUMMARY_EVERY_SEC:
+                last_print = t1
+                self.out(self.status_line())
+            if self.gap > 0:
+                time.sleep(self.gap)
+
+    def write_row(self, t0, t1, wait, resp, err, mark):
+        items = (resp or {}).get("items", {}) if isinstance(resp, dict) else {}
+        ang = (items.get("angles") or {}).get("value")
+        cur_it = items.get("currents") or {}
+        cur = flat_currents(cur_it.get("value"))
+        if not err and isinstance(resp, dict) and not resp.get("ok"):
+            err = str(resp.get("error"))
+        if not err and not isinstance(resp, dict):
+            err = f"bad response: {resp!r}"[:200]
+        transport_err = bool(err)
+        errs = [f"{k}:{v['error']}" for k, v in items.items() if isinstance(v, dict) and v.get("error")]
+        regs_none = sum(1 for r in ((resp or {}).get("regs") or []) if isinstance(resp, dict) and r[2] is None)
+        if regs_none:
+            errs.append(f"regs:{regs_none} no reply")
+        if errs:
+            err = (err + " " if err else "") + " ".join(errs)
+        rtt = (t1 - t0) * 1000.0
+        row = [self.n, f"{t0:.6f}", f"{t1:.6f}", f"{rtt:.2f}", "" if wait is None else f"{wait * 1000.0:.2f}",
+               f"{resp['t']:.6f}" if isinstance(resp, dict) and isinstance(resp.get("t"), (int, float)) else "",
+               (resp or {}).get("total_ms", "") if isinstance(resp, dict) else "",
+               (items.get("angles") or {}).get("ms", ""), cur_it.get("ms", "")]
+        row += ([f"{float(v):.2f}" for v in ang] if isinstance(ang, list) and len(ang) == 6 else [""] * 6)
+        row += (list(cur) if cur is not None else [""] * 6)
+        row += ["" if cur is not None or "value" not in cur_it else json.dumps(cur_it.get("value")),
+                json.dumps((items.get("gripper_value") or {}).get("value")) if "gripper_value" in items else "",
+                json.dumps((items.get("status") or {}).get("value")) if "status" in items else "",
+                json.dumps((resp or {}).get("regs")) if isinstance(resp, dict) and resp.get("regs") else "",
+                err, "+".join(mark)]
+        self.w.writerow(row)
+        if self.n % 20 == 0:
+            self.f.flush()
+        self.n += 1
+        self.t_first = self.t_first or t0
+        self.t_last = t1
+        if transport_err:
+            self.n_err += 1
+        else:
+            if errs:
+                self.n_item_err += 1
+            self.rtts.append(rtt)
+            if isinstance(resp.get("total_ms"), (int, float)):
+                self.pi_totals.append(resp["total_ms"])
+            for k in self.item_ms:
+                ms = (items.get(k) or {}).get("ms")
+                if isinstance(ms, (int, float)):
+                    self.item_ms[k].append(ms)
+        if cur is not None:
+            self.n_cur_ok += 1
+
+    def hz(self):
+        if self.n < 2 or self.t_last is None:
+            return 0.0
+        return (self.n - 1) / max(self.t_last - self.t_first, 1e-9)
+
+    def status_line(self):
+        med = statistics.median(self.rtts) if self.rtts else float("nan")
+        return (f"[読み] {self.n} 回  {self.hz():.1f} Hz  往復の中央値 {med:.1f} ms  "
+                f"電流が 6 個の数で読めた {self.n_cur_ok} 回  項目が読めなかった {self.n_item_err} 回  "
+                f"やりとりの失敗 {self.n_err} 回")
+
+    def summary(self):
+        def stats(xs):
+            if not xs:
+                return None
+            s = sorted(xs)
+            return {"n": len(s), "median": round(statistics.median(s), 2), "p95": round(s[int(0.95 * (len(s) - 1))], 2),
+                    "max": round(s[-1], 2), "min": round(s[0], 2)}
+        return {"n": self.n, "n_error": self.n_err, "n_item_error": self.n_item_err, "n_currents_6": self.n_cur_ok, "hz": round(self.hz(), 2),
+                "rtt_ms": stats(self.rtts), "pi_total_ms": stats(self.pi_totals),
+                "item_ms": {k: stats(v) for k, v in self.item_ms.items()}}
+
+
+# =============================================================================
+# ターミナルの 1 文字
+# =============================================================================
+
+class TermKeys:
+    """ターミナルを 1 文字ずつ読む形にする (Ctrl+C は効くまま). 端末でなければ何もしない."""
+
+    def __init__(self):
+        self.ok = sys.stdin.isatty()
+        self.old = None
+
+    def __enter__(self):
+        if self.ok:
+            import termios
+            import tty
+            self.old = termios.tcgetattr(sys.stdin)
+            tty.setcbreak(sys.stdin.fileno())
+        return self
+
+    def __exit__(self, *a):
+        if self.old is not None:
+            import termios
+            termios.tcsetattr(sys.stdin, termios.TCSADRAIN, self.old)
+
+    def get(self, timeout):
+        if not self.ok:
+            time.sleep(timeout)
+            return None
+        r, _, _ = select.select([sys.stdin], [], [], timeout)
+        return sys.stdin.read(1) if r else None
+
+
+def key_thread(keys, prober, quit_ev, allow_quit):
+    while not quit_ev.is_set():
+        c = keys.get(0.1)
+        if c is None:
+            continue
+        c = c.lower()
+        if c == "q" and allow_quit:
+            quit_ev.set()
+        elif c == "p":
+            prober.toggle_pause()
+        else:
+            prober.mark(c)
+
+
+# =============================================================================
+# main
+# =============================================================================
+
+def parse_regs(s):
+    if s in (None, "", "none"):
+        return []
+    if s == "sts":
+        return list(STS_REGS)
+    out = []
+    for part in s.split(","):
+        sid, addr = part.split(":")
+        out.append((int(sid), int(addr)))
+    return out
+
+
+def make_out_dir(mode):
+    ensure_ssd_mounted()
+    now = datetime.now()
+    d = OUT_ROOT / f"servo_current_{now.strftime('%Y%m%d')}" / f"{now.strftime('%H%M%S')}_{mode}"
+    if d.exists():
+        sys.exit(f"[ABORT] 同じ名前のフォルダがあります: {d}")
+    d.mkdir(parents=True)
+    shutil.copy2(__file__, d / Path(__file__).name)
+    return d
+
+
+def write_json(path, obj):
+    tmp = Path(str(path) + ".tmp")
+    tmp.write_text(json.dumps(obj, ensure_ascii=False, indent=1), encoding="utf-8")
+    os.replace(tmp, path)
+
+
+def n_reads(cmd):
+    return sum(1 for k in ("angles", "currents", "status", "gripper") if cmd.get(k)) + len(cmd.get("regs") or [])
+
+
+def first_read(request_fn, out_dir):
+    """読めるものを全部 1 回ずつ. 1 回の頼みは FIRST_CHUNK 個までに分ける.
+    返事がないときの目安は 1 回 0.4〜0.8 秒 (シリアルの待ちだけの見込み. 上限の保証ではない).
+    --with-pad のときは, ゲームパッドの窓を開く前 (操作を受け付ける前) に呼ぶ (codex R2 必須)."""
+    cmds = [read_cmd(currents=True, status=True, gripper=True, regs=(), raw=True)]
+    for i in range(0, len(STS_REGS), FIRST_CHUNK):
+        c = read_cmd(currents=False, regs=STS_REGS[i:i + FIRST_CHUNK], raw=True)
+        c["angles"] = False
+        cmds.append(c)
+    t0 = time.time()
+    resps = []
+    for c in cmds:
+        r = request_fn(c)
+        check_first(r)
+        resps.append(r)
+    t1 = time.time()
+    resp = {"ok": True, "t": resps[0].get("t"), "items": resps[0].get("items", {}),
+            "total_ms": round(sum(r.get("total_ms") or 0 for r in resps), 2),
+            "regs": [x for r in resps for x in (r.get("regs") or [])],
+            "regs_detail": [x for r in resps for x in (r.get("regs_detail") or [])]}
+    notes = describe_first(resp)
+    print("---- 始めの 1 回 (読めるものを全部)")
+    for ln in notes:
+        print("  " + ln)
+    print(f"  往復 {1000 * (t1 - t0):.1f} ms")
+    write_json(out_dir / "first_read.json", {"cmds": cmds, "resps": resps, "rtt_ms": round(1000 * (t1 - t0), 2),
+                                             "notes": notes})
+    return resp
+
+
+def run_solo(args, out_dir, cmd, meta):
+    try:
+        cli = SoloClient()
+    except OSError as e:
+        sys.exit(f"[ABORT] daemon につながりません ({e}). daemon が動いているか, ゲームパッドなどほかに "
+                 "daemon を使うものが動いていないか確かめてください")
+    try:
+        first_read(cli.request, out_dir)
+        prober = Prober(cli.request, out_dir, cmd, args.gap if args.gap is not None else GAP_SOLO_SEC)
+        quit_ev = threading.Event()
+        print("読み始めます。印: 1 静止 2 空で閉じた 3 つかんだ 4 引っ張った 5 離した 6 押した 0 そのほか / p 止める・再開 / q 終わる")
+        prober.start()
+        end = None
+        with TermKeys() as keys:
+            kt = threading.Thread(target=key_thread, args=(keys, prober, quit_ev, True), daemon=True)
+            kt.start()
+            t_end = time.time() + args.seconds if args.seconds else None
+            try:
+                while not quit_ev.is_set():
+                    if t_end is not None and time.time() >= t_end:
+                        end = "seconds"
+                        break
+                    if not prober.thread.is_alive():
+                        end = "probe_died"
+                        break
+                    time.sleep(0.05)
+                end = end or "q"
+            except KeyboardInterrupt:
+                end = "ctrl_c"
+            quit_ev.set()
+            prober.stop()
+            kt.join(1.0)
+        meta["end_reason"] = end
+        return prober, None
+    finally:
+        cli.close()
+
+
+def run_with_pad(args, out_dir, cmd, meta):
+    """gamepad_teleop をこのプロセスで動かす. 鍵をかけてから gamepad_teleop の送り口を読む
+    (gamepad_teleop は読んだときの _daemon_request を持つので, 先に鍵の版にしておく)."""
+    sys.path.insert(0, str(Path(__file__).resolve().parent))
+    import gamepad_teleop as gt
+    import run_policy_client as rpcmod            # gamepad_teleop が pi05 をパスに入れる. pymycobot のスタブも入る
+    import policy_recorder as pr
+    import visual_servo_pick as vsp
+    pr.install_daemon_lock()
+    locked = vsp._daemon_request
+    tl = threading.local()
+
+    def on_rpc(cmd_obj, resp, t0, t1, err):
+        tl.last = (t0, t1)
+
+    pr.add_rpc_observer(on_rpc)
+    pad_f = open(out_dir / "pad_rpc.csv", "x", newline="", encoding="utf-8")
+    pad_w = csv.writer(pad_f)
+    pad_w.writerow(["t_call", "cmd", "joint_id", "wait_ms", "rpc_ms", "probe_paused", "error"])
+    pad_lock = threading.Lock()
+    pad_waits = {"reading": [], "paused": []}
+    prober_box = []
+
+    def timed(*a, **k):
+        tl.last = None
+        t_call = time.time()
+        err = ""
+        try:
+            return locked(*a, **k)
+        except BaseException as e:
+            err = f"{type(e).__name__}: {e}"
+            raise
+        finally:
+            last = getattr(tl, "last", None)
+            tl.wait = None if last is None else last[0] - t_call
+            if threading.current_thread().name != "probe" and not getattr(tl, "probe_own", False):
+                c = a[0] if a else k.get("cmd_obj", {})
+                c = c if isinstance(c, dict) else {}
+                paused = bool(prober_box and prober_box[0].paused.is_set())
+                wait_ms = None if last is None else (last[0] - t_call) * 1000.0
+                rpc_ms = None if last is None else (last[1] - last[0]) * 1000.0
+                with pad_lock:
+                    pad_w.writerow([f"{t_call:.6f}", c.get("cmd"), c.get("joint_id", ""),
+                                    "" if wait_ms is None else f"{wait_ms:.2f}",
+                                    "" if rpc_ms is None else f"{rpc_ms:.2f}", int(paused), err])
+                    pad_f.flush()
+                    if wait_ms is not None and c.get("cmd") != "ping":
+                        pad_waits["paused" if paused else "reading"].append(wait_ms)
+    timed._policy_locked = True                     # gamepad_teleop.main の install_daemon_lock で包み直させない
+    timed._orig = locked
+    vsp._daemon_request = timed
+    gt.load_daemon_modules()
+    if gt.daemon_request is not timed:
+        sys.exit("[ABORT] gamepad_teleop の送り口が鍵の版になっていません (先に読まれていた). 何も送らずに終わります")
+    assert rpcmod is gt.rpc
+
+    state = {"started": False, "teleop_log": None}
+    quit_ev = threading.Event()
+    keys = TermKeys()
+
+    def on_frame(frame, tp):
+        if not state["started"]:
+            state["started"] = True
+            state["teleop_log"] = str(getattr(tp.log, "path", "")) or None
+            p = Prober(lambda c: vsp._daemon_request(c, timeout_override=RPC_TIMEOUT_SEC), out_dir, cmd,
+                       args.gap if args.gap is not None else GAP_PAD_SEC, wait_of_last=lambda: getattr(tl, "wait", None))
+            prober_box.append(p)
+            p.start()
+            threading.Thread(target=key_thread, args=(keys, p, quit_ev, False), daemon=True).start()
+            print("読み始めます。印はターミナルかゲームパッドの窓で: 1 静止 2 空で閉じた 3 つかんだ 4 引っ張った "
+                  "5 離した 6 押した 0 そのほか / p 止める・再開 / 終わるときはウィンドウを閉じる")
+        for ev in gt.pygame.event.get(gt.pygame.KEYDOWN):   # 窓が手前のときの印 (gamepad_teleop はキーを使わない)
+            ch = (ev.unicode or "").lower()
+            if ch == "p":
+                prober_box[0].toggle_pause()
+            elif ch:
+                prober_box[0].mark(ch)
+
+    # 始めの 1 回は, ゲームパッドの窓を開く前 (操作を受け付ける前) に読む. on_frame の中で読むと, 読み終わるまで
+    # gamepad_teleop が L2+R2 の停止もボタンも見ない (codex R2 必須). つながりは gamepad_teleop.main がそのまま使う.
+    try:
+        tl.probe_own = True                             # この道具の読み取り (パッドの命令に数えない)
+        try:
+            try:
+                vsp.get_daemon_socket()
+            except Exception as e:
+                sys.exit(f"[ABORT] daemon につながりません ({type(e).__name__}: {e}). 何も送らずに終わります")
+            first_read(lambda c: vsp._daemon_request(c, timeout_override=RPC_TIMEOUT_SEC), out_dir)
+        finally:
+            tl.probe_own = False
+        with keys:
+            gt.main(["--execute"], on_frame=on_frame)
+    finally:
+        # どこで終わっても 読み取り・観察・CSV を片付ける (codex R3 推奨)
+        quit_ev.set()
+        if prober_box:
+            prober_box[0].stop()
+        pr.remove_rpc_observer(on_rpc)
+        with pad_lock:
+            pad_f.close()
+        if not state["started"]:
+            # gamepad_teleop の窓まで行かなかった (始めの 1 回の失敗・記録フォルダが作れないなど):
+            # つながりはこの道具が閉じる. 窓まで行ったあとは gamepad_teleop.main が閉じる
+            # (送っている途中の仕事が戻らないときは, わざと閉じない. それを変えない)
+            vsp.close_daemon_socket()
+    meta["teleop_log"] = state["teleop_log"]
+
+    def stats(xs):
+        if not xs:
+            return None
+        s = sorted(xs)
+        return {"n": len(s), "median": round(statistics.median(s), 2), "p95": round(s[int(0.95 * (len(s) - 1))], 2),
+                "max": round(s[-1], 2)}
+    meta["pad_wait_ms"] = {k: stats(v) for k, v in pad_waits.items()}
+    return (prober_box[0] if prober_box else None), meta["pad_wait_ms"]
+
+
+def main(argv=None):
+    ap = argparse.ArgumentParser(description="関節と爪の電流を読むだけ (アームには送らない)")
+    ap.add_argument("--with-pad", action="store_true", help="gamepad_teleop をこのプロセスで動かす (ゲームパッドで動かす)")
+    ap.add_argument("--seconds", type=float, default=None, help="この秒数で終わる (ひとりのときだけ)")
+    ap.add_argument("--regs", default="none", help="毎回読むサーボの番地: none (既定) / sts (1〜7 の 60,61,69,70) / 'id:addr,...'")
+    ap.add_argument("--status", action="store_true", help="毎回 get_servo_status も読む")
+    ap.add_argument("--gripper", action="store_true", help="毎回 get_gripper_value も読む")
+    ap.add_argument("--no-currents", action="store_true", help="get_servo_currents を読まない (番地だけで測るとき)")
+    ap.add_argument("--gap", type=float, default=None, help=f"読み取りの間の秒 (既定 ひとり {GAP_SOLO_SEC} / パッド {GAP_PAD_SEC})")
+    args = ap.parse_args(argv)
+    if args.with_pad and args.seconds:
+        ap.error("--seconds は --with-pad と一緒に使えません (ウィンドウを閉じて終わる)")
+    regs = parse_regs(args.regs)
+    cmd = read_cmd(currents=not args.no_currents, status=args.status, gripper=args.gripper, regs=regs)
+    if args.with_pad and n_reads(cmd) > PAD_MAX_READS:
+        ap.error(f"--with-pad のときは 1 回に読む数 (角度・電流・状態・爪・番地の合計) を {PAD_MAX_READS} までにしてください "
+                 f"(今 {n_reads(cmd)}). daemon が読んでいる間はゲームパッドの命令 (stop も) が待たされます")
+    mode = "pad" if args.with_pad else "solo"
+    out_dir = make_out_dir(mode)
+    print(f"[記録] {out_dir}")
+    meta = {"started_at": datetime.now().isoformat(timespec="seconds"), "argv": sys.argv, "mode": mode,
+            "cmd": cmd, "gap_sec": args.gap, "daemon": f"{DAEMON_HOST}:{DAEMON_PORT}", "sends_to_arm": "none (read only)"
+            + ("; gamepad_teleop sends as usual" if args.with_pad else "")}
+    prober = None
+    try:
+        if args.with_pad:
+            prober, _ = run_with_pad(args, out_dir, cmd, meta)
+        else:
+            prober, _ = run_solo(args, out_dir, cmd, meta)
+    finally:
+        meta["ended_at"] = datetime.now().isoformat(timespec="seconds")
+        if prober is not None:
+            meta["probe"] = prober.summary()
+            print(prober.status_line())
+        if meta.get("pad_wait_ms"):
+            print(f"[パッド] 鍵の待ち (ms): 読んでいる間 {meta['pad_wait_ms'].get('reading')} / "
+                  f"止めている間 {meta['pad_wait_ms'].get('paused')}")
+        write_json(out_dir / "summary.json", meta)
+        print(f"[終了] アームには何も送っていません (ゲームパッドで送った分を除く)。記録: {out_dir}")
+    return 0
+
+
+if __name__ == "__main__":
+    sys.exit(main())
diff --git a/tools/test_daemon_read_servo.py b/tools/test_daemon_read_servo.py
new file mode 100644
index 0000000..d4cc5d2
--- /dev/null
+++ b/tools/test_daemon_read_servo.py
@@ -0,0 +1,211 @@
+#!/usr/bin/env python3
+"""test_daemon_read_servo.py — tools/pi/mecharm_daemon.py の read_servo (依頼 109) を偽物の MyCobot で確かめる
+
+  python3 tools/test_daemon_read_servo.py
+
+確かめること: 読むだけ (送る関数を 1 つも呼ばない)・頼んだ分だけ読む・読めないときは None と error で ok=True・
+頼み方がおかしいときは何も読まずに ok=False・raw でバイト列が返り _read が元に戻る・今までの命令の返事が同じ.
+"""
+import importlib.util
+import json
+import sys
+import types
+import unittest
+from pathlib import Path
+
+DAEMON = Path(__file__).resolve().parent / "pi" / "mecharm_daemon.py"
+
+
+def load_daemon():
+    pkg = types.ModuleType("pymycobot")
+    mod = types.ModuleType("pymycobot.mycobot")
+    mod.MyCobot = object
+    saved = {k: sys.modules.get(k) for k in ("pymycobot", "pymycobot.mycobot")}
+    sys.modules["pymycobot"] = pkg
+    sys.modules["pymycobot.mycobot"] = mod
+    try:
+        spec = importlib.util.spec_from_file_location("mecharm_daemon_under_test", DAEMON)
+        m = importlib.util.module_from_spec(spec)
+        spec.loader.exec_module(m)
+    finally:
+        for k, v in saved.items():
+            if v is None:
+                sys.modules.pop(k, None)
+            else:
+                sys.modules[k] = v
+    return m
+
+
+def class_read(self, genre):
+    return bytes([0xFE, 0xFE, 0x02, genre, 0xFA])
+
+
+class FakeMC:
+    """読む関数だけを持つ. 送る関数が呼ばれたら記録する."""
+    _read = class_read          # 2.9.6 の MyCobot と同じくクラスに置く
+
+    def __init__(self):
+        self.calls = []
+        self.sent = []
+        self.currents = [10, 20, 30, 40, 50, 60]
+
+    def _rec(self, name, *a):
+        self.calls.append((name,) + a)
+
+    def get_angles(self):
+        self._rec("get_angles")
+        self._read(0x20)
+        return [1.0, 2.0, 3.0, 4.0, 5.0, -30.0]
+
+    def get_servo_currents(self):
+        self._rec("get_servo_currents")
+        self._read(0xE2)
+        if isinstance(self.currents, Exception):
+            raise self.currents
+        return self.currents
+
+    def get_servo_status(self):
+        self._rec("get_servo_status")
+        return [0, 0, 0]
+
+    gripper = 42
+
+    def get_gripper_value(self):
+        self._rec("get_gripper_value")
+        return self.gripper
+
+    def get_servo_data(self, sid, addr):
+        self._rec("get_servo_data", sid, addr)
+        self._read(0x53)
+        return -1 if addr == 99 else sid * 100 + addr
+
+    def __getattr__(self, name):          # 送る関数 (send_angles など) は呼ばれたら記録して何もしない
+        if name.startswith(("send_", "set_", "release", "focus", "jog", "stop", "power", "pause", "resume")):
+            def f(*a, **k):
+                self.sent.append(name)
+                return 1
+            return f
+        raise AttributeError(name)
+
+
+class ReadServoTest(unittest.TestCase):
+    def setUp(self):
+        self.d = load_daemon()
+        self.mc = FakeMC()
+        self.d._mc = self.mc
+
+    def cmd(self, **kw):
+        obj = {"cmd": "read_servo"}
+        obj.update(kw)
+        r = self.d.handle_command(obj)
+        json.dumps(r)                       # JSON で返せること
+        return r
+
+    def test_default_reads_angles_and_currents_only(self):
+        r = self.cmd()
+        self.assertTrue(r["ok"])
+        self.assertEqual(sorted(r["items"]), ["angles", "currents"])
+        self.assertEqual(r["items"]["currents"]["value"], [10, 20, 30, 40, 50, 60])
+        self.assertEqual(r["items"]["angles"]["value"][5], -30.0)
+        self.assertEqual([c[0] for c in self.mc.calls], ["get_angles", "get_servo_currents"])
+        self.assertEqual(r["regs"], [])
+        self.assertNotIn("raw", r["items"]["angles"])
+        self.assertEqual(self.mc.sent, [])
+
+    def test_all_items_and_regs_in_order(self):
+        r = self.cmd(status=True, gripper=True, regs=[[7, 69], [1, 60]])
+        self.assertEqual([c[0] for c in self.mc.calls],
+                         ["get_angles", "get_servo_currents", "get_servo_status", "get_gripper_value",
+                          "get_servo_data", "get_servo_data"])
+        self.assertEqual([r_[:3] for r_ in r["regs"]], [[7, 69, 769], [1, 60, 160]])
+        self.assertEqual(r["items"]["gripper_value"]["value"], 42)
+        self.assertEqual(self.mc.sent, [])
+
+    def test_no_angles_no_currents(self):
+        r = self.cmd(angles=False, currents=False, regs=[[2, 69]])
+        self.assertEqual(r["items"], {})
+        self.assertEqual(self.mc.calls, [("get_servo_data", 2, 69)])
+
+    def test_read_error_is_none_with_ok_true(self):
+        self.mc.currents = ValueError("unpack")
+        r = self.cmd()
+        self.assertTrue(r["ok"])
+        self.assertIsNone(r["items"]["currents"]["value"])
+        self.assertIn("ValueError", r["items"]["currents"]["error"])
+        self.assertEqual(r["items"]["angles"]["value"][0], 1.0)     # ほかの項目は読める
+
+    def test_missing_function(self):
+        del FakeMC.get_servo_currents
+        try:
+            r = self.cmd()
+        finally:
+            FakeMC.get_servo_currents = ReadServoTest._orig_cur
+        self.assertTrue(r["ok"])
+        self.assertIsNone(r["items"]["currents"]["value"])
+        self.assertIn("not available", r["items"]["currents"]["error"])
+
+    def test_no_reply_values_become_none(self):
+        # 2.9.6 は返事がないと [] / -1 を返す. 読めなかったとして扱う (codex R1 必須 2)
+        self.mc.currents = []
+        self.mc.gripper = -1
+        r = self.cmd(gripper=True, regs=[[7, 99], [7, 69]])
+        self.assertTrue(r["ok"])
+        cur = r["items"]["currents"]
+        self.assertEqual((cur["value"], cur["error"], cur["returned"]), (None, "no reply", []))
+        g = r["items"]["gripper_value"]
+        self.assertEqual((g["value"], g["error"], g["returned"]), (None, "no reply", -1))
+        self.assertEqual([x[:3] for x in r["regs"]], [[7, 99, None], [7, 69, 769]])
+        self.assertEqual(r["regs_detail"][0]["error"], "no reply")
+        self.mc.currents = None
+        self.assertEqual(self.cmd()["items"]["currents"]["error"], "no reply")
+
+    def test_missing_get_servo_data_reports_error(self):
+        orig = FakeMC.get_servo_data
+        del FakeMC.get_servo_data
+        try:
+            r = self.cmd(regs=[[1, 60]])
+        finally:
+            FakeMC.get_servo_data = orig
+        self.assertEqual(r["regs"][0][:3], [1, 60, None])
+        self.assertIn("not available", r["regs_detail"][0]["error"])
+
+    def test_unjsonable_values(self):
+        self.mc.currents = object()
+        v = self.cmd()["items"]["currents"]["value"]
+        self.assertIsInstance(v, str)
+
+    def test_raw_capture_and_restore(self):
+        r = self.cmd(raw=True, regs=[[7, 69]])
+        self.assertEqual(r["items"]["currents"]["raw"], ["fefe02e2fa"])
+        self.assertEqual(r["regs_detail"][0]["raw"], ["fefe0253fa"])
+        self.assertNotIn("_read", self.mc.__dict__)                 # 包みが外れている
+        self.assertIs(type(self.mc)._read, class_read)
+
+    def test_raw_restored_after_exception(self):
+        self.mc.currents = RuntimeError("x")
+        self.cmd(raw=True)
+        self.assertNotIn("_read", self.mc.__dict__)
+
+    def test_bad_regs_read_nothing(self):
+        for regs in ([[8, 69]], [[0, 1]], [[1, 101]], [[1]], [[1, 2, 3]], "1:69", [1], [["a", 2]],
+                     [[1, 60]] * 33, [[1.9, 60.9]], [[True, 60]], [["1", "60"]], [{"0": 1, "1": 2}], {}, "", 0, False):
+            self.mc.calls.clear()
+            r = self.cmd(regs=regs)
+            self.assertFalse(r["ok"], regs)
+            self.assertEqual(self.mc.calls, [], regs)
+        self.assertEqual(self.mc.sent, [])
+
+    def test_existing_commands_unchanged(self):
+        self.assertEqual(self.d.handle_command({"cmd": "ping"}),
+                         {"ok": True, "daemon": "mecharm_daemon", "fresh_mode": 0})
+        self.assertEqual(self.d.handle_command({"cmd": "get_angles_once"}),
+                         {"ok": True, "angles": [1.0, 2.0, 3.0, 4.0, 5.0, -30.0]})
+        self.assertEqual(self.d.handle_command({"cmd": "get_gripper_value"}), {"ok": True, "value": 42})
+        self.assertEqual(self.d.handle_command({"cmd": "nope"}), {"ok": False, "error": "unknown cmd: nope"})
+
+
+ReadServoTest._orig_cur = FakeMC.get_servo_currents
+
+
+if __name__ == "__main__":
+    unittest.main()
diff --git a/tools/test_servo_current_probe.py b/tools/test_servo_current_probe.py
new file mode 100644
index 0000000..3b02335
--- /dev/null
+++ b/tools/test_servo_current_probe.py
@@ -0,0 +1,308 @@
+#!/usr/bin/env python3
+"""test_servo_current_probe.py — tools/servo_current_probe.py の読む側 (Prober など) を偽物の daemon で確かめる
+
+  python3 tools/test_servo_current_probe.py
+
+アームにも daemon にもつながない. 一時フォルダに書く.
+"""
+import csv
+import json
+import socket
+import sys
+import tempfile
+import threading
+import time
+import unittest
+from pathlib import Path
+
+sys.path.insert(0, str(Path(__file__).resolve().parent))
+import servo_current_probe as scp  # noqa: E402
+
+
+def fake_resp(cur=(1, 2, 3, 4, 5, 6), ok=True, err_item=None):
+    items = {"angles": {"value": [0.0, 1.0, 2.0, 3.0, 4.0, -30.0], "ms": 3.0},
+             "currents": {"value": list(cur) if isinstance(cur, tuple) else cur, "ms": 4.0}}
+    if err_item:
+        items["currents"]["error"] = err_item
+    return {"ok": ok, "t": time.time(), "total_ms": 7.5, "items": items, "regs": []}
+
+
+class ProberTest(unittest.TestCase):
+    def setUp(self):
+        self.tmp = tempfile.TemporaryDirectory()
+        self.dir = Path(self.tmp.name)
+
+    def tearDown(self):
+        self.tmp.cleanup()
+
+    def rows(self, name="samples.csv"):
+        with open(self.dir / name, newline="", encoding="utf-8") as f:
+            return list(csv.DictReader(f))
+
+    def test_rows_and_marks(self):
+        seq = [fake_resp(), fake_resp(cur=[]), fake_resp(cur=None, err_item="ValueError: x")]
+        sent = []
+
+        def req(c):
+            sent.append(c)
+            if len(sent) > 3:
+                raise OSError("down")
+            return seq[len(sent) - 1]
+        p = scp.Prober(req, self.dir, scp.read_cmd(), 0.0, out=lambda *a: None)
+        p.mark("3")
+        self.assertFalse(p.mark("x"))
+        for _ in range(4):
+            resp, err = None, ""
+            try:
+                resp = req(p.cmd)
+            except Exception as e:
+                err = f"{type(e).__name__}: {e}"
+            marks = []
+            while not p.marks.empty():
+                marks.append(p.marks.get_nowait())
+            p.write_row(time.time(), time.time() + 0.01, None, resp, err, marks)
+        p.stop()
+        r = self.rows()
+        self.assertEqual(len(r), 4)
+        self.assertEqual([r[0][f"cur{k}"] for k in range(6)], ["1", "2", "3", "4", "5", "6"])
+        self.assertEqual(r[0]["mark"], "grasp")
+        self.assertEqual(r[0]["j5"], "-30.00")
+        self.assertEqual(r[1]["currents_raw"], "[]")
+        self.assertEqual(r[1]["cur0"], "")
+        self.assertIn("currents:ValueError", r[2]["error"])
+        self.assertIn("OSError", r[3]["error"])
+        self.assertEqual(p.n_cur_ok, 1)
+        self.assertEqual(p.n_err, 1)            # やりとりの失敗は OSError の 1 回だけ
+        self.assertEqual(p.n_item_err, 1)       # 項目が読めなかった (ValueError) 1 回
+        m = self.rows("marks.csv")
+        self.assertEqual([(x["key"], x["name"]) for x in m], [("3", "grasp")])
+        s = p.summary()
+        self.assertEqual(s["n"], 4)
+        self.assertEqual(s["rtt_ms"]["n"], 3)
+        json.dumps(s)
+        self.assertTrue(all(c["cmd"] == "read_servo" for c in sent))
+
+    def test_thread_runs_pauses_and_stops(self):
+        n = [0]
+
+        def req(c):
+            n[0] += 1
+            time.sleep(0.002)
+            return fake_resp()
+        p = scp.Prober(req, self.dir, scp.read_cmd(), 0.0, out=lambda *a: None)
+        p.start()
+        time.sleep(0.15)
+        p.toggle_pause()
+        time.sleep(0.1)                 # 止める前に始めた 1 回が終わるのを待つ
+        k = n[0]
+        time.sleep(0.15)
+        self.assertEqual(n[0], k)       # 止めている間は読まない
+        p.toggle_pause()
+        time.sleep(0.1)
+        p.stop()
+        self.assertGreater(n[0], k)
+        self.assertFalse(p.thread.is_alive())
+        self.assertGreater(p.hz(), 10)
+        self.assertFalse(p.mark("1"))   # 閉じたあとの印は書かない
+
+
+class HelpersTest(unittest.TestCase):
+    def test_parse_regs(self):
+        self.assertEqual(scp.parse_regs("none"), [])
+        self.assertEqual(len(scp.parse_regs("sts")), 28)
+        self.assertIn((7, 69), scp.parse_regs("sts"))
+        self.assertEqual(scp.parse_regs("7:69,7:70"), [(7, 69), (7, 70)])
+
+    def test_read_cmd_is_read_only(self):
+        c = scp.read_cmd(status=True, gripper=True, regs=[(7, 69)], raw=True)
+        self.assertEqual(c["cmd"], "read_servo")
+        self.assertEqual(c["regs"], [[7, 69]])
+        self.assertLessEqual(len(scp.STS_REGS), 32)       # daemon の上限 SERVO_READ_MAX_REGS
+
+    def test_check_first(self):
+        with self.assertRaises(SystemExit) as cm:
+            scp.check_first({"ok": False, "error": "unknown cmd: read_servo"})
+        self.assertIn("daemon_add_read_servo.sh", str(cm.exception))
+        with self.assertRaises(SystemExit):
+            scp.check_first(None)
+        scp.check_first(fake_resp())
+
+    def test_first_read_is_chunked(self):
+        sent = []
+
+        def req(c):
+            sent.append(c)
+            return fake_resp()
+        with tempfile.TemporaryDirectory() as d:
+            scp.first_read(req, Path(d))
+            j = json.loads((Path(d) / "first_read.json").read_text(encoding="utf-8"))
+        self.assertTrue(all(scp.n_reads(c) <= scp.FIRST_CHUNK for c in sent))
+        self.assertEqual(sorted(tuple(r) for c in sent for r in c["regs"]), sorted(scp.STS_REGS))
+        self.assertTrue(sent[0]["currents"] and sent[0]["status"] and sent[0]["gripper"])
+        self.assertEqual(len(j["resps"]), len(sent))
+
+    def test_pad_read_cap(self):
+        with self.assertRaises(SystemExit):
+            scp.main(["--with-pad", "--regs", "sts"])
+        with self.assertRaises(SystemExit):
+            scp.main(["--with-pad", "--status", "--gripper", "--regs", "7:69"])
+
+    def test_describe_first(self):
+        r = fake_resp()
+        r["regs"] = [[7, 69, 12, 3.0], [1, 60, 5, 2.0]]
+        lines = scp.describe_first(r)
+        self.assertTrue(any("currents" in ln for ln in lines))
+        self.assertTrue(any(ln.startswith("servo 7 (爪)") for ln in lines))
+
+
+class SoloClientTest(unittest.TestCase):
+    def test_line_protocol(self):
+        srv = socket.socket()
+        srv.bind(("127.0.0.1", 0))
+        srv.listen(1)
+        port = srv.getsockname()[1]
+        got = []
+
+        def serve():
+            c, _ = srv.accept()
+            buf = b""
+            while len(got) < 2:
+                buf += c.recv(4096)
+                while b"\n" in buf:
+                    line, buf = buf.split(b"\n", 1)
+                    got.append(json.loads(line))
+                    resp = json.dumps({"ok": True, "n": len(got)}) + "\n"
+                    c.sendall(resp[:5].encode())        # 返事を 2 回に分けて送る
+                    time.sleep(0.01)
+                    c.sendall(resp[5:].encode())
+            c.close()
+        t = threading.Thread(target=serve, daemon=True)
+        t.start()
+        cli = scp.SoloClient("127.0.0.1", port, timeout=2)
+        self.assertEqual(cli.request({"cmd": "a"})["n"], 1)
+        self.assertEqual(cli.request({"cmd": "b"})["n"], 2)
+        cli.close()
+        srv.close()
+        self.assertEqual([g["cmd"] for g in got], ["a", "b"])
+
+
+class WithPadWiringTest(unittest.TestCase):
+    """--with-pad のつなぎ: gamepad_teleop の送り口が鍵の版になり, パッドの命令が pad_rpc.csv に残る.
+    gamepad_teleop.main は偽物に差し替える (pygame の窓も daemon も使わない)."""
+
+    def test_wiring(self):
+        try:
+            import gamepad_teleop as gt
+            import run_policy_client  # noqa: F401
+            import visual_servo_pick as vsp
+        except Exception as e:      # pygame・cv2 がない環境
+            self.skipTest(f"import できない: {e}")
+        if gt.rpc is not None or getattr(vsp._daemon_request, "_policy_locked", False):
+            self.skipTest("ほかのテストが先に読み込んだ")
+        calls = []
+        lock_holder = []
+
+        def base(cmd_obj, timeout_override=None, _deadline_at=None):
+            calls.append((threading.current_thread().name, cmd_obj["cmd"]))
+            lock_holder.append(threading.current_thread().name)
+            time.sleep(0.003)
+            if cmd_obj["cmd"] == "read_servo":
+                return fake_resp()
+            return {"ok": True}
+        orig_req, orig_main, orig_get = vsp._daemon_request, gt.main, gt.pygame.event.get
+        orig_sock = vsp.get_daemon_socket
+        vsp.get_daemon_socket = lambda *a, **k: None
+        vsp._daemon_request = base
+        tmp = tempfile.TemporaryDirectory()
+        out_dir = Path(tmp.name)
+
+        class FakeLog:
+            path = Path("/tmp/teleop_fake/log.jsonl")
+
+        class FakeTp:
+            log = FakeLog()
+
+        def fake_main(argv, on_frame=None):
+            self.assertEqual(argv, ["--execute"])
+            # 始めの 1 回 (読めるものを全部) は窓を開く前に終わっている (codex R2 必須)
+            self.assertTrue((out_dir / "first_read.json").exists())
+            self.assertEqual(len([c for c in calls if c[1] == "read_servo"]), 1 + len(scp.STS_REGS) // scp.FIRST_CHUNK)
+            for i in range(20):
+                on_frame(i, FakeTp())
+                gt.daemon_request({"cmd": "send_angle", "joint_id": 2, "angle": 1.0})
+                time.sleep(0.01)
+            return 0
+        gt.main = fake_main
+        gt.pygame.event.get = lambda *a, **k: []
+        try:
+            class A:
+                gap = 0.0
+            meta = {}
+            prober, waits = scp.run_with_pad(A(), out_dir, scp.read_cmd(), meta)
+            self.assertIs(gt.daemon_request, vsp._daemon_request)
+            self.assertTrue(gt.daemon_request._policy_locked)
+            with open(out_dir / "pad_rpc.csv", newline="", encoding="utf-8") as f:
+                rows = list(csv.DictReader(f))
+            self.assertEqual([r["cmd"] for r in rows], ["send_angle"] * 20)
+            self.assertTrue(all(r["wait_ms"] != "" and r["rpc_ms"] != "" for r in rows))
+            self.assertEqual(waits["reading"]["n"], 20)
+            self.assertEqual(meta["teleop_log"], "/tmp/teleop_fake/log.jsonl")
+            self.assertGreater(prober.n, 5)
+            self.assertTrue((out_dir / "first_read.json").exists())
+            self.assertIn(("probe", "read_servo"), calls)
+            self.assertFalse(prober.thread.is_alive())
+            with open(out_dir / "samples.csv", newline="", encoding="utf-8") as f:
+                srows = list(csv.DictReader(f))
+            self.assertTrue(all(r["wait_ms"] != "" for r in srows))
+        finally:
+            vsp._daemon_request, gt.main, gt.pygame.event.get = orig_req, orig_main, orig_get
+            vsp.get_daemon_socket = orig_sock
+            tmp.cleanup()
+
+
+    def test_cleanup_before_handover(self):
+        """窓まで行かずに終わったとき (始めの 1 回の失敗・gamepad_teleop の初めの失敗) も,
+        つながり・観察・pad_rpc.csv を片付ける (codex R3 推奨)."""
+        try:
+            import gamepad_teleop as gt
+            import run_policy_client  # noqa: F401
+            import policy_recorder as pr
+            import visual_servo_pick as vsp
+        except Exception as e:
+            self.skipTest(f"import できない: {e}")
+        saved = (vsp._daemon_request, vsp.get_daemon_socket, vsp.close_daemon_socket, gt.main, gt.rpc, gt.daemon_request)
+        closed = []
+
+        class A:
+            gap = 0.0
+        try:
+            for case in ("first_read_fails", "main_fails_early"):
+                closed.clear()
+                gt.rpc = None                       # gamepad_teleop の送り口を読み直させる
+                gt.daemon_request = None
+                n_obs = len(pr._rpc_observers)
+
+                def base(cmd_obj, timeout_override=None, _deadline_at=None, case=case):
+                    if case == "first_read_fails":
+                        raise RuntimeError("daemon error: unknown cmd: read_servo")
+                    return fake_resp()
+                vsp._daemon_request = base
+                vsp.get_daemon_socket = lambda *a, **k: None
+                vsp.close_daemon_socket = lambda: closed.append(1)
+
+                def bad_main(argv, on_frame=None):
+                    sys.exit("[ABORT] 同じ名前のフォルダがあります")
+                gt.main = bad_main
+                with tempfile.TemporaryDirectory() as d:
+                    with self.assertRaises((SystemExit, RuntimeError)):
+                        scp.run_with_pad(A(), Path(d), scp.read_cmd(), {})
+                    self.assertTrue((Path(d) / "pad_rpc.csv").exists())
+                self.assertTrue(closed, case)                               # つながりを閉じた
+                self.assertEqual(len(pr._rpc_observers), n_obs, case)  # 観察は残さない
+        finally:
+            (vsp._daemon_request, vsp.get_daemon_socket, vsp.close_daemon_socket, gt.main, gt.rpc,
+             gt.daemon_request) = saved
+
+
+if __name__ == "__main__":
+    unittest.main()
