ロボットアームのテレメトリダッシュボードを作る Log#1 | ターミナルに流れて消えていた状態を、止めずに観る
ロボットアームを動かすコードは、ずっとターミナルで動かしてきた。print が流れ、エラーが出て、また流れる。動いている間、アームが今どの段にいて、何秒そこに留まっているか、グリッパーは開いているか閉じているか——それはログの行として一瞬表示され、次の行に押し流されて消える。後から「さっきどこで止まった」を読み返そうとすると、スクロールバックの海を遡ることになる。
この状態を変える。制御ループは止めない。daemon も触らない。送信も増やさない。制御の脇に読み取り専用の観測層を立てて、流れて消える状態をブラウザで観られるようにする。テレメトリダッシュボードを作る。第一歩として、状態を投げる emit、最新値を配る observe_server、グランスとドリルインで観る HTML を作り、実機の Pick&Place がブラウザにライブで映るところまで進めた。
なぜターミナルではダメなのか
ターミナルは状態を「流す」。流れたものは消える。これが2つの不便になる。
まず、詰まりが見えない。アームがある姿勢へ降下しようとして、何秒そこで粘っているか——ターミナルは「降下開始」の行を1回出すだけで、経過時間を見せない。動いているのか固まっているのかが、ログの流れからは判別できない。
そして、失敗の反復が読めない。同じ理由で3回続けて掴み損ねたとき、その3回は時間方向に流れて散らばる。「同じ失敗を繰り返している」という、改善に一番効く情報が、行と行の間に埋もれる。
商用のロボット開発では、この問題はとっくに認識されている。Foxglove は「開発者はデバッグにターミナル(rosout)を使うが、商用や最終用途には向かない。オペレータは大量のデータストリームを追う時間がない」と書いている。テレメトリ・モニタリング・オブザーバビリティという言葉で呼ばれる領域がそれで、ターミナルから観測プラットフォームへ移る、というのが定石になっている。
この移行を、iPhone と Unity で実機を動かしてきた個人の延長として、自分の手で作る。それがこのシリーズの主題になる。
daemon から読む案は捨てた
最初に確かめたのは、状態をどこから読むかだった。mechArm は Pi 上の常駐 daemon(mecharm_daemon.py)が唯一のシリアル所有者で、Mac の制御スクリプトが TCP 5555 経由でコマンドを送る構成になっている。
素朴には「観測も daemon に2本目のクライアントとして繋いで、現在角度を polling すればいい」と考えた。だが daemon のソースを読んで、それができないことが分かった。該当箇所はこうなっている。
daemon の accept ループは単一クライアントを逐次処理する設計で、listen(1) のあと1本の接続を serve_client で掴むと、そのクライアントが切断するまで recv ループでブロックする。つまり制御スクリプトが繋いでいる間、観測クライアントの2本目は accept されない。一番見たい「運転中」に、観測は繋げない。
def main():
global _server_sock
signal.signal(signal.SIGTERM, shutdown_handler)
signal.signal(signal.SIGINT, shutdown_handler)
init_mycobot()
_server_sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
_server_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
_server_sock.bind((DAEMON_HOST, DAEMON_PORT))
_server_sock.listen(1)
log(f"ポート {DAEMON_PORT} で待ち受け中...")
try:
while _running:
try:
client_sock, addr = _server_sock.accept()
except OSError:
break
serve_client(client_sock, addr)
finally:
if _mc is not None:
try:
_mc.set_fresh_mode(0)
log("set_fresh_mode(0)")
except Exception:
pass
_server_sock.close()
log("シャットダウン完了")さらに daemon が返せる状態は get_angles(6関節角)だけで、フェーズ・グリッパーの開閉・試行の文脈は持っていない。それらは制御ループの中にしか存在しない。
この2つの事実が、設計を一方向に決めた。観測の供給源は daemon ではなく、制御ループ自身にする。制御ループは送信のたびに新鮮な角度を既に読んでいて、フェーズもグリッパーの状態も手の中にある。観測はそれを脇のチャンネルに流し、別プロセスがそれを受けてブラウザに配る。daemon には一切触らない。
3つの層
作るものを3つに分けた。
ひとつめ、emit.py。制御ループから観測値を localhost の UDP 5601 へ「1 datagram = 1 JSON 行」で投げるだけの送信側。投げて忘れる(fire-and-forget)。例外を全て握り、制御を1ミリ秒も止めない。
ふたつめ、observe_server.py。UDP を受けて最新の1個だけ保持し、WebSocket でブラウザへ約100ミリ秒間隔で配る中継役(keeper)。
みっつめ、static/index.html。受け取った値を、ひと目で分かる「グランス」と、掘って見る「ドリルイン」の2層で描くブラウザUI。
供給源(emit)・中継(server)・表示(html)を分けた。送信側だけが制御ループに依存し、中継と表示はその下流で「アームの状態」という抽象だけを扱う。供給を差し替えれば、別の機種でも同じ中継・同じ表示が使える継ぎ目になる。今は mechArm が供給するが、後で別のアームが供給しても表示側は変わらない。
emit.py:投げて忘れる送信側
まず送信側の全文。ここが今回の心臓になる。ソケットはモジュールロードで1個だけ生成して使い回し、seq はプロセス内で単調増加、そして全ての例外を握って raise しない——制御ループがこの emit のせいで止まることが絶対にあってはならないため。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""observe/emit.py — UDP fire-and-forget テレメトリ送信
制御ループからの観測値を localhost UDP 5601 に「1 datagram = 1 JSON 行」で投げる。
**fire-and-forget・例外を全て握る・制御の振る舞いに絶対干渉しない**。
スキーマ(ステップ A の keeper 側と完全一致):
{"t": <unix_ms:int>, "seq": <int>, "source": "control_loop"|...,
"angles": [j1..j6]|null, "ee": [x,y,z]|null,
"gripper": 0|1|null, "phase": <str>|null, "phase_t0": <ms>|null,
"trial": {"id": <str>, "task": <str>}|null,
"verdict": <dict>|null}
設計:
- ソケットはモジュールロードで 1 個生成して使い回す(毎回 open しない)
- seq はモジュール内グローバルで単調増加、emit ごとに +1
- どの例外も raise しない(制御を絶対に止めない)
- localhost のみ。外部公開しない
"""
from __future__ import print_function
import json
import socket
import time
_UDP_HOST = "127.0.0.1"
_UDP_PORT = 5601
# ソケットはモジュールロードで 1 個生成して使い回す。
# 失敗しても emit() は raise しない(None チェックで握る)。
# nonblocking: 送信バッファ満杯時に sendto がブロックして制御ループを止めないため。
# 取りこぼした datagram は次の emit で回復する(観測の最新主義)。
try:
_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
_sock.setblocking(False)
except Exception:
_sock = None
# seq はモジュール内グローバル。プロセス開始から単調増加。
# telemetry_probe.py との競合は source 識別子で見分ける運用。
_seq = 0
def emit(angles=None, phase=None, phase_t0_ms=None, gripper=None,
ee=None, trial=None, verdict=None, source="control_loop"):
"""テレメトリ 1 発を UDP 5601 へ送る。
全例外を握る・raise しない・制御を 1 ミリ秒も止めない。
UDP 送信失敗・JSON 化失敗・ソケット未生成、どれでも例外を投げない。
"""
global _seq
try:
_seq += 1
payload = {
"t": int(time.time() * 1000),
"seq": _seq,
"source": source,
"angles": list(angles) if angles is not None else None,
"ee": list(ee) if ee is not None else None,
"gripper": gripper,
"phase": phase,
"phase_t0": phase_t0_ms,
"trial": trial,
"verdict": verdict,
}
if _sock is None:
return
data = (json.dumps(payload) + "\n").encode("utf-8")
_sock.sendto(data, (_UDP_HOST, _UDP_PORT))
except Exception:
# 制御を絶対に止めない。失敗は黙って捨てる
passスキーマには angles や phase だけでなく、ee・verdict も最初から枠として置いてある。今回これらは常に null で流れる。だが将来 EE 位置の算出や成否判定(VLM)を配線するとき、スキーマを変えずに値を埋めるだけで済む。空の席を先に予約しておく。
ソケットの setblocking(False) は、レビューを通したあとに加えた1行になる。localhost の小さな datagram なので送信がブロックする実害はほぼ無いが、「emit は絶対に制御タイミングへ遅延を足さない」を実害の大小ではなく構造で保証するために入れた。差分はこれだけ。
34a35,36
> # nonblocking: 送信バッファ満杯時に sendto がブロックして制御ループを止めないため。
> # 取りこぼした datagram は次の emit で回復する(観測の最新主義)。
36a39
> _sock.setblocking(False)受信側の不在・送信バッファの圧迫を想定し、5万連発のバースト送信でも例外が出ないことを確認している。
observe_server.py:最新の1個だけを配る中継
中継側の全文。UDP を非同期で受け続けて最新の1個だけを保持し、WebSocket で約100ミリ秒間隔でブラウザに配る。受信が無くても現在時刻と最後の値を送り続けるので、ブラウザ側は「固まり」を識別できる。全てのバインドは localhost に限定している。
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""observe_server.py — keeper: UDP 受信 → WebSocket push
UDP 5601 で telemetry datagram を受け、**最新 1 個だけ** 保持。
WebSocket /ws で接続中ブラウザに ~100ms 間隔で最新値を push。
設計の性質:
- UDP 受信は fire-and-forget。古い datagram は捨てる(最新主義)
- 受信が無くてもタイマで現在時刻+最後の値を送り、ブラウザが「固まり」を識別できる
- 全バインド **localhost 限定**。外部公開しない
- daemon を触らない・制御ループを触らない
依存(個別 pip):
pip install fastapi uvicorn
CLI:
python3 observe_server.py
→ http://127.0.0.1:8000/ で UI
"""
import asyncio
import json
import socket
import sys
import time
from pathlib import Path
try:
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import FileResponse, JSONResponse
import uvicorn
except ImportError as e:
print("[ERROR] 依存パッケージが無い: {}".format(e), file=sys.stderr)
print(" pip install fastapi uvicorn", file=sys.stderr)
sys.exit(1)
# ---- 定数 -------------------------------------------------------------------
HTTP_HOST = "127.0.0.1"
HTTP_PORT = 8000
UDP_LISTEN_HOST = "127.0.0.1"
UDP_LISTEN_PORT = 5601
WS_PUSH_INTERVAL_SEC = 0.1
_STATIC_DIR = Path(__file__).resolve().parent / "static"
# ---- 最新値ストア(プロセス内グローバル)-----------------------------------
_latest = {"datagram": None, "received_at_ms": None}
# ---- UDP listener(バックグラウンドタスク)---------------------------------
async def _udp_listener():
"""UDP 5601 を非同期で受信し続け、_latest を更新。
例外は記録のみ。listener は落ちないことを優先する。"""
loop = asyncio.get_event_loop()
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
sock.setblocking(False)
try:
sock.bind((UDP_LISTEN_HOST, UDP_LISTEN_PORT))
except OSError as e:
print("[SRV] UDP bind 失敗 {}:{}: {}".format(
UDP_LISTEN_HOST, UDP_LISTEN_PORT, e), file=sys.stderr)
return
print("[SRV] UDP listen {}:{}".format(UDP_LISTEN_HOST, UDP_LISTEN_PORT))
while True:
try:
data, _addr = await loop.sock_recvfrom(sock, 65535)
except Exception as e:
print("[SRV] UDP recv 例外: {}".format(e), file=sys.stderr)
await asyncio.sleep(0.1)
continue
try:
line = data.decode("utf-8").strip()
payload = json.loads(line)
except Exception as e:
print("[SRV] UDP parse 例外: {} raw={!r}".format(e, data[:200]),
file=sys.stderr)
continue
_latest["datagram"] = payload
_latest["received_at_ms"] = int(time.time() * 1000)
# ---- FastAPI ----------------------------------------------------------------
app = FastAPI()
@app.on_event("startup")
async def _startup():
asyncio.create_task(_udp_listener())
@app.get("/")
def index():
html_path = _STATIC_DIR / "index.html"
if not html_path.exists():
return JSONResponse(
status_code=500,
content={"error": "static/index.html not found",
"path": str(html_path)})
return FileResponse(str(html_path), media_type="text/html")
@app.get("/healthz")
def healthz():
"""ヘルスチェック。daemon/UDP 状況問わず常に 200 を返す(keeper 自身の生存)."""
return {
"ok": True,
"server_now_ms": int(time.time() * 1000),
"last_received_at_ms": _latest["received_at_ms"],
"has_datagram": _latest["datagram"] is not None,
}
@app.websocket("/ws")
async def ws_endpoint(ws: WebSocket):
await ws.accept()
print("[SRV] WS connected")
try:
while True:
now_ms = int(time.time() * 1000)
msg = {
"server_now_ms": now_ms,
"datagram": _latest["datagram"],
"received_at_ms": _latest["received_at_ms"],
}
await ws.send_text(json.dumps(msg))
await asyncio.sleep(WS_PUSH_INTERVAL_SEC)
except WebSocketDisconnect:
print("[SRV] WS disconnected")
except Exception as e:
print("[SRV] WS 例外: {}".format(e), file=sys.stderr)
# ---- メイン -----------------------------------------------------------------
def main():
print("=" * 60)
print("[SRV] observe_server.py")
print("[SRV] HTTP/WS = {}:{}".format(HTTP_HOST, HTTP_PORT))
print("[SRV] UDP in = {}:{}".format(UDP_LISTEN_HOST, UDP_LISTEN_PORT))
print("[SRV] open http://{}:{}/".format(HTTP_HOST, HTTP_PORT))
print("[SRV] health http://{}:{}/healthz".format(HTTP_HOST, HTTP_PORT))
print("=" * 60)
uvicorn.run(app, host=HTTP_HOST, port=HTTP_PORT, log_level="warning")
if __name__ == "__main__":
main()「最新の1個だけ保持」は意図的な選択になる。観測は履歴の完全性ではなく、今この瞬間の状態を見るためのもの。古い datagram は捨て、最新で上書きする。受信が止まっても WebSocket は時刻だけ更新して送り続けるので、ブラウザは「データが止まった」ことを時間差として検出できる。
index.html:グランスとドリルイン
表示側の全文。判断を変える4つ(フェーズと経過秒・グリッパー・J1角度・試行)を上段のグランスに大きく置き、関節6軸の生値・EE・成否判定・生datagramは下段のドリルインに畳んだ。配色はモノクロ、フォントは IBM Plex Mono と Noto Sans JP。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>observe — グランス+ドリルイン</title>
<link rel="preconnect" href="https://fonts.googleapis.com">
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin>
<link href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:wght@400;500;600;700&family=Noto+Sans+JP:wght@400;500;700&display=swap" rel="stylesheet">
<style>
:root {
--bg: #f5f5f0;
--fg: #0d0d0d;
--mono: 'IBM Plex Mono', monospace;
--sans: 'Noto Sans JP', sans-serif;
--border: 1.5px solid var(--fg);
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg);
color: var(--fg);
font-family: var(--mono);
padding: 40px 16px;
max-width: 960px;
margin: 0 auto;
transition: opacity 0.3s;
}
body.stale { opacity: 0.32; }
.conn-bar {
display: flex;
justify-content: space-between;
align-items: baseline;
border-bottom: var(--border);
padding-bottom: 10px;
margin-bottom: 18px;
font-size: 13px;
letter-spacing: 0.08em;
}
.conn-bar .label { color: #888; }
.conn-left { display: flex; align-items: center; gap: 8px; }
.conn-dot {
display: inline-block;
width: 11px; height: 11px;
background: var(--fg);
}
.conn-right { color: #888; }
.conn-right b { color: var(--fg); font-weight: 600; }
.glance {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 18px;
margin-bottom: 18px;
}
.cell {
border: var(--border);
padding: 22px 24px;
min-height: 190px;
display: flex;
flex-direction: column;
}
.cell .k {
font-size: 13px;
letter-spacing: 0.12em;
color: #888;
margin-bottom: 14px;
}
.big {
font-size: 76px;
font-weight: 700;
line-height: 1.0;
letter-spacing: -0.01em;
}
.big .unit { font-size: 26px; font-weight: 500; color: #888; margin-left: 4px; }
.phase-name { font-size: 34px; font-weight: 600; margin-bottom: 8px; }
.phase-elapsed { font-size: 60px; font-weight: 700; line-height: 1.0; }
.phase-elapsed .unit { font-size: 22px; font-weight: 500; color: #888; }
.grip {
font-size: 60px;
font-weight: 700;
align-self: flex-start;
}
.grip.close {
background: var(--fg);
color: var(--bg);
padding: 4px 18px;
}
.trial .row { font-size: 15px; margin-top: 6px; display: flex; gap: 14px; }
.trial .row .tk { color: #888; min-width: 48px; }
details.drill {
border: var(--border);
padding: 14px 18px;
}
details.drill summary {
font-size: 13px;
letter-spacing: 0.1em;
cursor: pointer;
list-style: none;
display: flex;
justify-content: space-between;
}
details.drill summary::-webkit-details-marker { display: none; }
details.drill[open] summary { margin-bottom: 16px; }
.drill-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.drill-block h4 {
font-size: 12px;
letter-spacing: 0.1em;
color: #888;
font-weight: 500;
margin-bottom: 8px;
border-bottom: 1px solid #ccc;
padding-bottom: 4px;
}
.drill-block .line {
font-size: 14px;
display: flex;
justify-content: space-between;
padding: 2px 0;
}
.drill-block .line .dk { color: #888; }
.raw {
font-size: 12px;
color: #666;
white-space: pre-wrap;
word-break: break-all;
line-height: 1.5;
}
@media (max-width: 680px) {
body { padding: 32px 12px; }
.glance { grid-template-columns: 1fr; }
.drill-grid { grid-template-columns: 1fr; }
.big { font-size: 60px; }
.phase-elapsed { font-size: 48px; }
}
</style>
</head>
<body>
<div class="conn-bar">
<div class="conn-left">
<span class="label">CONN</span>
<span class="conn-dot" id="conn-dot"></span>
<span id="conn-state">—</span>
</div>
<div class="conn-right">
LAST <b id="last">—</b> DELTA <b id="delta">—</b> SEQ <b id="seq">—</b>
</div>
</div>
<div class="glance">
<div class="cell">
<div class="k">PHASE</div>
<div class="phase-name" id="phase-name">—</div>
<div class="phase-elapsed"><span id="phase-elapsed">—</span><span class="unit">s</span></div>
</div>
<div class="cell">
<div class="k">GRIPPER</div>
<div class="grip" id="grip">—</div>
</div>
<div class="cell">
<div class="k">J1 (ANGLES[0])</div>
<div class="big"><span id="j1">—</span><span class="unit">deg</span></div>
</div>
<div class="cell trial">
<div class="k">TRIAL</div>
<div class="row"><span class="tk">ID</span><span id="trial-id">—</span></div>
<div class="row"><span class="tk">TASK</span><span id="trial-task">—</span></div>
</div>
</div>
<details class="drill">
<summary>
<span>DRILL-IN — JOINTS / EE / VERDICT / RAW</span>
<span>[ + ]</span>
</summary>
<div class="drill-grid">
<div class="drill-block">
<h4>JOINTS (deg)</h4>
<div class="line"><span class="dk">j1</span><span id="dj1">—</span></div>
<div class="line"><span class="dk">j2</span><span id="dj2">—</span></div>
<div class="line"><span class="dk">j3</span><span id="dj3">—</span></div>
<div class="line"><span class="dk">j4</span><span id="dj4">—</span></div>
<div class="line"><span class="dk">j5</span><span id="dj5">—</span></div>
<div class="line"><span class="dk">j6</span><span id="dj6">—</span></div>
</div>
<div class="drill-block">
<h4>EE (m)</h4>
<div class="line"><span class="dk">x</span><span id="eex">—</span></div>
<div class="line"><span class="dk">y</span><span id="eey">—</span></div>
<div class="line"><span class="dk">z</span><span id="eez">—</span></div>
<h4 style="margin-top:16px;">VERDICT</h4>
<div class="line"><span class="dk">success</span><span id="vsuccess">—</span></div>
<div class="line"><span class="dk">reward</span><span id="vreward">—</span></div>
<div class="line"><span class="dk">reason</span><span id="vreason">—</span></div>
</div>
</div>
<div style="margin-top:16px;">
<h4 style="font-size:12px;letter-spacing:0.1em;color:#888;font-weight:500;margin-bottom:8px;border-bottom:1px solid #ccc;padding-bottom:4px;">RAW</h4>
<div class="raw" id="raw">—</div>
</div>
</details>
<script>
const STALE_MS = 2000;
const $ = (id) => document.getElementById(id);
let state = {
phase: null,
phase_t0: null,
last_received_ms: null,
server_now_ms: null,
};
// フェーズ整形: 末尾の _a{N} を先に切り出してから stage 番号部を読む
function fmtPhase(raw) {
if (!raw) return "—";
let suffix = "";
let m = raw.match(/^(.*)_a(\d+)$/);
if (m) { raw = m[1]; suffix = " (a" + m[2] + ")"; }
const sm = raw.match(/^(stage\d+[a-z]*)_?(.*)$/);
if (sm) {
const head = sm[1].toUpperCase();
const tail = sm[2] ? " " + sm[2].toUpperCase() : "";
return head + tail + suffix;
}
return raw.toUpperCase() + suffix;
}
function fmtGripper(g) {
if (g === 1) return { text: "CLOSE", cls: "grip close" };
if (g === 0) return { text: "OPEN", cls: "grip" };
return { text: "—", cls: "grip" };
}
function fmtNum(v, digits) {
if (v === null || v === undefined || typeof v !== "number") return "—";
return v.toFixed(digits);
}
function applyDatagram(dg, received_at_ms, server_now_ms) {
state.server_now_ms = server_now_ms;
state.last_received_ms = received_at_ms;
if (!dg) {
$("seq").textContent = "—";
return;
}
$("seq").textContent = (dg.seq !== undefined && dg.seq !== null) ? dg.seq : "—";
// PHASE
state.phase = dg.phase || null;
state.phase_t0 = dg.phase_t0 || null;
$("phase-name").textContent = fmtPhase(dg.phase);
// GRIPPER
const g = fmtGripper(dg.gripper);
const ge = $("grip");
ge.textContent = g.text;
ge.className = g.cls;
// J1
const angles = dg.angles;
if (angles && angles.length >= 1) {
$("j1").textContent = fmtNum(angles[0], 2);
} else {
$("j1").textContent = "—";
}
// TRIAL
if (dg.trial) {
$("trial-id").textContent = dg.trial.id || "—";
$("trial-task").textContent = dg.trial.task || "—";
} else {
$("trial-id").textContent = "—";
$("trial-task").textContent = "—";
}
// DRILL-IN joints
for (let i = 0; i < 6; i++) {
const el = $("dj" + (i + 1));
if (angles && angles.length > i) el.textContent = fmtNum(angles[i], 2);
else el.textContent = "—";
}
// DRILL-IN ee
const ee = dg.ee;
$("eex").textContent = (ee && ee.length > 0) ? fmtNum(ee[0], 4) : "—";
$("eey").textContent = (ee && ee.length > 1) ? fmtNum(ee[1], 4) : "—";
$("eez").textContent = (ee && ee.length > 2) ? fmtNum(ee[2], 4) : "—";
// DRILL-IN verdict
const v = dg.verdict;
if (v) {
$("vsuccess").textContent = (v.success !== undefined && v.success !== null) ? String(v.success) : "—";
$("vreward").textContent = (v.reward !== undefined && v.reward !== null) ? String(v.reward) : "—";
$("vreason").textContent = v.reason || "—";
} else {
$("vsuccess").textContent = "—";
$("vreward").textContent = "—";
$("vreason").textContent = "—";
}
// RAW
$("raw").textContent = JSON.stringify(dg, null, 2);
tickElapsed();
}
// 経過秒のローカル時計。datagram の谷間でも進める
function tickElapsed() {
if (state.phase_t0 === null) {
$("phase-elapsed").textContent = "—";
return;
}
const now = Date.now();
const elapsed = (now - state.phase_t0) / 1000.0;
$("phase-elapsed").textContent = elapsed.toFixed(1);
}
// stale 判定とコネクション表示
function tickStale() {
if (state.last_received_ms === null) return;
const now = Date.now();
const delta = now - state.last_received_ms;
document.body.classList.toggle("stale", delta > STALE_MS);
$("delta").textContent = (delta / 1000.0).toFixed(1) + "s";
const d = new Date(state.last_received_ms);
const hh = String(d.getHours()).padStart(2, "0");
const mm = String(d.getMinutes()).padStart(2, "0");
const ss = String(d.getSeconds()).padStart(2, "0");
const ms = String(d.getMilliseconds()).padStart(3, "0");
$("last").textContent = `${hh}:${mm}:${ss}.${ms}`;
}
setInterval(() => { tickElapsed(); tickStale(); }, 100);
// WebSocket 接続
function connect() {
const ws = new WebSocket("ws://127.0.0.1:8000/ws");
ws.onopen = () => {
$("conn-state").textContent = "open";
$("conn-dot").style.background = "var(--fg)";
};
ws.onclose = () => {
$("conn-state").textContent = "closed";
$("conn-dot").style.background = "#bbb";
setTimeout(connect, 1000);
};
ws.onmessage = (ev) => {
try {
const msg = JSON.parse(ev.data);
applyDatagram(msg.datagram, msg.received_at_ms, msg.server_now_ms);
} catch (e) {}
};
}
connect();
</script>
</body>
</html>経過秒の扱いに、このUIの肝がある。tickElapsed は WebSocket の受信に依存せず、ローカルの setInterval で100ミリ秒ごとに Date.now() - phase_t0 を再計算する。なぜそうしたかは、後の実機運転で分かる。
制御ループへの接続:印字を1文字も変えない
供給源は制御ループ自身、と決めた。だが制御ループ(run_pick_place.py、本体993行)は、これまでの実機ログが全て print 出力を前提に書かれている。その印字フォーマットを変えると、過去の記録との互換が壊れる。
そこで、既存の print には一切触れず、観測 emit を脇に追加する方針を取った。状態(フェーズ・グリッパー)はモジュール辞書 _OBSERVE_STATE に持ち、各ステージで更新する。emit 本体は _do_move が移動後の現在角を取得した直後の1か所に集約する——全11ステージの移動がこの関数を経由するので、1か所で全フェーズの角度がブラウザに届く。_daemon_get_angles_filtered() の戻り値を再利用するので、daemon への問い合わせは1回も増えない。
追加分の差分は次の通り。全て追加行(>)で、削除・変更行は1つも無い。
91a92,99
> # 観測 emit(脇 UDP・fire-and-forget・例外は emit 側で握る)
> # 観測モジュールが理由で制御が止まらないよう、import を guard する
> try:
> from observe.emit import emit as _observe_emit # noqa: E402
> except Exception:
> def _observe_emit(*_args, **_kwargs): # 観測無効でも制御は動く
> pass
>
118a127,150
> # ---- 観測 emit 用の状態(run() が更新、_do_move が読む)-----------------
> # 状態は run() スコープ所有(_observe_phase / _observe_gripper ヘルパ経由で更新)。
> # 内側関数(stage_lookup_and_move)には phase 更新を持たせない。
> # emit は _do_move の post_actual 取得直後の 1 サイトに集約する(最小変更)。
> _OBSERVE_STATE = {
> "phase": None,
> "phase_t0_ms": None,
> "gripper": None,
> "run_stem": None,
> "task": "pick_and_place_with_j0_table",
> }
>
>
> def _observe_phase(phase_str):
> """run() からフェーズ遷移を記録する。phase_t0 も自動更新."""
> _OBSERVE_STATE["phase"] = phase_str
> _OBSERVE_STATE["phase_t0_ms"] = int(time.time() * 1000)
>
>
> def _observe_gripper(state):
> """run() から gripper 状態を記録する(0=open / 1=close)."""
> _OBSERVE_STATE["gripper"] = state
>
>
189a222,232
> # 観測 emit(脇 UDP・fire-and-forget・例外は emit 側で握る)
> # post_actual を再利用するので daemon I/O は増えない。print 出力にも影響しない
> _observe_emit(
> angles=post_actual,
> phase=_OBSERVE_STATE["phase"],
> phase_t0_ms=_OBSERVE_STATE["phase_t0_ms"],
> gripper=_OBSERVE_STATE["gripper"],
> trial=({"id": _OBSERVE_STATE["run_stem"],
> "task": _OBSERVE_STATE["task"]}
> if _OBSERVE_STATE["run_stem"] else None),
> )
534a578,582
> # 観測 emit 用の状態を初期化(このプロセスで複数 run() を回す場合の前回残置を消す)
> _OBSERVE_STATE["phase"] = None
> _OBSERVE_STATE["phase_t0_ms"] = None
> _OBSERVE_STATE["gripper"] = None
> _OBSERVE_STATE["run_stem"] = run_stem
586a635
> _observe_phase("stage1")
588a638,639
> # stage1 で gripper open まで完了
> _observe_gripper(0)
590a642
> _observe_phase("stage2")
604a657
> _observe_phase("stage3_approach_pick" + suf)
612a666
> _observe_phase("stage4_pick" + suf)
619a674
> _observe_phase("stage5" + suf)
624a680
> _observe_gripper(1)
626a683
> _observe_phase("stage6" + suf)
687a745
> _observe_gripper(0)
721a780
> _observe_phase("pickfail_to_home")
736a796
> _observe_phase("stage7_approach_place")
744a805
> _observe_phase("stage8_place")
751a813
> _observe_phase("stage9")
755a818
> _observe_gripper(0)
758a822
> _observe_phase("stage9b_judge_place")
793a858
> _observe_phase("stage10")
828a894
> _observe_phase("abort_recover_home")この変更は制御コードに触れるため、実機の前に外部レビュー(codex)を通した。判定は「制御非干渉として実機テストへ進んでよい」。指摘は2点の注意のみで、いずれも制御の振る舞いには影響しないもの——ひとつは emit を _do_move の1か所に集約したことで、移動を伴わないステージ(グリッパー開閉など)の phase 名が次の移動で上書きされ datagram に出ないこと。もうひとつは送信ソケットが既定でブロッキングだったこと。後者は前述の setblocking(False) で塞いだ。前者は表示の粒度の問題として、実機を見てから判断することにした。
実機運転:初日に失敗の反復が見えた
daemon を起動し、観測サーバとブラウザを立て、いつもの Pick&Place を1回流した。アームが動き、その状態がブラウザにライブで映った。
そして、初日のこの1回が、テレメトリダッシュボードを作る理由をそのまま実演することになった。pick が3回続けて失敗し、4回目で成功し、その直後の place で到達失敗して中断した。実機ログの全量を残す。
======================================================================
[RUN] run_pick_place.py dry_run=False log=run_0001_20260616_113906.json
======================================================================
[Pi] fresh_mode=1
[INIT ] fresh_mode=1
[STAGE 1] HOME 復帰 + グリッパー開
[SEND] stage1_to_home: send_angles (single) [ +0.00, +0.00, +0.00, +0.00, +0.00, -30.00] speed=20 wait=1.0s (pre max|Δ|=1.93°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.93° (tol 2.5°)
[MOVE ] stage1_to_home arrived=True retried=False post_max|Δ|=1.930°
[GRIP ] stage1_grip_open action=open sleep=2.0s dry_run=False
[Pi] GRIPPER: OPEN result= None
[STAGE 2] 赤ブロック + 白箱 同時検出(block と box は同回・同 homography)
[CAM] 接続中カメラ一覧:
index=0 name=FaceTime HDカメラ
index=1 name=Iriun Camera
index=2 name=iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone) index=2
[CAM] 横カメラ(Iriun) index=1
[DETECT] resolved top index: 2
[Mac] マット四隅検出中... (10フレーム)
[Mac] ✓ マット四隅検出: 10/10 フレーム成功
[Mac] ✓ 動的homography計算完了
[Mac] ✓ マット範囲: X=[380, 1666] Y=[128, 1079]
[06f] TEMPLATES_DIR overridden to: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/place/templates
[06f] ROTATION_ANGLES_DEG overridden to: [0, 15, 30, 45, 60, 75]
[06f] SCALES overridden to: [0.85, 1.0, 1.15]
[テンプレ] 計 8 枚 (day165=8, auto=0)
[06f] DETECT: center=(1461,730) score=0.656 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.09s)
[DETECT][1/3] box Unity=(-0.217,+0.210) score=0.6558569669723511
赤ブロック pixel=(592, 860) → Unity=(0.2109, 0.2592)
[DETECT][1/3] block Unity=(+0.211,+0.259) score=null
[06f] DETECT: center=(1460,731) score=0.656 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.12s)
[DETECT][2/3] box Unity=(-0.216,+0.210) score=0.6555615067481995
赤ブロック pixel=(592, 861) → Unity=(0.2109, 0.2597)
[DETECT][2/3] block Unity=(+0.211,+0.260) score=null
[06f] DETECT: center=(1460,731) score=0.655 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.13s)
[DETECT][3/3] box Unity=(-0.216,+0.210) score=0.6553437113761902
赤ブロック pixel=(592, 860) → Unity=(0.2109, 0.2592)
[DETECT][3/3] block Unity=(+0.211,+0.259) score=null
[DETECT] block_xz=[0.2108844451904297, 0.2591982116699219] block_score=None
[DETECT] box_xz=[-0.21600440979003907, 0.20998764038085938] box_score=0.6555615067481995
[LOOKUP] stage3_approach_pick phase=approach_pick target_xz=(+0.2109,+0.2592) → j0=-32.518° method=interp x_resid=0.00000m z_resid=0.00627m joint_ok=True
[SEND] stage3_approach_pick: send_angles (single) [ -32.52, +87.74, -38.33, +3.60, -70.11, -30.00] speed=20 wait=1.0s (pre max|Δ|=86.16°, post-send timeout 14.9s)
[SEND] arrival OK: max|Δ|=1.56° (tol 2.0°)
[MOVE ] stage3_approach_pick arrived=True retried=False post_max|Δ|=1.420°
[LOOKUP] stage4_pick phase=pick target_xz=(+0.2109,+0.2592) → j0=-39.626° method=interp x_resid=0.00000m z_resid=0.03529m joint_ok=True
[SEND] stage4_pick: send_angles (single) [ -39.63, +88.00, -21.44, +1.40, -70.48, -32.87] speed=20 wait=1.0s (pre max|Δ|=15.47°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.23° (tol 3.0°)
[MOVE ] stage4_pick arrived=True retried=False post_max|Δ|=1.230°
[GRIP ] stage5_grip_close action=close sleep=2.0s dry_run=False
[Pi] GRIPPER: CLOSE result= None
[SEND] stage6_lift: send_angles (single) [ -32.52, +87.74, -38.33, +3.60, -70.11, -30.00] speed=20 wait=1.0s (pre max|Δ|=17.59°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE ] stage6_lift arrived=True retried=False post_max|Δ|=1.380°
[CAM] 接続中カメラ一覧:
index=0 name=FaceTime HDカメラ
index=1 name=Iriun Camera
index=2 name=iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone) index=2
[CAM] 横カメラ(Iriun) index=1
[CAP-VLM] pick 保存: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260616_113906_frames/run_0001_20260616_113906_pick.jpg
[JUDGE ] pick attempt=1 adopted=no (vlm=no human=None) confidence=0.92 reason=爪の先端付近には赤いワイヤーは見えるが赤いブロックは把持されておらず、白いブロックが爪から離れた場所(右下)に置かれている
[GRIP ] regrasp_open_a1 action=open sleep=2.0s dry_run=False
[Pi] GRIPPER: OPEN result= None
[SEND] regrasp_retreat_a1: send_angles (single) [ +85.00, +87.74, -38.33, +3.60, -70.11, -30.00] speed=20 wait=1.0s (pre max|Δ|=117.87°, post-send timeout 19.7s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE ] regrasp_retreat_a1 arrived=True retried=False post_max|Δ|=1.380°
[STAGE 2] 赤ブロック + 白箱 同時検出(block と box は同回・同 homography)
[CAM] 接続中カメラ一覧:
index=0 name=FaceTime HDカメラ
index=1 name=Iriun Camera
index=2 name=iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone) index=2
[CAM] 横カメラ(Iriun) index=1
[DETECT] resolved top index: 2
[Mac] マット四隅検出中... (10フレーム)
[Mac] ✓ マット四隅検出: 10/10 フレーム成功
[Mac] ✓ 動的homography計算完了
[Mac] ✓ マット範囲: X=[384, 1661] Y=[129, 1079]
[テンプレ] 計 8 枚 (day165=8, auto=0)
[06f] DETECT: center=(1454,729) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.19s)
[DETECT][1/3] box Unity=(-0.215,+0.211) score=0.6512719988822937
赤ブロック pixel=(575, 889) → Unity=(0.2216, 0.2729)
[DETECT][1/3] block Unity=(+0.222,+0.273) score=null
[06f] DETECT: center=(1454,729) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.29s)
[DETECT][2/3] box Unity=(-0.215,+0.211) score=0.6514489054679871
赤ブロック pixel=(575, 889) → Unity=(0.2216, 0.2729)
[DETECT][2/3] block Unity=(+0.222,+0.273) score=null
[06f] DETECT: center=(1454,729) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.07s)
[DETECT][3/3] box Unity=(-0.215,+0.211) score=0.6511404514312744
赤ブロック pixel=(576, 888) → Unity=(0.2211, 0.2725)
[DETECT][3/3] block Unity=(+0.221,+0.272) score=null
[DETECT] block_xz=[0.22160145568847656, 0.2729190673828125] block_score=None
[DETECT] box_xz=[-0.21489224243164062, 0.21066572570800782] box_score=0.6512719988822937
[LOOKUP] stage3_approach_pick_a2 phase=approach_pick target_xz=(+0.2216,+0.2729) → j0=-34.555° method=interp x_resid=0.00000m z_resid=0.01658m joint_ok=True
[SEND] stage3_approach_pick_a2: send_angles (single) [ -34.55, +87.74, -38.33, +3.60, -70.11, -30.00] speed=20 wait=1.0s (pre max|Δ|=119.54°, post-send timeout 19.9s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE ] stage3_approach_pick_a2 arrived=True retried=False post_max|Δ|=1.380°
[LOOKUP] stage4_pick_a2 phase=pick target_xz=(+0.2216,+0.2729) → j0=-42.600° method=interp x_resid=0.00000m z_resid=0.06027m joint_ok=True
[INFO ] stage4_pick_a2 z_residual_m=0.0603m (情報のみ・ABORT しない。工程跨ぎや弧高さ差で構造的に大きいことがある)
[SEND] stage4_pick_a2: send_angles (single) [ -42.60, +88.00, -21.44, +1.40, -70.48, -32.87] speed=20 wait=1.0s (pre max|Δ|=15.73°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.41° (tol 3.0°)
[MOVE ] stage4_pick_a2 arrived=True retried=False post_max|Δ|=1.410°
[GRIP ] stage5_grip_close_a2 action=close sleep=2.0s dry_run=False
[Pi] GRIPPER: CLOSE result= None
[SEND] stage6_lift_a2: send_angles (single) [ -34.55, +87.74, -38.33, +3.60, -70.11, -30.00] speed=20 wait=1.0s (pre max|Δ|=17.59°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE ] stage6_lift_a2 arrived=True retried=False post_max|Δ|=1.380°
[CAM] 接続中カメラ一覧:
index=0 name=FaceTime HDカメラ
index=1 name=Iriun Camera
index=2 name=iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone) index=2
[CAM] 横カメラ(Iriun) index=1
[CAP-VLM] pick_a2 保存: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260616_113906_frames/run_0001_20260616_113906_pick_a2.jpg
[JUDGE ] pick attempt=2 adopted=no (vlm=no human=None) confidence=0.9 reason=爪の先端付近に赤いものが見えるが、それはワイヤー/ケーブルであり、白い立方体ブロックがマット右側に別途置かれている。爪は何も掴んでいない状態に見える。
[GRIP ] regrasp_open_a2 action=open sleep=2.0s dry_run=False
[Pi] GRIPPER: OPEN result= None
[SEND] regrasp_retreat_a2: send_angles (single) [ +85.00, +87.74, -38.33, +3.60, -70.11, -30.00] speed=20 wait=1.0s (pre max|Δ|=119.98°, post-send timeout 20.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE ] regrasp_retreat_a2 arrived=True retried=False post_max|Δ|=1.380°
[STAGE 2] 赤ブロック + 白箱 同時検出(block と box は同回・同 homography)
[CAM] 接続中カメラ一覧:
index=0 name=FaceTime HDカメラ
index=1 name=Iriun Camera
index=2 name=iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone) index=2
[CAM] 横カメラ(Iriun) index=1
[DETECT] resolved top index: 2
[Mac] マット四隅検出中... (10フレーム)
[Mac] ✓ マット四隅検出: 10/10 フレーム成功
[Mac] ✓ 動的homography計算完了
[Mac] ✓ マット範囲: X=[383, 1661] Y=[129, 1079]
[テンプレ] 計 8 枚 (day165=8, auto=0)
[06f] DETECT: center=(1454,730) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 5.14s)
[DETECT][1/3] box Unity=(-0.215,+0.210) score=0.6509156227111816
赤ブロック pixel=(575, 889) → Unity=(0.2215, 0.2728)
[DETECT][1/3] block Unity=(+0.221,+0.273) score=null
[06f] DETECT: center=(1455,729) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 4.98s)
[DETECT][2/3] box Unity=(-0.215,+0.210) score=0.6506261825561523
赤ブロック pixel=(588, 822) → Unity=(0.2145, 0.2405)
[DETECT][2/3] block Unity=(+0.214,+0.240) score=null
[06f] DETECT: center=(1455,729) score=0.651 angle=0° scale=0.85 size=(158x158) template=white_box_20260520_163923_012.png (eval 4.95s)
[DETECT][3/3] box Unity=(-0.215,+0.210) score=0.6507194638252258
赤ブロック pixel=(588, 822) → Unity=(0.2145, 0.2405)
[DETECT][3/3] block Unity=(+0.214,+0.240) score=null
[DETECT] block_xz=[0.21448802185058594, 0.2404526824951172] block_score=None
[DETECT] box_xz=[-0.21527491760253906, 0.21001191711425782] box_score=0.6507194638252258
[LOOKUP] stage3_approach_pick_a3 phase=approach_pick target_xz=(+0.2145,+0.2405) → j0=-33.203° method=interp x_resid=0.00000m z_resid=0.02195m joint_ok=True
[SEND] stage3_approach_pick_a3: send_angles (single) [ -33.20, +87.74, -38.33, +3.60, -70.11, -30.00] speed=20 wait=1.0s (pre max|Δ|=117.83°, post-send timeout 19.7s)
[SEND] arrival OK: max|Δ|=1.48° (tol 2.0°)
[MOVE ] stage3_approach_pick_a3 arrived=True retried=False post_max|Δ|=1.380°
[LOOKUP] stage4_pick_a3 phase=pick target_xz=(+0.2145,+0.2405) → j0=-40.492° method=interp x_resid=0.00000m z_resid=0.01972m joint_ok=True
[SEND] stage4_pick_a3: send_angles (single) [ -40.49, +88.00, -21.44, +1.40, -70.48, -32.87] speed=20 wait=1.0s (pre max|Δ|=15.73°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.41° (tol 3.0°)
[MOVE ] stage4_pick_a3 arrived=True retried=False post_max|Δ|=1.410°
[GRIP ] stage5_grip_close_a3 action=close sleep=2.0s dry_run=False
[Pi] GRIPPER: CLOSE result= None
[SEND] stage6_lift_a3: send_angles (single) [ -33.20, +87.74, -38.33, +3.60, -70.11, -30.00] speed=20 wait=1.0s (pre max|Δ|=17.59°, post-send timeout 8.0s)
[SEND] arrival OK: max|Δ|=1.38° (tol 2.0°)
[MOVE ] stage6_lift_a3 arrived=True retried=False post_max|Δ|=1.380°
[CAM] 接続中カメラ一覧:
index=0 name=FaceTime HDカメラ
index=1 name=Iriun Camera
index=2 name=iPhone14 Pro MAXのカメラ
[CAM] 上カメラ(iPhone) index=2
[CAM] 横カメラ(Iriun) index=1
[CAP-VLM] pick_a3 保存: /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260616_113906_frames/run_0001_20260616_113906_pick_a3.jpg
[JUDGE ] pick attempt=3 adopted=yes (vlm=yes human=None) confidence=0.85 reason=ロボットアームの爪(グリッパー)の先端部分に赤い四角いブロックが挟まれており、爪が閉じた状態でブロックを保持しているように見える
[LOOKUP] stage7_approach_place phase=approach_place target_xz=(-0.2160,+0.2100) → j0=+43.213° method=interp x_resid=0.00000m z_resid=0.00303m joint_ok=True
[SEND] stage7_approach_place: send_angles (single) [ +43.21, +57.39, +17.49, +0.08, -89.20, -29.44] speed=20 wait=1.0s (pre max|Δ|=76.34°, post-send timeout 13.5s)
[SEND] [TIMEOUT] 13.5s 内に未到達: max|Δ|=2.46° actual=[ +43.50, +59.85, +17.66, +0.17, -89.91, -29.61]
[SEND] リトライしません: pre→last max|Δ| 76.63° >= 1.0° (部分移動/ストール疑い)
[MOVE ] stage7_approach_place arrived=False retried=False post_max|Δ|=2.460°
[ABORT ] stage=7 reason=到達失敗
[SEND] abort_recover_home: send_angles (single) [ +0.00, +0.00, +0.00, +0.00, +0.00, -30.00] speed=20 wait=1.0s (pre max|Δ|=89.91°, post-send timeout 15.5s)
[SEND] arrival OK: max|Δ|=2.46° (tol 2.5°)
[MOVE ] abort_recover_home arrived=True retried=False post_max|Δ|=2.460°
[RECOVER] ABORT 後 HOME 復帰: OK
[Pi] fresh_mode=0
[LOG ] /Users/shimazueiichi/r2r2r-scripts/dynamic_tracking_limits/logs/run_pick_place/run_0001_20260616_113906.jsonpick の3回の判定理由を並べると、失敗の質が見える。1回目「赤いワイヤーは見えるが赤いブロックは把持されておらず」、2回目「赤いものが見えるが、それはワイヤー/ケーブルであり」、3回目で初めて「赤い四角いブロックが挟まれており」。爪先のワイヤーをブロックと見間違える、あるいは実際に掴めていない——同じ原因で2回続けて落ち、3回目で掴めた。その後の place で到達に失敗(13.5秒内に未到達・部分移動かストール疑い)し、HOME へ復帰して中断した。
ブラウザは何を見せたか
この運転の間、ブラウザには PHASE がステージ名で表示され、その下の経過秒が動き、GRIPPER が OPEN と CLOSE を切り替え、J1 が実際の姿勢を追った。掴み直しの2回目では PHASE が「STAGE4 PICK (a2)」と接尾辞付きで表示され、J1 はログの送信値 -42.60 と整合する値を示した。
ここで、経過秒をローカルの setInterval で進める設計が効いた。移動と移動の間には数秒の空白がある——ある瞬間のスクリーンショットでは、最後の datagram から11.0秒が経過していた。経過秒を datagram の受信に同期させていたら、この11秒間は画面が固まって見える。だが実際にはローカル時計が独立して刻み続け、データ供給が止まっている間も経過秒は増え続けた。データが来ない谷間そのものが、「このフェーズに何秒留まっているか」として見える。これが、ターミナルが原理的に見せられなかった「詰まり」の可視化になる。
注入テストでこの挙動を単独で確認している。経過秒の起点を5秒前に設定した datagram を1回だけ送り、以後の送信を止める。それでも画面の経過秒は5.0から6.0、7.0と増え続けた。供給が1発きりでも、ローカル時計が進む。
初日に見えた、次にやること
このダッシュボードはまだ完成ではない。むしろ、初日の運転が「足りないもの」を具体的に示した。
VLM は pick の各試行で判定理由を出していた。「ワイヤーをブロックと見間違えている」という、改善に直結する情報だ。だがそれは今、ターミナルにしか無い。スキーマには verdict の席を予約してあるが、まだ空のままだ。次はここを埋める——成否判定をダッシュボードに乗せ、ドリルインの「—」を、失敗の理由が語る欄に変える。
そしてもうひとつ。この運転でいちばん意味があった情報は「同じ理由で3回続けて落ちた」という、試行をまたいだパターンだった。だが現在のグランスは、1試行の中の「今」しか見せない。失敗の反復を画面で見せるには、試行の履歴を持つ必要がある。単一の datagram を超えて、時系列を持つダッシュボードへ。
供給(emit)・中継(server)・表示(html)を分けたので、これらの拡張は表示側を育てるだけで足りる。制御ループには、もう触らない。観測は制御の一部ではない。制御を変えずに、制御の外側へ状態を逃がす層だ。
ターミナルからブラウザへ移したのは、見た目ではない。失敗の見え方そのものだ。次のログでは、見えた失敗を画面の上で読めるようにする。
