kassam 2a78f1b609 Record/replay studio, manual recorder kit, and new-robot install tooling
Dashboard (web/hand_web.py)
- Record/replay panel driving g1_record_replay.py as a pty child: take library
  (replay/download/duplicate/rename/delete/upload/delete-all), pause & resume,
  and in-take key buttons that grey out in the --fingers modes the recorder
  ignores (measured: in touch mode the keys change nothing at all).
- /api/restart is container-aware: it kills inspire_g1 and lets the supervisor
  relaunch it. It used to run manage.sh, which started a SECOND inspire_g1
  beside the supervised one - two writers on one RS-485 bus - and never
  returned.
- Shape/combo libraries take a .bak on every write, with an undo button. Both
  files are rewritten in full, so deleting the last entry was unrecoverable.

recorder/
- The recorder lives in this project now: one source of truth for the CLI and
  the dashboard, with pause/resume added to replay.
- record.sh picks a runtime by itself (a python with the SDK, the vendored SDK,
  or the inspire-hand container). bundle.sh packs a ~340KB portable kit.

tools/
- preflight.sh: read-only readiness report for a new robot (hardware, docker,
  build prerequisites, per-robot settings) ending in an install-path verdict.
- fetch_deps.sh: stage build dependencies, verifying the libs are aarch64.
- export_ui.py: regenerate an embedding app's vendored copy of the UI.

docker/
- build_image.sh resolves its dependencies from several layouts: deps/ inside
  the project, /usr/local, a source install prefix, or a ROS2 colcon workspace
  (where the idl headers live when /usr/local has none).
- web/ is copied in the last layer, so dashboard edits skip the C++ rebuild.
- restart=always, and start.sh always builds so an edit cannot silently run a
  stale image.

deps/unitree_sdk2 is vendored so a robot that has never seen the SDK can build.
Docs: README quickstart + embedding notes, SETUP_G1 corrected (that udev rule
stopped creating /dev/inspire_* symlinks a while ago), ROBOT_README describing
a live install.
2026-08-28 20:27:28 +04:00

1713 lines
86 KiB
Python
Executable File

#!/usr/bin/env python3
"""
Inspire hand web dashboard: sliders + arm/hand combos + live status.
- hand poses -> C++ hand_bridge (127.0.0.1:7799) -> rt/inspire/cmd
- arm actions -> build/arm_action (Unitree G1ArmActionClient)
- status/restart/iface controls
Needs inspire_g1 + hand_bridge (./manage.sh dashboard starts both).
"""
import os, socket, json, argparse, subprocess, glob, re, time, pty, signal, threading, queue, shlex
from flask import Flask, request, jsonify, Response
REPO = os.environ.get("HAND_REPO") or os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
BUILD = os.path.join(REPO, "build")
BRIDGE_HOST, BRIDGE_PORT = "127.0.0.1", 7799
# Saved shapes/combos live in HAND_DATA, which run.sh bind-mounts from the host. They used
# to sit inside the image at /opt/hand, so every `docker rm -f` in run.sh silently destroyed
# the whole library.
DATA_HOME = os.environ.get("HAND_DATA") or REPO
os.makedirs(DATA_HOME, exist_ok=True)
POSES_FILE = os.path.join(DATA_HOME, "hand_poses.txt")
COMBOS_FILE = os.path.join(DATA_HOME, "hand_combos.json")
IFACE_FILE = os.path.join(DATA_HOME, ".dashboard_iface")
PORT = 8088 # set from --port; the recorder pulls shapes back off this port
# Record/replay is driven by g1_record_replay.py as a CHILD PROCESS rather than
# reimplemented here. That script is ~1000 lines of arm gravity feed-forward, touch
# thresholding and closure remapping, and a second copy inside the dashboard would drift
# from the CLI the first time either one changed. RECORDER_DIR overrides the search.
REC_SCRIPT = "g1_record_replay.py"
# recorder/ inside this project comes first: the recorder ships WITH the dashboard so the
# two can never fall out of step, and a checkout is self-contained. The outside paths stay
# as fallbacks for a host that still keeps its own working copy.
REC_DIR = os.path.abspath(os.environ.get("RECORDER_DIR") or next(
(p for p in (os.path.join(REPO, "recorder"),
os.path.join(REPO, "..", "Manual_Recorder"),
os.path.expanduser("~/Manual_Recorder"),
os.path.expanduser("~/Robotics_workspace/yslootahtech/Project/G1/G1_Lootah/"
"Manual_Recorder"))
if os.path.isfile(os.path.join(p, REC_SCRIPT))), ""))
# The recorder needs unitree_sdk2py, which on a workstation lives in the g1_env conda env —
# point RECORDER_PY at that interpreter if the default python3 cannot import it.
REC_PY = os.environ.get("RECORDER_PY") or "python3"
TAKES_DIR = os.path.join(REC_DIR, "DataG1") if REC_DIR else ""
JOINTS = ["pinky", "ring", "middle", "index", "thumb_bend", "thumb_rot"]
ARM_ACTIONS = {"shake hand": 27, "right hand up": 23, "hands up": 15, "high five": 18,
"hug": 19, "high wave": 26, "face wave": 25, "clap": 17, "heart": 20,
"reject": 22, "release arm": 99}
app = Flask(__name__)
app.config["MAX_CONTENT_LENGTH"] = 256 * 1024 * 1024 # take uploads; a 60Hz minute is ~4MB
def get_iface():
try:
v = open(IFACE_FILE).read().strip()
return v or "eth0"
except Exception:
return os.environ.get("IFACE", "eth0")
def sh(args, timeout=20):
try:
r = subprocess.run(args, capture_output=True, timeout=timeout)
return r.returncode, (r.stdout + r.stderr).decode(errors="ignore")
except Exception as e:
return 1, str(e)
def running(name):
return sh(["pgrep", "-x", name], timeout=5)[0] == 0
def iface_status(name):
_, out = sh(["ip", "-brief", "addr", "show", name], timeout=4)
m = re.search(r"(\d+\.\d+\.\d+\.\d+)", out)
return {"up": "UP" in out, "ip": m.group(1) if m else None}
def detected_ports():
"""Which tty did inspire_g1 open for each hand, per its startup log.
Handles BOTH log formats. The old one put them on one line:
Inspire hands detected: right=/dev/ttyUSB1 left=/dev/ttyUSB0
the current one splits them and adds the USB path, which the old single-line regex
could not match — so the dashboard showed a red "no ports" on a perfectly healthy
service:
Inspire hands identified by usb path order:
RIGHT = /dev/ttyUSB0 (usb 1-2.2.3, rs485 id 1)
LEFT = /dev/ttyUSB1 (usb 1-2.2.1.1, rs485 id 1)
"""
try:
txt = open("/tmp/inspire_g1.log").read()
except Exception:
return []
m = re.search(r"right=(\S+)\s+left=(\S+)", txt) # old single-line form
if m:
return [m.group(1), m.group(2)]
r = re.findall(r"^\s*RIGHT\s*=\s*(\S+)", txt, re.M) # current two-line form
l = re.findall(r"^\s*LEFT\s*=\s*(\S+)", txt, re.M)
if r and l:
return [r[-1], l[-1]]
return []
def send_pose(vals):
if len(vals) != 12:
return False, "need 12 values"
line = " ".join("%.3f" % float(v) for v in vals) + "\n"
try:
s = socket.create_connection((BRIDGE_HOST, BRIDGE_PORT), timeout=2)
s.sendall(line.encode()); r = s.recv(16); s.close()
return (b"ok" in r), r.decode(errors="ignore").strip()
except Exception as e:
return False, "bridge down: %s" % e
def read_state(want_force=False):
"""Ask the bridge for state. 'S' returns 24 values (12 angles + 12 forces); 'R' returns
the legacy 12 angles. Falls back automatically so an un-rebuilt bridge still works."""
def ask(cmd, n):
try:
s = socket.create_connection((BRIDGE_HOST, BRIDGE_PORT), timeout=2)
s.sendall(cmd); r = s.recv(512); s.close()
vals = [float(x) for x in r.decode().split()]
return vals if len(vals) == n else None
except Exception:
return None
if want_force:
v = ask(b"S\n", 24)
if v:
return v[:12], v[12:]
v = ask(b"R\n", 12)
return (v, None) if v else (None, None)
v = ask(b"S\n", 24)
if v:
return v[:12]
return ask(b"R\n", 12)
def run_arm(aid, timeout=15):
rc, out = sh([os.path.join(BUILD, "arm_action"), str(int(aid)), get_iface()], timeout=timeout)
return rc == 0, out[-200:]
def load_poses():
m = {}
if os.path.exists(POSES_FILE):
for line in open(POSES_FILE):
p = line.split()
if len(p) == 13 and not line.startswith("#"):
try: m[p[0]] = [float(x) for x in p[1:]]
except ValueError: pass
return m
def backup_file(path):
"""Keep the previous contents next to a library file before overwriting it.
Both libraries are single files rewritten IN FULL on every save, rename and delete, so
deleting the last entry silently empties the file. Five calibrated finger shapes were
lost exactly that way, with no copy anywhere on the robot to restore from. A .bak costs
a few KB and makes that one click recoverable.
"""
try:
if os.path.exists(path) and os.path.getsize(path) > 0:
with open(path, "rb") as a, open(path + ".bak", "wb") as b:
b.write(a.read())
except OSError:
pass
def write_poses(m):
backup_file(POSES_FILE)
with open(POSES_FILE, "w") as f:
f.write("# name <right:%s> <left:same>\n" % " ".join(JOINTS))
for k in sorted(m):
f.write(k + " " + " ".join("%.3f" % x for x in m[k]) + "\n")
def save_pose(name, vals):
m = load_poses(); m[name] = [float(v) for v in vals]; write_poses(m)
def load_combos():
try: return json.load(open(COMBOS_FILE))
except Exception: return {}
def write_combos(m):
backup_file(COMBOS_FILE)
json.dump(m, open(COMBOS_FILE, "w"), indent=2)
@app.route("/")
def index():
return Response(HTML, mimetype="text/html")
@app.route("/api/status")
def api_status():
ports, live = detected_ports(), sorted(glob.glob("/dev/ttyUSB*"))
mismatch = bool(ports) and any(p not in live for p in ports)
return jsonify(service=running("inspire_g1"), bridge=running("hand_bridge"),
ports=ports, live=live, mismatch=mismatch, iface=get_iface(),
eth0=iface_status("eth0"), wlan0=iface_status("wlan0"))
IN_CONTAINER = os.path.exists("/.dockerenv")
@app.route("/api/restart", methods=["POST"])
def api_restart():
"""Restart the RS-485 service.
In the container the ONLY correct move is to kill inspire_g1 and let docker/start.sh's
supervisor bring a fresh one up (~3s, with ClearError and the force limits re-applied),
because that supervisor owns the process.
manage.sh must not be used here. It is written for a host shell where nothing else
supervises: it stops the service and starts its own copy, so the supervisor's restart
and manage.sh's start race — two writers on one RS-485 bus, which is exactly how the
hands end up with latched actuator faults. Measured in the container: the call also
never returned, leaving inspire_g1 parented to a blocked manage.sh.
"""
if IN_CONTAINER:
sh(["pkill", "-x", "inspire_g1"], timeout=5)
for _ in range(30): # the supervisor polls every 3s
time.sleep(0.5)
if running("inspire_g1"):
break
return jsonify(ok=running("inspire_g1"), via="supervisor")
env = dict(os.environ); env["IFACE"] = get_iface()
subprocess.run([os.path.join(REPO, "manage.sh"), "restart"], capture_output=True, timeout=25, env=env)
return jsonify(ok=running("inspire_g1"), via="manage.sh")
@app.route("/api/iface", methods=["POST"])
def api_iface():
name = request.get_json(force=True).get("iface", "eth0")
if name not in ("eth0", "wlan0"):
return jsonify(ok=False, msg="eth0 or wlan0")
open(IFACE_FILE, "w").write(name)
sh(["pkill", "-x", "hand_bridge"], timeout=5); time.sleep(1)
subprocess.Popen(["setsid", os.path.join(BUILD, "hand_bridge"), name, str(BRIDGE_PORT)],
stdout=open("/tmp/hand_bridge.log", "w"), stderr=subprocess.STDOUT,
stdin=subprocess.DEVNULL)
time.sleep(1)
return jsonify(ok=True, iface=name)
@app.route("/api/apply", methods=["POST"])
def api_apply():
ok, msg = send_pose(request.get_json(force=True).get("q", []))
return jsonify(ok=ok, msg=msg)
@app.route("/api/release", methods=["POST"])
def api_release():
# NOT a release, despite the name. q<0 sends 0xFFFF, which the RH56 reads as "no change":
# the joint keeps its previous target and stays driven. There is no motor-release command,
# and the worm drive is non-backdrivable, so a finger can never be posed by hand. The UI
# button that promised this has been removed; the route stays so old callers don't 404.
ok, msg = send_pose([-1.0] * 12)
return jsonify(ok=ok, msg=msg, note="no-op: RH56 cannot release a finger")
ERR_BITS = [(0x01, "LOCKED-ROTOR"), (0x02, "OVER-TEMP"), (0x04, "OVER-CURRENT"),
(0x08, "ABNORMAL"), (0x10, "COMMS")]
STATUS_TXT = {0: "unclenching", 1: "grasping", 2: "reached pos", 3: "reached force",
5: "current prot", 6: "locked rotor", 7: "fault"}
def read_diag():
"""Full per-finger diagnostics from the bridge 'D' command: 8 blocks of 12 + publish rate."""
try:
s = socket.create_connection((BRIDGE_HOST, BRIDGE_PORT), timeout=2)
s.sendall(b"D\n")
buf = b""
while len(buf) < 4096 and b"\n" not in buf:
c = s.recv(4096)
if not c:
break
buf += c
s.close()
v = [float(x) for x in buf.decode().split()]
if len(v) < 97:
return None
b = [v[i * 12:(i + 1) * 12] for i in range(8)]
return dict(q=b[0], f=b[1], cur=b[2], fset=b[3], temp=b[4],
err=[int(x) for x in b[5]], sta=[int(x) for x in b[6]],
lost=[int(x) for x in b[7]], rate=v[96])
except Exception:
return None
@app.route("/api/diag")
def api_diag():
"""Everything the tracker needs in one call: angle, force, current, force-limit, temp,
ERROR bits, STATUS, lost counters and the measured publish rate."""
d = read_diag()
if not d:
q, f = read_state(want_force=True)
return jsonify(ok=q is not None, q=q, f=f, full=False)
d["err_txt"] = [" ".join(n for b, n in ERR_BITS if int(e) & b) or "" for e in d["err"]]
d["sta_txt"] = [STATUS_TXT.get(int(x), str(int(x))) for x in d["sta"]]
d["ok"] = True
d["full"] = True
return jsonify(d)
@app.route("/api/follow", methods=["POST"])
def api_follow():
"""Force-follow (admittance): press a finger and it moves under your hand.
The RH56 cannot be back-driven, so a press alone moves it 0.000. This reads the press and
drives the motor that way, which makes the ANGLE genuinely change — so what gets recorded
is a real measured angle instead of a grams-to-degrees guess. It also removes the
calibration problem: presses vary 5x between takes (41-110g one run, 48-527g the next), and
no fixed scale survives that. Watching the finger move and stopping when it looks right is
a closed loop with you in it.
"""
d = request.get_json(force=True)
on = 1 if d.get("on") else 0
return jsonify(**set_follow(on, d.get("gain", 0.0006), d.get("deadband", 60),
d.get("maxrate", 0.35), d.get("force", 400)))
FOLLOW_ON = False # tracked so a take can disarm it — both publish rt/inspire/cmd
def set_follow(on, gain=0.0006, deadband=60, maxrate=0.35, force=400):
global FOLLOW_ON
line = "F %d %s %s %s %s\n" % (1 if on else 0, gain, deadband, maxrate, force)
try:
s_ = socket.create_connection((BRIDGE_HOST, BRIDGE_PORT), timeout=2)
s_.sendall(line.encode()); r = s_.recv(64); s_.close()
FOLLOW_ON = bool(on)
return {"ok": True, "on": bool(on), "msg": r.decode(errors="ignore").strip()}
except Exception as e:
return {"ok": False, "msg": "bridge down: %s" % e}
@app.route("/api/track")
def api_track():
"""Angle AND force in one call, for the live joint tracker.
Both are needed together: a finger you press shows force but NOT angle, because the RH56
drive is non-backdrivable. Seeing the two columns side by side is what makes that obvious
instead of looking like a broken sensor.
"""
q, f = read_state(want_force=True)
return jsonify(ok=q is not None, q=q, f=f, has_force=f is not None)
@app.route("/api/capture")
def api_capture(): # read the current (hand-set) joint positions
v = read_state()
# Per-hand liveness. hand_bridge caches the last DDS state with no timestamp, so if
# inspire_g1 dies or one RS-485 link drops, it keeps serving the last values forever and
# the dashboard's "reads live" lamp stays green over a completely dead hand. A hand whose
# six joints are ALL exactly 0.000 is not a real pose — it is an unread bus.
live = [True, True]
if v:
for h, lo in ((0, 0), (1, 6)):
live[h] = any(abs(x) > 1e-9 for x in v[lo:lo + 6])
return jsonify(ok=v is not None, q=v, live_right=live[0], live_left=live[1])
@app.route("/api/selftest", methods=["POST"])
def api_selftest(): # close -> read -> open -> read; report movement + read health
send_pose([0.0] * 12); time.sleep(2.0)
closed = read_state() or [0.0] * 12
send_pose([1.0] * 12); time.sleep(2.0)
opened = read_state() or [0.0] * 12
moved = any(abs(closed[i] - opened[i]) > 0.05 for i in range(12))
reads_ok = any(abs(v) > 0.001 for v in closed + opened)
return jsonify(closed=closed, opened=opened, moved=moved, reads_ok=reads_ok)
@app.route("/api/armlist")
def api_armlist():
return jsonify(ARM_ACTIONS)
@app.route("/api/arm", methods=["POST"])
def api_arm():
ok, out = run_arm(request.get_json(force=True).get("id"))
return jsonify(ok=ok, msg=out)
@app.route("/api/combo/play", methods=["POST"])
def api_combo_play():
d = request.get_json(force=True)
q, arm, release = d.get("q"), d.get("arm"), d.get("release", False)
if q: send_pose(q)
if arm not in (None, "", -1):
ok, msg = run_arm(arm)
if q: send_pose(q)
if release: run_arm(99)
return jsonify(ok=ok, msg=msg)
return jsonify(ok=True, msg="pose applied")
@app.route("/api/poses")
def api_poses():
return jsonify(load_poses())
@app.route("/api/save", methods=["POST"])
def api_save():
d = request.get_json(force=True)
name, q = d.get("name", "").strip(), d.get("q", [])
if not name or len(q) != 12:
return jsonify(ok=False, msg="name + 12 values")
save_pose(name, q); return jsonify(ok=True)
@app.route("/api/combos", methods=["GET", "POST"])
def api_combos():
if request.method == "GET":
return jsonify(load_combos())
d = request.get_json(force=True); name = d.get("name", "").strip()
if not name: return jsonify(ok=False, msg="name required")
m = load_combos()
m[name] = {"q": d.get("q", []), "arm": d.get("arm"), "release": bool(d.get("release", True))}
write_combos(m)
return jsonify(ok=True)
@app.route("/api/library/restore", methods=["POST"])
def api_library_restore():
"""Put back the shape (or combo) library as it was before the last write."""
which = request.get_json(force=True).get("what", "poses")
path = POSES_FILE if which == "poses" else COMBOS_FILE
if not os.path.exists(path + ".bak"):
return jsonify(ok=False, msg="no backup yet (one is written on every save/delete)")
# READ the backup before writing one, or the "make restore undoable" step overwrites
# the very copy being restored — which is a restore that silently returns nothing.
with open(path + ".bak", "rb") as a:
saved = a.read()
backup_file(path) # current becomes the new .bak: restore is undoable
with open(path, "wb") as b:
b.write(saved)
n = len(load_poses() if which == "poses" else load_combos())
return jsonify(ok=True, count=n)
@app.route("/api/pose/delete", methods=["POST"])
def api_pose_delete():
name = request.get_json(force=True).get("name", "")
m = load_poses()
if name in m:
del m[name]; write_poses(m); return jsonify(ok=True)
return jsonify(ok=False, msg="not found")
@app.route("/api/pose/rename", methods=["POST"])
def api_pose_rename():
d = request.get_json(force=True)
old, new = d.get("old", ""), d.get("new", "").strip()
m = load_poses()
if old in m and new and new not in m:
m[new] = m.pop(old); write_poses(m); return jsonify(ok=True)
return jsonify(ok=False, msg="bad/duplicate name")
@app.route("/api/combo/delete", methods=["POST"])
def api_combo_delete():
name = request.get_json(force=True).get("name", "")
m = load_combos()
if name in m:
del m[name]; write_combos(m); return jsonify(ok=True)
return jsonify(ok=False, msg="not found")
@app.route("/api/combo/rename", methods=["POST"])
def api_combo_rename():
d = request.get_json(force=True)
old, new = d.get("old", ""), d.get("new", "").strip()
m = load_combos()
if old in m and new and new not in m:
m[new] = m.pop(old); write_combos(m); return jsonify(ok=True)
return jsonify(ok=False, msg="bad/duplicate name")
# ---- record / replay ------------------------------------------------------------------
ANSI = re.compile(r"\x1b\[[0-9;?]*[A-Za-z]")
# The recorder asks these two on stdin at the end of a take, and both are answered by the
# panel rather than by a human at a terminal.
PROMPT_PREVIEW = "Preview this recording"
PROMPT_SAVE = "Save recording?"
# Goes on a child process argv (never through a shell), so the only job here is to keep the
# name inside DataG1/. Parentheses are allowed because the recorder ITSELF names collisions
# that way — unique_path() writes grip1(1).jsonl — and without them those takes could not be
# listed, renamed, replayed or deleted from the panel.
SAFE_NAME = re.compile(r"^[A-Za-z0-9._()-]{1,64}$")
TAKE = None
TAKE_LOCK = threading.Lock()
class Take:
"""One recorder run, driven over a pty.
A pty and not a pipe: the recorder puts stdin in cbreak mode (tty.setcbreak) to read the
in-take keys, which raises on a pipe and would kill the take before the arms ever went
limp. The pty is also what lets this panel send those same keys — 1-9 shapes, o/c/[/]/;/'
— which is the only way to get a finger pose into a recording, since the RH56 cannot be
posed by hand.
"""
MAXLINES = 300
KEY_GAP = 0.15 # see _writer
def __init__(self, argv, action, seconds):
self.argv, self.action, self.seconds = argv, action, seconds
# The console opens with the command itself, so the log doubles as a receipt of
# exactly what ran — the same line you could paste into a terminal.
self.lines, self.cur, self.pending = ["$ " + cmd_str(argv)], "", ""
self._cr = False # a CR held over, waiting to see if an LF follows
self._tail = "" # rolling raw text, for the markers below
self.replaying = False # inside replay() — pause only applies there
self.paused = False
self.phase = "running"
self.rc = None
self.t0 = time.time()
self.lock = threading.Lock()
self.mfd, sfd = pty.openpty()
env = dict(os.environ, PYTHONUNBUFFERED="1", TERM="dumb")
# start_new_session: stop() signals the whole group, so the SIGINT reaches the
# recorder even once it has spawned DDS threads of its own.
self.proc = subprocess.Popen(argv, cwd=REC_DIR, stdin=sfd, stdout=sfd, stderr=sfd,
start_new_session=True, close_fds=True, env=env)
os.close(sfd)
self.keyq = queue.Queue(maxsize=64)
threading.Thread(target=self._pump, daemon=True).start()
threading.Thread(target=self._writer, daemon=True).start()
def _pump(self):
while True:
try:
data = os.read(self.mfd, 4096)
except OSError: # master closes when the child exits
break
if not data:
break
self._feed(data.decode("utf-8", "replace"))
self.rc = self.proc.wait()
with self.lock:
if self.cur:
self._push()
self.phase = "done"
self.keyq.put(None)
try:
os.close(self.mfd)
except OSError:
pass
def _writer(self):
"""Send keys ONE AT A TIME, ~KEY_GAP apart.
The recorder reads keys with a select() followed by sys.stdin.read(1), and Python's
stdin is buffered: a burst all lands in that buffer at once, select() then reports
nothing ready, and every key after the first is stranded there until the next one
arrives. Measured against the real read loop: three keys 10ms apart delivered two,
the same three 200ms apart delivered all three. Clicking two shape buttons quickly
must not silently drop one, so the pacing lives here rather than in the browser.
"""
while True:
item = self.keyq.get()
if item is None:
return
if not self.alive():
continue
try:
os.write(self.mfd, item)
except OSError:
return
time.sleep(self.KEY_GAP)
def _push(self):
self.lines.append(self.cur)
self.cur = ""
if len(self.lines) > self.MAXLINES:
del self.lines[:len(self.lines) - self.MAXLINES]
def _feed(self, txt):
with self.lock:
self.pending += txt
# A pty turns every \n into \r\n on the way out, so a CR is only a line-redraw
# (the recorder's \r progress counters) when no LF follows it. Treating them all
# as redraws wiped the content of every finished line.
for ch in ANSI.sub("", txt):
if self._cr:
self._cr = False
if ch == "\n":
self._push()
continue
self.cur = "" # bare CR: the line redraws itself in place
if ch == "\r":
self._cr = True
elif ch == "\n":
self._push()
else:
self.cur += ch
# Both prompts arrive WITHOUT a trailing newline, so they never reach self.lines
# — they have to be spotted in the raw stream. pending is cleared on every
# answer so the same prompt is not re-detected after it has been dealt with.
if PROMPT_SAVE in self.pending:
self.phase = "save?"
elif PROMPT_PREVIEW in self.pending:
self.phase = "preview?"
# Pause/resume state is READ BACK from the recorder rather than assumed from the
# click, so the button cannot drift out of step with the process — and it also
# tracks a pause typed at the terminal. A rolling tail is used because a marker
# can land across two reads.
self._tail = (self._tail + txt)[-400:]
if "REPLAY" in self._tail:
self.replaying, self.paused = True, False
self._tail = ""
elif "replay done" in self._tail or "replay stopped" in self._tail:
self.replaying, self.paused = False, False
self._tail = ""
elif "PAUSED" in self._tail:
self.paused = True
self._tail = ""
elif "resumed" in self._tail:
self.paused = False
self._tail = ""
def alive(self):
return self.proc.poll() is None
def snapshot(self, n=20):
with self.lock:
tail = [l for l in self.lines[-n:]]
if self.cur:
tail.append(self.cur)
return {"phase": self.phase, "lines": tail[-n:], "rc": self.rc,
"action": self.action, "seconds": self.seconds,
"replaying": self.replaying, "paused": self.paused,
"elapsed": round(time.time() - self.t0, 1)}
def key(self, k):
"""Relay one in-take key. Never a newline: ask() at the end of a take counts a bare
Enter as YES, so a stray one would preview (arm moves) or save a discarded take."""
k = k.replace("\r", "").replace("\n", "")[:1]
if not k or not self.alive():
return False
try:
self.keyq.put_nowait(k.encode())
except queue.Full:
return False
return True
def answer(self, yes):
if self.phase not in ("preview?", "save?") or not self.alive():
return False
with self.lock:
self.pending = ""
self.phase = "running"
# Through the same queue as the keys, so an answer can never overtake a key that is
# still waiting its turn.
self.keyq.put(b"y\n" if yes else b"n\n")
return True
def stop(self, hard=False):
"""SIGINT, not SIGKILL. The recorder catches KeyboardInterrupt, walks the arm home
and releases it; killing it outright abandons the arm limp, mid-air."""
if not self.alive():
return
sig = signal.SIGKILL if hard else signal.SIGINT
try:
os.killpg(os.getpgid(self.proc.pid), sig)
except Exception:
self.proc.send_signal(sig)
def rec_ready():
return bool(REC_DIR) and os.path.isfile(os.path.join(REC_DIR, REC_SCRIPT))
def sigint_ok():
"""Can Stop actually interrupt a take?
A process started in the background by a non-interactive shell inherits SIGINT as
SIG_IGN, and unlike a handler, SIG_IGN SURVIVES EXEC — so the recorder would inherit it
and Stop would do nothing at all. manage.sh starts this dashboard exactly that way
(setsid ... &), so it is the normal case, not the odd one. fix_sigint() below repairs it;
this reports whether the repair held, and the panel says so if it did not.
"""
return signal.getsignal(signal.SIGINT) != signal.SIG_IGN
def fix_sigint():
"""Swap an inherited SIG_IGN for a no-op HANDLER (main thread only).
A handler is reset to SIG_DFL in the child on exec, which is what makes Stop work, while
the dashboard itself stays as un-killable by SIGINT as it was before.
"""
if not sigint_ok():
signal.signal(signal.SIGINT, lambda *_a: None)
# DataG1/ also holds the arm home pose, which every take returns through on its way out
# (--home). It is not a recording: listing it invites a replay, and deleting it would break
# the return-to-home of every take there is.
NOT_A_TAKE = ("arm_home",)
def take_path(name):
"""Resolve a take name inside DataG1/, or None. Containment-checked: the name reaches
here from a browser and ends up on an argv and an unlink()."""
if not TAKES_DIR or not SAFE_NAME.match(name or "") or name in NOT_A_TAKE:
return None
p = os.path.abspath(os.path.join(TAKES_DIR, name + ".jsonl"))
return p if p.startswith(os.path.abspath(TAKES_DIR) + os.sep) else None
def num(d, key, default, lo, hi):
try:
v = float(d.get(key, default))
except (TypeError, ValueError):
return default
return lo if v < lo else (hi if v > hi else v)
def rec_argv(d, action):
"""Build the recorder command line.
ONE builder, used by both /api/rec/start and /api/rec/preview, so the command the panel
shows is the command that runs — there is no second copy of this logic to drift. The
panel is a front-end for the CLI, not a reimplementation of it: every option here is a
flag you could type yourself, and a take made either way lands in the same DataG1/.
"""
name = str(d.get("name") or "take").strip()
argv = [REC_PY, REC_SCRIPT, get_iface(), action]
if action == "record":
argv += ["--output", name,
"--seconds", "%g" % num(d, "seconds", 20, 1, 600),
"--thresh", "%g" % num(d, "thresh", 12, 1, 500),
"--span", "%g" % num(d, "span", 120, 5, 2000),
"--fingers", (d.get("fingers") if d.get("fingers") in
("touch", "position", "external", "follow") else "touch"),
# Shapes come from THIS dashboard, so keys 1-9 fire the same poses the
# sliders saved. The CLI default points at another host entirely.
"--shapes", "http://127.0.0.1:%d" % PORT]
else:
argv += ["--input", name, "--speed", "%g" % num(d, "speed", 1.0, 0.1, 3.0)]
if d.get("waist") == "lock":
argv += ["--waist", "lock"]
# Arm and hand are independent on BOTH actions: replay honours them too (the recorder
# builds its DDS client from use_arm/use_hand before either branch runs), so a take with
# both tracks can be replayed hand-only onto an arm you would rather not move.
if d.get("no_arm"):
argv += ["--no-arm"]
if d.get("no_hand"):
argv += ["--no-hand"]
return argv
def cmd_str(argv):
"""The same command as you would type it, cwd included — the panel prints this."""
return "cd %s && %s" % (shlex.quote(REC_DIR or "?"), " ".join(shlex.quote(a) for a in argv))
@app.route("/api/rec/status")
def api_rec_status():
t = TAKE
d = {"ok": True, "ready": rec_ready(), "dir": REC_DIR, "sigint": sigint_ok(),
"busy": bool(t and t.alive()), "follow": FOLLOW_ON}
if t:
d.update(t.snapshot())
return jsonify(d)
@app.route("/api/rec/start", methods=["POST"])
def api_rec_start():
"""Start a take (or a replay) as a child process.
Refuses to run two at once — both drive the arm over the same DDS topics, and the
second one would fight the first for every joint.
"""
global TAKE
d = request.get_json(force=True)
action = "replay" if d.get("action") == "replay" else "record"
name = str(d.get("name") or "take").strip()
if not rec_ready():
return jsonify(ok=False, msg="%s not found — set RECORDER_DIR" % REC_SCRIPT)
if not SAFE_NAME.match(name):
return jsonify(ok=False, msg="name: letters, digits, dot, dash, underscore only")
no_arm, no_hand = bool(d.get("no_arm")), bool(d.get("no_hand"))
if no_arm and no_hand:
return jsonify(ok=False, msg="--no-arm and --no-hand together records nothing")
if action == "replay":
# An arm-less replay still drives the hand, which is why the confirm is only
# required when the arm is actually in play.
if not no_arm and not d.get("confirm"):
return jsonify(ok=False, msg="replay moves the arm — confirm required")
if not take_path(name) or not os.path.exists(take_path(name)):
return jsonify(ok=False, msg="no take named %s" % name)
with TAKE_LOCK:
if TAKE and TAKE.alive():
return jsonify(ok=False, msg="a %s is already running" % TAKE.action)
# Force-follow publishes rt/inspire/cmd at 30Hz from the bridge, and so does the
# recorder. Left armed, the two would overwrite each other every frame and the
# fingers would judder — so the take disarms it and says it did.
disarmed = False
if FOLLOW_ON and not no_hand:
disarmed = set_follow(0).get("ok", False)
argv = rec_argv(d, action)
secs = num(d, "seconds", 20, 1, 600) if action == "record" else None
try:
TAKE = Take(argv, action, secs)
except Exception as e:
return jsonify(ok=False, msg="spawn failed: %s" % e)
return jsonify(ok=True, action=action, argv=argv, cmd=cmd_str(argv),
disarmed_follow=disarmed)
@app.route("/api/rec/preview", methods=["POST"])
def api_rec_preview():
"""The exact command Record/Replay would run, with nothing started.
This is the panel's honesty check: what it prints is what it spawns, because both come
out of rec_argv().
"""
d = request.get_json(force=True)
action = "replay" if d.get("action") == "replay" else "record"
argv = rec_argv(d, action)
return jsonify(ok=True, argv=argv, cmd=cmd_str(argv), ready=rec_ready())
@app.route("/api/rec/key", methods=["POST"])
def api_rec_key():
t = TAKE
if not (t and t.alive()):
return jsonify(ok=False, msg="nothing running")
return jsonify(ok=t.key(str(request.get_json(force=True).get("k", ""))))
@app.route("/api/rec/answer", methods=["POST"])
def api_rec_answer():
"""Answer the recorder's end-of-take prompts explicitly.
Both are answered with a literal y or n and never a bare newline, because ask() treats
an empty line as YES — and the first prompt is 'preview', which moves the arm.
"""
t = TAKE
if not (t and t.alive()):
return jsonify(ok=False, msg="nothing running")
return jsonify(ok=t.answer(bool(request.get_json(force=True).get("yes"))), phase=t.phase)
@app.route("/api/rec/stop", methods=["POST"])
def api_rec_stop():
t = TAKE
if not (t and t.alive()):
return jsonify(ok=False, msg="nothing running")
t.stop(hard=bool(request.get_json(force=True).get("hard")))
return jsonify(ok=True)
@app.route("/api/takes")
def api_takes():
out = []
for p in sorted(glob.glob(os.path.join(TAKES_DIR, "*.jsonl")) if TAKES_DIR else []):
if os.path.basename(p)[:-6] in NOT_A_TAKE:
continue
info = {"name": os.path.basename(p)[:-6], "kb": round(os.path.getsize(p) / 1024.0),
"mtime": int(os.path.getmtime(p)), "frames": None}
try:
with open(p, "rb") as f:
meta = json.loads(f.readline()).get("meta", {})
# Count the rest in chunks — a 60Hz take is thousands of lines and there is
# no reason to parse any of them just to list the file.
n = sum(c.count(b"\n") for c in iter(lambda: f.read(1 << 20), b""))
hz = float(meta.get("hz") or 0)
info.update(frames=n, hz=round(hz, 1), arm=bool(meta.get("arm")),
hand=bool(meta.get("hand")),
secs=round(n / hz, 1) if hz > 0 else None)
except Exception:
pass
out.append(info)
# now: so the page can age a take against the ROBOT's clock. Comparing mtime to the
# browser's clock would mislabel takes whenever the two hosts disagree.
return jsonify(ok=True, ready=rec_ready(), dir=TAKES_DIR, takes=out, now=int(time.time()),
total_kb=round(sum(t["kb"] for t in out)))
@app.route("/api/take/delete", methods=["POST"])
def api_take_delete():
d = request.get_json(force=True)
if d.get("all"):
# Wipes the library. The token is required so that a stray {"all": true} from a
# script or a mis-click cannot erase recordings that may be impossible to redo —
# and the list is re-read here rather than taken from the browser, so whatever the
# page happened to be showing does not decide what gets deleted.
if d.get("confirm") != "DELETE ALL":
return jsonify(ok=False, msg='delete-all needs confirm:"DELETE ALL"')
names = [os.path.basename(p)[:-6]
for p in glob.glob(os.path.join(TAKES_DIR, "*.jsonl"))] if TAKES_DIR else []
names = [n for n in names if n not in NOT_A_TAKE]
if not names:
return jsonify(ok=False, msg="no takes to delete")
else:
names = d.get("names") or [d.get("name", "")]
gone, bad = [], []
for n in names[:500]:
p = take_path(str(n))
if p and os.path.exists(p):
os.remove(p); gone.append(n)
else:
bad.append(n)
return jsonify(ok=bool(gone), deleted=gone, msg=("no such take: %s" % ", ".join(bad)) if bad else "")
@app.route("/api/take/duplicate", methods=["POST"])
def api_take_duplicate():
"""Copy a take under a free name — the safe way to try a destructive edit (a trim, a
rename scheme) without risking the only copy of a pose you cannot easily re-record."""
d = request.get_json(force=True)
src = take_path(str(d.get("name", "")))
if not src or not os.path.exists(src):
return jsonify(ok=False, msg="no such take")
stem = os.path.basename(src)[:-6]
for i in range(1, 100): # same naming the recorder uses: name(1)
dst = take_path("%s(%d)" % (stem, i))
if dst and not os.path.exists(dst):
break
else:
return jsonify(ok=False, msg="too many copies")
with open(src, "rb") as a, open(dst, "wb") as b:
b.write(a.read())
return jsonify(ok=True, name=os.path.basename(dst)[:-6])
@app.route("/api/take/download")
def api_take_download():
"""Send a take to the browser.
Hand-rolled rather than send_file(): the robot's container ships Flask 1.1, where the
keyword is attachment_filename, while newer Flask calls it download_name. Takes are a
couple of MB, so reading one into memory costs nothing and the endpoint works on both.
"""
name = request.args.get("name", "")
p = take_path(name)
if not p or not os.path.exists(p):
return jsonify(ok=False, msg="no such take"), 404
with open(p, "rb") as f:
data = f.read()
return Response(data, mimetype="application/x-ndjson",
headers={"Content-Disposition": 'attachment; filename="%s.jsonl"' % name})
@app.route("/api/take/upload", methods=["POST"])
def api_take_upload():
"""Bring a take recorded elsewhere (e.g. from the workstation) into DataG1/.
Validated, not trusted: the name is re-derived from the upload, and the first line must
parse as the recorder's own meta header — otherwise a stray file would sit in the list
looking like a take until someone pressed replay on it.
"""
f = request.files.get("file")
if not f:
return jsonify(ok=False, msg="no file")
stem = os.path.basename(f.filename or "")
stem = stem[:-6] if stem.endswith(".jsonl") else stem
if not SAFE_NAME.match(stem) or stem in NOT_A_TAKE:
return jsonify(ok=False, msg="name must be letters/digits/._- and not %s" % NOT_A_TAKE[0])
data = f.read()
try:
head = json.loads(data.split(b"\n", 1)[0])
if "meta" not in head:
raise ValueError("no meta header")
except Exception as e:
return jsonify(ok=False, msg="not a recording (%s)" % e)
dst = take_path(stem)
if not dst:
return jsonify(ok=False, msg="bad name")
if os.path.exists(dst):
if not request.form.get("overwrite"):
return jsonify(ok=False, msg="%s already exists" % stem)
with open(dst, "wb") as out:
out.write(data)
return jsonify(ok=True, name=stem, bytes=len(data))
@app.route("/api/take/rename", methods=["POST"])
def api_take_rename():
d = request.get_json(force=True)
old, new = take_path(str(d.get("old", ""))), take_path(str(d.get("new", "")))
if not old or not os.path.exists(old):
return jsonify(ok=False, msg="no such take")
if not new or os.path.exists(new):
return jsonify(ok=False, msg="bad or duplicate name")
os.rename(old, new)
return jsonify(ok=True)
HTML = r"""<!doctype html><html><head><meta charset=utf-8>
<meta name=viewport content="width=device-width,initial-scale=1"><title>Inspire Hand</title>
<style>
:root{--bg:#0a0f1c;--card:#111a2e;--card2:#0e1626;--fg:#e6edf7;--mut:#7e93b6;--acc:#e453a6;--acc2:#6ea8fe;--line:#1f2b45;--ok:#3fdda0;--bad:#ff6b7d;--warn:#ffcf5c}
*{box-sizing:border-box}
body{margin:0;background:radial-gradient(1200px 600px at 20% -10%,#152241 0,var(--bg) 55%);color:var(--fg);font:14px/1.45 ui-sans-serif,system-ui,Segoe UI,Roboto,sans-serif;padding:16px;max-width:1000px;margin:auto}
h2{font-size:13px;letter-spacing:.08em;text-transform:uppercase;color:var(--mut);margin:0 0 12px;font-weight:600}
.card{background:linear-gradient(180deg,var(--card),var(--card2));border:1px solid var(--line);border-radius:14px;padding:16px 18px;margin:14px 0;box-shadow:0 8px 30px rgba(0,0,0,.25)}
.top{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
.title{font-size:18px;font-weight:700;margin-right:auto}
.title small{color:var(--mut);font-weight:400;font-size:12px}
.pill{display:inline-flex;align-items:center;gap:6px;background:#0c1526;border:1px solid var(--line);border-radius:999px;padding:5px 11px;font-size:12px;color:var(--mut)}
.dot{width:9px;height:9px;border-radius:50%;background:var(--mut);box-shadow:0 0 8px transparent}
.dot.ok{background:var(--ok);box-shadow:0 0 10px var(--ok)}
.dot.bad{background:var(--bad);box-shadow:0 0 10px var(--bad)}
.dot.warn{background:var(--warn);box-shadow:0 0 10px var(--warn)}
.grid{display:grid;grid-template-columns:1fr 1fr;gap:14px}
@media(max-width:760px){.grid{grid-template-columns:1fr}}
.row{display:flex;align-items:center;gap:12px;margin:7px 0}
.lab{width:82px;color:var(--mut);font-size:13px}
input[type=range]{-webkit-appearance:none;flex:1;height:6px;border-radius:6px;background:linear-gradient(90deg,var(--acc) var(--f,100%),#22304c var(--f,100%));outline:none}
input[type=range]::-webkit-slider-thumb{-webkit-appearance:none;width:18px;height:18px;border-radius:50%;background:#fff;border:2px solid var(--acc);cursor:pointer;box-shadow:0 2px 6px rgba(0,0,0,.4)}
.val{width:40px;text-align:right;color:var(--mut);font-variant-numeric:tabular-nums;font-size:13px}
button{background:#17233c;color:var(--fg);border:1px solid #2b3c5e;border-radius:9px;padding:8px 13px;font:inherit;cursor:pointer;transition:.12s}
button:hover{background:#1e2e4c;border-color:#3a4f78}
button.acc{background:linear-gradient(180deg,#e453a6,#c53c8c);border-color:#e453a6;color:#fff;font-weight:600}
button.gho{background:transparent}
.bar{display:flex;gap:8px;flex-wrap:wrap;align-items:center}
input[type=text],select{background:#0c1526;color:var(--fg);border:1px solid #2b3c5e;border-radius:9px;padding:8px 11px;font:inherit}
label{color:var(--mut);font-size:13px;display:inline-flex;gap:6px;align-items:center}
#msg{margin-left:auto;color:var(--mut);font-size:13px}
.warnbar{display:none;background:#3a2a12;border:1px solid #6b501c;color:var(--warn);border-radius:9px;padding:8px 12px;margin-top:10px;font-size:13px}
.sec{margin:16px 0 6px;color:var(--acc2);font-weight:600;font-size:13px}
.log{background:#070d18;border:1px solid var(--line);border-radius:9px;padding:10px 12px;margin:10px 0 0;height:210px;overflow:auto;white-space:pre-wrap;word-break:break-word;font:12px/1.4 ui-monospace,SFMono-Regular,Menlo,monospace;color:#b9c8e0}
.askbar{background:#3a2a12;border:1px solid #6b501c;border-radius:9px;padding:8px 12px;margin-top:10px}
.kbtn.on{background:linear-gradient(180deg,#e453a6,#c53c8c);border-color:#e453a6;color:#fff;font-weight:600}
.kbtn.dim{opacity:.4}
input[type=number]{background:#0c1526;color:var(--fg);border:1px solid #2b3c5e;border-radius:9px;padding:8px 11px;font:inherit;width:72px}
</style></head><body>
<div class="card top">
<div class=title>Inspire Hand <small>fingers + arm</small></div>
<span class=pill><span class=dot id=d_svc></span>service</span>
<span class=pill><span class=dot id=d_br></span>bridge</span>
<span class=pill><span class=dot id=d_port></span><span id=t_port>ports</span></span>
<span class=pill><span class=dot id=d_eth></span>eth0</span>
<span class=pill><span class=dot id=d_wifi></span>wifi</span>
<select id=iface title="DDS interface (arm needs eth0)"><option value=eth0>eth0</option><option value=wlan0>wifi</option></select>
<button class=gho onclick=restart()>⟳ Restart</button>
</div>
<div class=warnbar id=warn></div>
<div class=card>
<h2>Fingers <span style="color:var(--mut);text-transform:none;letter-spacing:0">— arm does not move</span></h2>
<div class=bar style="margin-bottom:8px">
<button onclick="preset(1)">Open</button><button onclick="preset(0)">Close</button>
<button onclick="box()">Box</button><button onclick="test()">Open/close test</button>
<span id=msg>ready</span>
</div>
<div class=grid>
<div><div class=sec>right</div><div id=sr></div></div>
<div><div class=sec>left</div><div id=sl></div></div>
</div>
<div class=bar style="margin-top:14px">
<button class=acc onclick=apply()>Apply pose</button>
<button onclick="preset(1)">All open</button><button onclick="preset(0)">All closed</button>
<input id=pname type=text placeholder="shape name" size=12>
<button onclick=savePose()>Save shape</button>
<select id=plist onchange=loadPose()><option value="">load shape…</option></select>
</div>
</div>
<div class=card>
<h2>Force follow <span style="color:var(--mut);text-transform:none;letter-spacing:0">— press a finger and it moves under your hand</span></h2>
<div class=bar>
<button class=acc id=folbtn onclick=toggleFollow()>▶ Arm follow</button>
<span class=pill><span class=dot id=d_fol></span><span id=t_fol>off</span></span>
<label>sensitivity <input id=folgain type=range min=2 max=20 value=6 style="width:110px"></label>
<label>deadband <input id=foldead type=range min=20 max=200 step=10 value=60 style="width:110px"><span id=foldeadv>60g</span></label>
<span id=folmsg style="color:var(--mut)"></span>
</div>
<div style="color:var(--mut);font-size:12px;margin-top:8px">
Arm it, then push a fingertip pad — that finger closes while you push and holds when you
stop. Raise <b>deadband</b> if fingers drift from incidental contact; raise
<b>sensitivity</b> if you have to push too hard.<br>
Only publishes while armed, so it never fights the sliders or a recording.
<b>Safety:</b> a finger closes while you keep pushing — capped at ~3s for full travel,
grip force 400g. Push with a knuckle first.
</div>
</div>
<div class=card>
<h2>Joint tracker <span style="color:var(--mut);text-transform:none;letter-spacing:0">— watch which joints actually CHANGE</span></h2>
<div class=bar>
<button class=acc id=trkbtn onclick=toggleTrack()>▶ Start tracking</button>
<button onclick=resetTrack()>Reset</button>
<button onclick=exportTrack()>⬇ Export CSV</button>
<span class=pill><span class=dot id=d_trk></span><span id=t_trk>off</span></span>
<span id=trkmsg style="color:var(--mut)"></span>
</div>
<div class=grid style="margin-top:10px">
<div><div class=sec>right</div><div id=trkr></div></div>
<div><div class=sec>left</div><div id=trkl></div></div>
</div>
<div style="color:var(--mut);font-size:12px;margin-top:10px">
Trace = angle over time (fixed 0..1 scale, so flat really means flat).<br>Row 1 = angle · Row 2 = Δbase force, current, force limit, temp, ERROR/STATUS.<br>the exact signal the recorder thresholds on (amber above 12g = a touch it would capture).<br>
Pressing a finger moves FORCE but not ANGLE — the drive is non-backdrivable, so contact
cannot bend a joint. Only a command changes the angle.
</div>
</div>
<div class=card>
<h2>Diagnostics <span style="color:var(--mut);text-transform:none;letter-spacing:0">— live read-back &amp; self-test</span></h2>
<div class=bar>
<button class=acc onclick=selftest()>Run self-test (close → open)</button>
<span class=pill><span class=dot id=d_read></span><span id=t_read>reads</span></span>
<span id=selfmsg style="color:var(--mut)"></span>
</div>
<div class=grid style="margin-top:8px">
<div><div class=sec>right (actual)</div><div id=ar></div></div>
<div><div class=sec>left (actual)</div><div id=al></div></div>
</div>
</div>
<div class=card>
<h2>Read back <span style="color:var(--mut);text-transform:none;letter-spacing:0">— copy the hand's current pose into the sliders</span></h2>
<div class=bar>
<button class=acc onclick=captureHand()>Capture from hand → sliders</button>
<button onclick=regrip()>Re-grip (hold)</button>
<input id=rname type=text placeholder="shape name" size=12>
<button onclick=saveCaptured()>Save captured</button>
</div>
</div>
<div class=card>
<h2>Arm + hand combo <span style="color:var(--warn);text-transform:none;letter-spacing:0">⚠ arm moves</span></h2>
<div class=bar>
<select id=armsel></select>
<button class=acc onclick="playArm(true)">Play arm + pose</button>
<button onclick=releaseArm()>Release arm</button>
</div>
<div class=bar style="margin-top:12px">
<input id=cname type=text placeholder="combo name" size=12>
<label><input type=checkbox id=crel checked> release after</label>
<button onclick=saveCombo()>Save combo</button>
<select id=clist onchange=playCombo()><option value="">play combo…</option></select>
</div>
</div>
<div class=card>
<h2>Record / replay <span style="color:var(--warn);text-transform:none;letter-spacing:0">⚠ arm moves</span></h2>
<div class=bar>
<button class=acc id=recbtn onclick=startRec()>● Record</button>
<button id=recstop onclick="stopRec()" disabled>■ Stop</button>
<button id=recpause onclick="pauseRec()" style="display:none">⏸ Pause</button>
<input id=recname type=text placeholder="take name" size=10 value="take">
<label>secs <input id=recsecs type=number min=1 max=600 value=20></label>
<label>fingers
<select id=recfing title="how finger movement gets into the take">
<option value=touch>touch — press to set closure</option>
<option value=position>position — keys drive, angle recorded</option>
<option value=external>external — dashboard sliders drive</option>
<option value=follow>follow — push a fingertip to shape it</option>
</select></label>
<label title="record the arm joints (--no-arm when off)"><input type=checkbox id=recarm checked> arm</label>
<label title="record the fingers (--no-hand when off)"><input type=checkbox id=rechand checked> hand</label>
<span class=pill><span class=dot id=d_rec></span><span id=t_rec>idle</span></span>
<span id=recwarn style="color:var(--warn);font-size:12px"></span>
</div>
<div class=bar id=reckeys style="margin-top:9px">
<span style="color:var(--mut);font-size:12px;width:72px">in-take keys</span>
<button class=kbtn data-k="o" title="both hands to rest">o open</button>
<button class=kbtn data-k="c" title="both hands closed">c close</button>
<button class=kbtn data-k="[">[ R open</button>
<button class=kbtn data-k="]">] R close</button>
<button class=kbtn data-k=";">; L open</button>
<button class=kbtn data-k="'">' L close</button>
<button class=kbtn data-k="f" title="force-follow arm/disarm — fingers=follow only">f follow</button>
<span class=pill><span class=dot id=d_keys></span><span id=t_keys>R rest · L rest</span></span>
</div>
<div class=bar id=recshapes style="margin-top:6px"></div>
<div id=keynote style="color:var(--warn);font-size:12px;margin-top:6px"></div>
<div class=askbar id=recask style="display:none">
<span id=recasktxt></span>
<span class=bar style="margin-top:8px">
<button class=acc id=recyes onclick="recAnswer(true)">Yes</button>
<button id=recno onclick="recAnswer(false)">No</button>
</span>
</div>
<pre class=log id=reclog>idle — nothing running.</pre>
<div class=sec>takes <span style="color:var(--mut);font-weight:400">— DataG1/</span></div>
<div class=bar style="margin-bottom:6px">
<label>replay speed <input id=recspeed type=number min=0.1 max=3 step=0.1 value=1></label>
<label title="replay the arm track (--no-arm when off)"><input type=checkbox id=reparm checked> arm</label>
<label title="replay the finger track (--no-hand when off)"><input type=checkbox id=rephand checked> hand</label>
<label><input type=checkbox id=recwaist> lock waist</label>
<button onclick=refreshTakes()>⟳ refresh</button>
</div>
<div id=takelist></div>
<div class=bar style="margin-top:10px">
<input type=file id=takefile accept=".jsonl" style="max-width:230px">
<button onclick=uploadTake()>⬆ Upload take</button>
<button id=delall onclick=delAllTakes() title="delete every take in DataG1/"
style="border-color:#6b2733;color:#ff9aa6">🗑 Delete all</button>
<span id=takestat style="color:var(--mut);font-size:12px"></span>
</div>
</div>
<div class=card>
<h2>Library <span style="color:var(--mut);text-transform:none;letter-spacing:0">— manage saved shapes &amp; combos (modify = load → edit → Save over same name)</span></h2>
<div class=grid>
<div><div class=sec>shapes <button onclick="restoreLib('poses')" style="padding:3px 8px;font-size:12px" title="put the shape library back as it was before the last save/delete">↺ undo last change</button></div><div id=libshapes></div></div>
<div><div class=sec>combos <button onclick="restoreLib('combos')" style="padding:3px 8px;font-size:12px" title="put the combo library back as it was before the last save/delete">↺ undo last change</button></div><div id=libcombos></div></div>
</div>
</div>
<script>
const J=["pinky","ring","middle","index","thumb_bend","thumb_rot"];
// Fully-qualified names for the CSV export, so a file read elsewhere is unambiguous
const JOINT_FULL=[].concat(J.map(x=>"right_"+x), J.map(x=>"left_"+x));let els=[];
function mk(host,base){for(let i=0;i<6;i++){const r=document.createElement('div');r.className='row';
const l=document.createElement('div');l.className='lab';l.textContent=J[i];
const s=document.createElement('input');s.type='range';s.min=0;s.max=1;s.step=0.01;s.value=1;
const v=document.createElement('div');v.className='val';v.textContent='1.00';
s.oninput=()=>{v.textContent=(+s.value).toFixed(2);s.style.setProperty('--f',(s.value*100)+'%');live();};
s.style.setProperty('--f','100%');r.append(l,s,v);host.appendChild(r);els[base+i]={s,v};}}
mk(sr,0);mk(sl,6);
function vals(){return els.map(e=>+e.s.value);}
function setVals(a){a.forEach((x,i)=>{els[i].s.value=x;els[i].v.textContent=(+x).toFixed(2);els[i].s.style.setProperty('--f',(x*100)+'%');});}
function msg(t,ok){const m=document.getElementById('msg');m.textContent=t;m.style.color=ok===false?'#ff6b7d':(ok?'#3fdda0':'#7e93b6');}
async function post(u,b){return (await fetch(u,{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(b||{})})).json();}
async function apply(){const j=await post('/api/apply',{q:vals()});msg(j.ok?'applied':('err: '+j.msg),j.ok);}
let t=null;function live(){clearTimeout(t);t=setTimeout(apply,90);}
function preset(v){setVals(Array(12).fill(v));apply();}
function box(){const b=[.5,.5,.5,.5,.4,.1];setVals(b.concat(b));apply();}
async function test(){for(let k=0;k<3;k++){preset(1);await new Promise(r=>setTimeout(r,900));preset(0);await new Promise(r=>setTimeout(r,900));}preset(1);}
async function savePose(){const n=pname.value.trim();if(!n)return msg('name?',false);const j=await post('/api/save',{name:n,q:vals()});msg(j.ok?'saved '+n:j.msg,j.ok);refreshPoses();}
let POSES={};async function refreshPoses(){POSES=await (await fetch('/api/poses')).json();plist.innerHTML='<option value="">load shape…</option>';Object.keys(POSES).sort().forEach(n=>plist.innerHTML+='<option>'+n+'</option>');renderLib();renderShapeKeys();}
function loadPose(){const n=plist.value;if(POSES[n]){setVals(POSES[n]);apply();}}
// diagnostics: live actual read-back + self-test
let acts=[];
function mkAct(host,base){for(let i=0;i<6;i++){const r=document.createElement('div');r.className='row';
const l=document.createElement('div');l.className='lab';l.textContent=J[i];
const bar=document.createElement('div');bar.style.cssText='flex:1;height:8px;border-radius:6px;background:#22304c;overflow:hidden';
const fill=document.createElement('div');fill.style.cssText='height:100%;width:0;background:var(--acc2);transition:width .2s';bar.appendChild(fill);
const v=document.createElement('div');v.className='val';v.textContent='';
r.append(l,bar,v);host.appendChild(r);acts[base+i]={fill,v};}}
mkAct(ar,0);mkAct(al,6);
// Live read-back. Reports each hand SEPARATELY: one dead RS-485 link used to be invisible
// because the other hand's non-zero values alone turned the lamp green.
let SYNCED=false;
async function pollState(){try{const j=await (await fetch('/api/capture')).json();if(j.ok&&j.q){
j.q.forEach((x,i)=>{const dead=(i<6)?!j.live_right:!j.live_left;const c=Math.max(0,Math.min(1,x));
acts[i].fill.style.width=dead?'0%':(c*100)+'%';
acts[i].fill.style.background=dead?'#ff6b7d':'var(--acc2)';
acts[i].v.textContent=dead?'':(+x).toFixed(2);});
const nd=(j.live_right?0:1)+(j.live_left?0:1);
setdot('d_read',nd===0?'ok':(nd===2?'bad':'warn'));
t_read.textContent = nd===0 ? 'reads live'
: (nd===2 ? 'BOTH hands not answering'
: ((!j.live_right?'RIGHT':'LEFT')+' hand not answering'));
// Sync the sliders to the real pose ONCE on load. They used to open at 1.00 regardless of
// where the fingers actually were, so the first Apply snapped the hand to a pose you never
// chose. Only sync a hand that is actually answering.
if(!SYNCED){SYNCED=true;const s=vals().slice();
if(j.live_right)for(let i=0;i<6;i++)s[i]=Math.max(0,Math.min(1,j.q[i]));
if(j.live_left) for(let i=6;i<12;i++)s[i]=Math.max(0,Math.min(1,j.q[i]));
setVals(s);msg('sliders synced to the hand',true);}
}}catch(e){}}
async function selftest(){selfmsg.textContent='testing (close→open, ~5s)…';selfmsg.style.color='#7e93b6';const j=await post('/api/selftest');const mv=j.moved?'hand moved ✓':'NO movement detected';const rd=j.reads_ok?'reads ok':'reads DEAD (hand not answering)';selfmsg.textContent=mv+' · '+rd;selfmsg.style.color=(j.moved||j.reads_ok)?'#3fdda0':'#ff6b7d';}
// releaseHand() removed: the RH56 has no motor-release command. q<0 means "no change"
// (the joint keeps its previous target and stays driven), and the worm drive cannot be
// back-driven at any force. Fingers can never be posed by hand — use touch-to-teach.
async function captureHand(){const j=await (await fetch('/api/capture')).json();if(j.ok&&j.q){setVals(j.q);msg('captured — tweak or Save',true);}else msg('capture failed (read-back)',false);}
async function regrip(){await apply();msg('holding current shape',true);}
async function saveCaptured(){const n=(rname.value.trim()||pname.value.trim());if(!n)return msg('name?',false);const j=await post('/api/save',{name:n,q:vals()});msg(j.ok?'saved '+n:j.msg,j.ok);refreshPoses();}
async function loadArm(){const a=await (await fetch('/api/armlist')).json();armsel.innerHTML='';Object.entries(a).forEach(([n,id])=>armsel.innerHTML+='<option value="'+id+'">'+n+' ('+id+')</option>');}
async function playArm(w){msg('arm moving…');const j=await post('/api/combo/play',{q:w?vals():null,arm:+armsel.value,release:false});msg(j.ok?'arm done':('arm err'),j.ok);}
async function releaseArm(){msg('releasing…');const j=await post('/api/arm',{id:99});msg(j.ok?'released':'err',j.ok);}
async function saveCombo(){const n=cname.value.trim();if(!n)return msg('name?',false);const j=await post('/api/combos',{name:n,q:vals(),arm:+armsel.value,release:crel.checked});msg(j.ok?'saved combo '+n:j.msg,j.ok);refreshCombos();}
let COMBOS={};async function refreshCombos(){COMBOS=await (await fetch('/api/combos')).json();clist.innerHTML='<option value="">play combo…</option>';Object.keys(COMBOS).sort().forEach(n=>clist.innerHTML+='<option>'+n+'</option>');renderLib();}
async function playCombo(){const n=clist.value;if(!COMBOS[n])return;const c=COMBOS[n];setVals(c.q);msg('playing '+n+'');const j=await post('/api/combo/play',{q:c.q,arm:c.arm,release:c.release});msg(j.ok?'played '+n:'err',j.ok);clist.value='';}
// Library manager (list + play + rename + delete)
function libBtn(t,fn,title){const x=document.createElement('button');x.textContent=t;x.style.padding='5px 9px';if(title)x.title=title;x.onclick=fn;return x;}
function renderLib(){
if(typeof libshapes==='undefined')return;
libshapes.innerHTML='';const sn=Object.keys(POSES||{}).sort();
if(!sn.length)libshapes.innerHTML='<div style="color:var(--mut);font-size:13px">no shapes yet</div>';
sn.forEach(n=>{const r=document.createElement('div');r.className='row';
const lab=document.createElement('div');lab.style.cssText='flex:1;color:var(--fg)';lab.textContent=n;r.appendChild(lab);
r.append(libBtn('load',()=>{setVals(POSES[n]);msg('loaded '+n,true);},'load into sliders'),
libBtn('play',()=>{setVals(POSES[n]);apply();},'apply to hands'),
libBtn('',()=>renameItem('pose',n),'rename'),
libBtn('🗑',()=>delItem('pose',n),'delete'));
libshapes.appendChild(r);});
libcombos.innerHTML='';const cn=Object.keys(COMBOS||{}).sort();
if(!cn.length)libcombos.innerHTML='<div style="color:var(--mut);font-size:13px">no combos yet</div>';
cn.forEach(n=>{const c=COMBOS[n];const r=document.createElement('div');r.className='row';
const lab=document.createElement('div');lab.style.cssText='flex:1;color:var(--fg)';lab.innerHTML=n+' <span style="color:var(--mut)">(arm '+c.arm+(c.release?', rel':'')+')</span>';r.appendChild(lab);
r.append(libBtn('play',()=>{setVals(c.q);post('/api/combo/play',{q:c.q,arm:c.arm,release:c.release});msg('playing '+n,true);},'play combo'),
libBtn('',()=>renameItem('combo',n),'rename'),
libBtn('🗑',()=>delItem('combo',n),'delete'));
libcombos.appendChild(r);});
}
// Both libraries are rewritten in full on every change, so a .bak is taken first and this
// puts it back — deleting the last shape used to be unrecoverable.
async function restoreLib(what){
const j=await post('/api/library/restore',{what:what});
msg(j.ok?('restored '+j.count+' '+what):j.msg,j.ok);
refreshPoses();refreshCombos();}
async function delItem(kind,n){if(!confirm('Delete '+kind+' "'+n+'"?'))return;await post('/api/'+kind+'/delete',{name:n});(kind==='pose'?refreshPoses():refreshCombos());}
async function renameItem(kind,n){const nn=(prompt('Rename '+kind,n)||'').trim();if(!nn||nn===n)return;const j=await post('/api/'+kind+'/rename',{old:n,new:nn});if(!j.ok){msg(j.msg||'rename failed',false);return;}(kind==='pose'?refreshPoses():refreshCombos());}
// ---- live joint tracker -------------------------------------------------------------
// Per joint: a movement TRACE (angle over time), plus the diagnostics that turn "it will not
// move" into an answer -- current (0mA = never energised), ERROR (latched faults survive
// until CLEAR_ERROR), STATUS, temperature, and the force LIMIT the measured force is being
// compared against.
const TRK_THRESH=12; // recorder's --thresh default, in grams of dbase
const TRK_TRACE=90; // samples kept per joint (~22s at 250ms)
let TRK={on:false,t:null,n:0,t0:0,a:[],f:[],fbase:null,bacc:[],bn:0,hist:[],rows:[],log:[]};
function mkTrk(host, base){
host.innerHTML='';
for(let i=0;i<6;i++){
const r=document.createElement('div');
r.style.cssText='display:grid;grid-template-columns:60px 78px 1fr;gap:6px;align-items:center;margin:5px 0';
const l=document.createElement('div'); l.className='lab'; l.style.width='60px'; l.textContent=J[i];
const sp=document.createElement('div'); sp.style.cssText='height:22px';
sp.innerHTML='<svg width="78" height="22"><polyline fill="none" stroke="#6ea8fe" stroke-width="1.5" points=""/></svg>';
const v=document.createElement('div'); v.style.cssText='font-size:11px;font-variant-numeric:tabular-nums;line-height:1.35;color:var(--mut)';
r.append(l,sp,v); host.appendChild(r);
TRK.rows[base+i]={poly:sp.querySelector('polyline'), v};
}
}
function resetTrack(){
TRK.n=0; TRK.t0=Date.now(); TRK.a=[]; TRK.f=[]; TRK.fbase=null; TRK.bacc=[]; TRK.bn=0; TRK.hist=[]; TRK.log=[];
for(let i=0;i<12;i++){TRK.a[i]=null; TRK.f[i]=null; TRK.hist[i]=[];}
TRK.rows.forEach(r=>{if(r){r.v.textContent='-'; r.poly.setAttribute('points','');}});
trkmsg.textContent='';
}
function toggleTrack(){
TRK.on=!TRK.on;
trkbtn.textContent = TRK.on ? '⏹ Stop tracking' : '▶ Start tracking';
setdot('d_trk', TRK.on?'ok':'');
t_trk.textContent = TRK.on?'tracking':'off';
if(TRK.on){ resetTrack(); TRK.t=setInterval(pollTrack,250); } else { clearInterval(TRK.t); TRK.t=null; }
}
function spark(poly, arr){
if(arr.length<2){poly.setAttribute('points','');return;}
// Fixed 0..1 scale so a flat line reads as "did not move" rather than being auto-zoomed
// into looking like motion -- the whole question this panel answers.
const w=78,h=22,n=arr.length;
const pts=arr.map((v,k)=>`${(k/(TRK_TRACE-1)*w).toFixed(1)},${(h-1-Math.max(0,Math.min(1,v))*(h-2)).toFixed(1)}`);
poly.setAttribute('points',pts.join(' '));
}
async function pollTrack(){
try{
const j=await (await fetch('/api/diag')).json();
if(!j.ok||!j.q) return;
if(j.f&&!TRK.fbase){
if(!TRK.bacc.length) TRK.bacc=j.f.slice(); else j.f.forEach((v,k)=>TRK.bacc[k]+=v);
TRK.bn++; if(TRK.bn>=4) TRK.fbase=TRK.bacc.map(v=>v/TRK.bn);
}
TRK.n++;
TRK.log.push({t:(Date.now()-TRK.t0)/1000, q:j.q.slice(),
f:(j.f?j.f.slice():null), cur:(j.cur?j.cur.slice():null)});
for(let i=0;i<12;i++){
const q=j.q[i];
TRK.hist[i].push(q); if(TRK.hist[i].length>TRK_TRACE) TRK.hist[i].shift();
spark(TRK.rows[i].poly, TRK.hist[i]);
if(!TRK.a[i]) TRK.a[i]={min:q,max:q}; else {TRK.a[i].min=Math.min(TRK.a[i].min,q); TRK.a[i].max=Math.max(TRK.a[i].max,q);}
const ar=TRK.a[i].max-TRK.a[i].min, moved=ar>0.03;
let dtxt='-';
if(j.f&&TRK.fbase){
const d=j.f[i]-TRK.fbase[i], ad=Math.abs(d);
if(!TRK.f[i]) TRK.f[i]={peak:ad}; else TRK.f[i].peak=Math.max(TRK.f[i].peak,ad);
dtxt=`d${d>=0?'+':''}${d.toFixed(0)} pk${TRK.f[i].peak.toFixed(0)}g`;
}
const touched=TRK.f[i]&&TRK.f[i].peak>TRK_THRESH;
let l1=`<span style="color:${moved?'#3fdda0':'var(--mut)'}">${q.toFixed(3)} Δ${ar.toFixed(3)}${moved?' MOVED':''}</span>`;
let l2=`<span style="color:${touched?'#ffcf5c':'var(--mut)'}">${dtxt}</span>`;
if(j.full){
const cur=j.cur[i], fs=j.fset[i], tp=j.temp[i], et=j.err_txt[i], st=j.sta_txt[i];
const gated=j.f&&Math.abs(j.f[i])>=fs&&fs>0;
l2+=` <span style="color:${cur>5?'#3fdda0':'var(--mut)'}">${cur.toFixed(0)}mA</span>`;
l2+=` <span style="color:${gated?'#ff6b7d':'var(--mut)'}">lim ${fs.toFixed(0)}g${gated?' GATED':''}</span>`;
l2+=` <span style="color:${tp>55?'#ff6b7d':'var(--mut)'}">${tp.toFixed(0)}°</span>`;
if(et) l2+=` <span style="color:#ff6b7d;font-weight:600">${et}</span>`;
else l2+=` <span style="color:var(--mut)">${st}</span>`;
}
TRK.rows[i].v.innerHTML=l1+'<br>'+l2;
}
const secs=((Date.now()-TRK.t0)/1000).toFixed(0);
const am=TRK.a.filter(x=>x&&x.max-x.min>0.03).length;
const fm=TRK.f.filter(x=>x&&x.peak>TRK_THRESH).length;
let extra='';
if(j.full){
const faults=j.err_txt.filter(x=>x).length;
const lost=j.lost.reduce((a,b)=>a+b,0);
extra=` · bus ${j.rate.toFixed(1)}Hz · lost ${lost}` + (faults?` · ${faults} FAULT`:'');
}
trkmsg.innerHTML=`${TRK.n} samples · ${secs}s · ${am} rotated · ${fm} touched${extra}`;
}catch(e){}
}
mkTrk(trkr,0); mkTrk(trkl,6); resetTrack();
// Export the whole tracked trace as CSV — one row per sample, angle+force+current for all 12
// joints, with the hand each column physically belongs to spelled out in the header so the
// file is unambiguous away from this page.
function exportTrack(){
if(!TRK.log.length){ trkmsg.textContent='nothing tracked yet — press Start first'; return; }
const hdr=['time_s'];
for(const p of ['angle','dforce_g','current_mA'])
for(let i=0;i<12;i++) hdr.push(`${JOINT_FULL[i]}_${p}`);
const rows=[hdr.join(',')];
for(const s of TRK.log){
const r=[s.t.toFixed(3)];
for(let i=0;i<12;i++) r.push(s.q[i].toFixed(4));
for(let i=0;i<12;i++) r.push(s.f&&TRK.fbase?(s.f[i]-TRK.fbase[i]).toFixed(1):'');
for(let i=0;i<12;i++) r.push(s.cur?s.cur[i].toFixed(0):'');
rows.push(r.join(','));
}
const blob=new Blob([rows.join('\n')],{type:'text/csv'});
const a=document.createElement('a');
const ts=new Date().toISOString().replace(/[:.]/g,'-').slice(0,19);
a.href=URL.createObjectURL(blob); a.download=`hand_track_${ts}.csv`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(a.href);
trkmsg.textContent=`exported ${TRK.log.length} samples`;
}
// ---- force follow ---------------------------------------------------------------------
let FOL=false;
async function toggleFollow(){
FOL=!FOL;
const g=+document.getElementById('folgain').value/10000; // slider 2..20 -> .0002..0.002
const d=+document.getElementById('foldead').value;
const j=await post('/api/follow',{on:FOL,gain:g,deadband:d});
if(!j.ok){ folmsg.textContent=j.msg; FOL=false; }
folbtn.textContent=FOL?'⏹ Disarm':'▶ Arm follow';
setdot('d_fol',FOL?'ok':'');
t_fol.textContent=FOL?'ARMED — push a finger':'off';
folmsg.textContent=FOL?`gain ${g} · deadband ${d}g`:'';
if(FOL&&!TRK.on) toggleTrack(); // watching the angle move is the point
}
document.getElementById('foldead').oninput=e=>{
foldeadv.textContent=e.target.value+'g';
if(FOL) post('/api/follow',{on:true,gain:+document.getElementById('folgain').value/10000,
deadband:+e.target.value});
};
document.getElementById('folgain').oninput=()=>{
if(FOL) post('/api/follow',{on:true,gain:+document.getElementById('folgain').value/10000,
deadband:+document.getElementById('foldead').value});
};
function setdot(id,st){const d=document.getElementById(id);d.className='dot '+st;}
async function refreshStatus(){try{const s=await (await fetch('/api/status')).json();
setdot('d_svc',s.service?'ok':'bad');setdot('d_br',s.bridge?'ok':'bad');
setdot('d_eth',s.eth0.up?'ok':'bad');setdot('d_wifi',s.wlan0.up?'ok':'bad');
setdot('d_port',s.mismatch?'warn':(s.ports.length?'ok':'bad'));
t_port.textContent=s.ports.length?('ports '+s.ports.map(p=>p.replace('/dev/tty','')).join('/')):'no ports';
iface.value=s.iface;
const w=document.getElementById('warn');
if(s.mismatch){w.style.display='block';w.textContent='⚠ Port drift: service opened '+s.ports.join(', ')+' but live ports are '+s.live.join(', ')+'. Click ⟳ Restart to re-detect.';}
else if(!s.service){w.style.display='block';w.textContent='⚠ Service not running — click ⟳ Restart.';}
else w.style.display='none';
}catch(e){}}
async function restart(){msg('restarting service…');const j=await post('/api/restart');msg(j.ok?'service restarted':'restart failed',j.ok);refreshStatus();}
iface.onchange=async()=>{msg('switching to '+iface.value+'');const j=await post('/api/iface',{iface:iface.value});msg(j.ok?('using '+iface.value):'err',j.ok);refreshStatus();};
// ---- record / replay ------------------------------------------------------------------
// Drives g1_record_replay.py as a child process. The buttons below are the same keys the
// CLI reads from the terminal during a take -- the hand cannot be posed by hand, so a
// finger pose only gets into a recording by being commanded.
let RECBUSY=false,RECPHASE='',RECTICK=0;
// One body builder for the preview line and the real start, so the command shown above the
// console is the command that runs. Arm and hand map to the CLI's --no-arm/--no-hand and
// work on BOTH actions.
function recBody(){return {action:'record',name:recname.value.trim(),seconds:+recsecs.value,
fingers:recfing.value,no_arm:!recarm.checked,no_hand:!rechand.checked};}
function repBody(n){return {action:'replay',name:n||'<take>',speed:+recspeed.value,
no_arm:!reparm.checked,no_hand:!rephand.checked,waist:recwaist.checked?'lock':'recorded'};}
async function startRec(){
const b=recBody();
if(b.no_arm&&b.no_hand)return msg('arm and hand both off — nothing to record',false);
if(!b.no_arm&&!confirm('Start a take?\n\nThe arms hold for ~3s and then go LIMP so you can '
+'pose them by hand.\nHave hold of the arm before the 3s is up.'))return;
const j=await post('/api/rec/start',b);
if(!j.ok){msg(j.msg,false);return;}
// The dashboard's own follow state is client-side, so mirror the server's disarm here or
// the button would keep claiming ARMED over a bridge that has stopped publishing.
if(j.disarmed_follow){FOL=false;folbtn.textContent='▶ Arm follow';setdot('d_fol','');
t_fol.textContent='off';folmsg.textContent='disarmed for the take';}
// A take starts from the rest pose with follow disarmed — mirror that, or the state pill
// would keep claiming whatever the previous take ended on.
KEYQ=REST12.slice();FOLLOWARM=false;markKey('');renderKeyState();
msg('recording'+(j.disarmed_follow?' — force-follow disarmed':''),true);pollRec();}
async function stopRec(){await post('/api/rec/stop',{});msg('stopping — arm goes home, then releases',true);}
// Pause is the recorder's own p key. The arm keeps being commanded to the frame it stopped
// on — a pause holds the pose, it does not release the arm.
async function pauseRec(){const j=await post('/api/rec/key',{k:'p'});
if(!j.ok)msg(j.msg||'nothing running',false);}
// ---- in-take keys: what you pressed, and what the hand was told ----------------------
// The pose shown is the COMMANDED one — the same 12 values the recorder publishes to
// rt/inspire/cmd for that key. It is not a read-back; a finger that is blocked will show
// here as commanded and still not be there.
const REST12=[1,1,1,1,1,0,1,1,1,1,1,0]; // thumb_rot parked closed, as the recorder does
let KEYQ=REST12.slice(),FOLLOWARM=false;
function applyKeyLocal(k){
if(k==='o')KEYQ=REST12.slice();
else if(k==='c')KEYQ=Array(12).fill(0);
else if(k==='[')for(let i=0;i<6;i++)KEYQ[i]=1; // note: also un-parks thumb_rot
else if(k===']')for(let i=0;i<6;i++)KEYQ[i]=0;
else if(k===';')for(let i=6;i<12;i++)KEYQ[i]=1;
else if(k==="'")for(let i=6;i<12;i++)KEYQ[i]=0;
else if(k==='f')FOLLOWARM=!FOLLOWARM;
else if(/^[1-9]$/.test(k)){const n=Object.keys(POSES||{}).sort()[+k-1];
if(n&&POSES[n])KEYQ=POSES[n].slice();}
renderKeyState();}
function handWord(a,b){const s=KEYQ.slice(a,b);
if(s.every(v=>v===0))return'closed';
if(s.every((v,i)=>v===REST12[a+i]))return'rest';
if(s.every(v=>v===1))return'open';
return'shaped';}
function renderKeyState(){
t_keys.textContent='R '+handWord(0,6)+' · L '+handWord(6,12)
+(FOLLOWARM?' · FOLLOW ARMED':'');
setdot('d_keys',FOLLOWARM?'warn':(RECBUSY?'ok':''));}
function markKey(k){
document.querySelectorAll('.kbtn').forEach(b=>b.classList.toggle('on',b.dataset.k===k));}
async function recKey(k){
const j=await post('/api/rec/key',{k:k});
if(!j.ok)return msg(j.msg||'no take running',false);
markKey(k);applyKeyLocal(k);
msg('sent '+k+' → R '+handWord(0,6)+' · L '+handWord(6,12),true);}
// Which keys the recorder will actually ACT on, per --fingers mode. Measured against the
// real recorder on loopback DDS, watching rt/inspire/cmd:
// position -> every key changes the commanded pose (o/c/[/]/;/' and shapes)
// touch -> NOTHING changes; the recorder holds the rest pose all take and the keys
// are dead. This is the default mode, so the buttons would otherwise look
// broken.
// external -> the recorder publishes nothing at all; the sliders above drive the hand
// follow -> only f (arm/disarm) and the shape keys reach the hand
function keysLive(){
const m=recfing.value,live={};
['o','c','[',']',';',"'"].forEach(k=>live[k]=(m==='position'));
live['f']=(m==='follow');
for(let i=1;i<=9;i++)live[String(i)]=(m==='position'||m==='follow');
return live;}
function renderKeyModes(){
const live=keysLive(),m=recfing.value;
document.querySelectorAll('.kbtn').forEach(b=>{
const ok=live[b.dataset.k];b.classList.toggle('dim',!ok);
b.title=ok?(b.title||'').replace(/ \(ignored.*\)$/,'')
:'ignored in --fingers '+m;});
keynote.textContent =
m==='touch' ? '⚠ touch mode: the recorder holds the rest pose — these keys change nothing. '
+'Use fingers=position to drive the hand with them.'
: m==='external' ? '⚠ external mode: the recorder sends no hand commands — drive the 12 sliders above instead.'
: m==='follow' ? 'follow mode: only f (arm/disarm) and the shape keys reach the hand.'
: '';}
async function recAnswer(y){
if(y&&RECPHASE==='preview?'&&!confirm('Preview replays the take you just made — the ARM WILL '
+'MOVE.\n\nIs it clear?'))return;
const wasSave=(RECPHASE==='save?');
await post('/api/rec/answer',{yes:y});recask.style.display='none';
// Answering "save" is the moment the file appears — do not wait for the next poll to
// notice the process is gone.
if(wasSave&&y){setTimeout(refreshTakes,900);setTimeout(afterRun,1800);}}
function renderShapeKeys(){
if(typeof recshapes==='undefined')return;
const names=Object.keys(POSES||{}).sort().slice(0,9); // same 9 the recorder pulls
recshapes.innerHTML='<span style="color:var(--mut);font-size:12px;width:72px">shapes</span>';
if(!names.length){recshapes.innerHTML+='<span style="color:var(--mut);font-size:12px">no saved '
+'shapes yet — save one above and it becomes key 1-9 during a take</span>';return;}
names.forEach((n,i)=>{const b=document.createElement('button');
b.className='kbtn';b.dataset.k=String(i+1);b.textContent=(i+1)+' '+n;
b.style.padding='5px 9px';b.title='apply shape '+n+' during a take';
recshapes.appendChild(b);});
renderKeyModes();}
async function pollRec(){
let s;try{s=await (await fetch('/api/rec/status')).json();}catch(e){return;}
const busy=s.busy;recbtn.disabled=busy;recstop.disabled=!busy;
// Stop works by SIGINT, which a dashboard started with SIGINT ignored cannot pass to the
// recorder. Say so instead of leaving a button that silently does nothing.
recwarn.textContent=s.sigint?'':'⚠ Stop is disabled — restart the dashboard (SIGINT is ignored)';
if(!s.sigint)recstop.disabled=true;
setdot('d_rec',busy?(s.phase==='running'?'ok':'warn'):(s.ready?'':'bad'));
// Pause only exists inside replay(), so the button only exists there — including the
// preview replay at the end of a take, which is the same code path.
recpause.style.display=(busy&&s.replaying)?'':'none';
recpause.textContent=s.paused?'▶ Resume':'⏸ Pause';
recpause.className=s.paused?'acc':'';
t_rec.textContent=!s.ready?'recorder not found'
:busy?(s.action+' · '+(s.paused?'PAUSED':s.phase.replace('?',''))+' · '+s.elapsed+'s'
+(s.seconds?'/'+s.seconds+'s':''))
:(s.phase==='done'?('finished — exit '+s.rc):'idle');
if(s.lines&&s.lines.length){
const atEnd=reclog.scrollTop+reclog.clientHeight>=reclog.scrollHeight-24;
reclog.textContent=s.lines.join('\n');
if(atEnd)reclog.scrollTop=reclog.scrollHeight;}
const ask=busy&&(s.phase==='preview?'||s.phase==='save?');
recask.style.display=ask?'block':'none';
if(ask){recasktxt.innerHTML=s.phase==='preview?'
?'▶️ <b>Preview this take before saving?</b> — the ARM WILL MOVE through what you just recorded.'
:'💾 <b>Save this take?</b> — Discard throws the recording away.';
recyes.textContent=s.phase==='preview?'?'Preview (arm moves)':'Save';
recno.textContent=s.phase==='preview?'?'Skip':'Discard';}
if(RECBUSY&&!busy)afterRun(); // a finished take is a new file in the list
RECBUSY=busy;RECPHASE=s.phase||'';}
async function afterRun(){
// Refresh twice. The recorder writes the take as it exits, so a poll that lands a few ms
// early sees the process gone and the file not yet there — which is what left a saved
// take invisible until you pressed ⟳.
const before=+(takelist.dataset.count||0);
await refreshTakes();
setTimeout(async()=>{
await refreshTakes();
const now=+(takelist.dataset.count||0);
if(now>before)msg('take saved — '+now+' in the library',true);},1500);}
async function refreshTakes(){
const j=await (await fetch('/api/takes')).json();takelist.innerHTML='';
if(!j.ready){takelist.innerHTML='<div style="color:var(--bad);font-size:13px">recorder not '
+'found — set RECORDER_DIR to the folder holding g1_record_replay.py</div>';return;}
const sz=j.total_kb>1024?(j.total_kb/1024).toFixed(1)+' MB':j.total_kb+' KB';
takestat.textContent=j.takes.length?(j.takes.length+' takes · '+sz+' in '+j.dir):'';
takelist.dataset.count=j.takes.length;takelist.dataset.mb=sz;
delall.disabled=!j.takes.length;
if(!j.takes.length){takelist.innerHTML='<div style="color:var(--mut);font-size:13px">no takes in '
+j.dir+' — record one, or upload a .jsonl below</div>';return;}
j.takes.sort((a,b)=>b.mtime-a.mtime);
j.takes.forEach(t=>{
const r=document.createElement('div');r.className='row';
const lab=document.createElement('div');lab.style.cssText='flex:1;color:var(--fg)';
// "new" is aged against the ROBOT's clock (j.now), not the browser's.
const isNew=(j.now-t.mtime)<90;
const bits=[];if(t.secs)bits.push(t.secs+'s');if(t.frames)bits.push(t.frames+' fr');
if(t.hz)bits.push(t.hz+'Hz');
const tr=(t.arm?'arm':'')+(t.arm&&t.hand?'+':'')+(t.hand?'hand':'');if(tr)bits.push(tr);
bits.push(t.kb+'KB');
lab.innerHTML=t.name+(isNew?' <span style="color:var(--ok);font-size:12px">● new</span>':'')
+' <span style="color:var(--mut)">('+bits.join(' · ')+')</span>';
r.appendChild(lab);
r.append(libBtn('▶ replay',()=>replayTake(t.name),'replay this take — ARM MOVES'),
libBtn('',()=>{location.href='/api/take/download?name='+encodeURIComponent(t.name);},
'download the .jsonl'),
libBtn('',()=>dupTake(t.name),'duplicate — edit a copy, keep the original'),
libBtn('',()=>renameTake(t.name),'rename'),
libBtn('🗑',()=>delTake(t.name),'delete'));
takelist.appendChild(r);});}
async function replayTake(n){
const b=repBody(n);b.confirm=true;
if(b.no_arm&&b.no_hand)return msg('arm and hand both off — nothing to replay',false);
const what=b.no_arm?'the HAND will move (arm track skipped)'
:'the ARM WILL MOVE through the whole recording';
if(!confirm('Replay "'+n+'"'+what+'.\n\nIs the robot clear of people and objects?'))return;
const j=await post('/api/rec/start',b);
msg(j.ok?'replaying '+n:j.msg,j.ok);pollRec();}
async function renameTake(n){const nn=(prompt('Rename take',n)||'').trim();if(!nn||nn===n)return;
const j=await post('/api/take/rename',{old:n,new:nn});if(!j.ok)msg(j.msg,false);
else msg('renamed to '+nn,true);refreshTakes();}
async function delTake(n){
// A take is a pose you may not be able to re-record — name it in the prompt, do not just
// ask "are you sure".
if(!confirm('Delete take "'+n+'"?\n\nThis removes the recording from DataG1/ for good.'))return;
const j=await post('/api/take/delete',{name:n});if(!j.ok)msg(j.msg,false);
else msg('deleted '+n,true);refreshTakes();}
async function dupTake(n){const j=await post('/api/take/duplicate',{name:n});
msg(j.ok?('copied to '+j.name):j.msg,j.ok);refreshTakes();}
async function delAllTakes(){
// Deletes on the click — no dialog, by request. The server still requires the
// confirm token, which the button supplies here; that guard is now only about a stray
// API call, not about this button.
const n=+(takelist.dataset.count||0);
if(!n)return msg('no takes to delete',false);
const j=await post('/api/take/delete',{all:true,confirm:'DELETE ALL'});
msg(j.ok?('deleted '+j.deleted.length+' takes'):j.msg,j.ok);refreshTakes();}
async function uploadTake(){
const f=takefile.files[0];if(!f)return msg('choose a .jsonl first',false);
const fd=new FormData();fd.append('file',f);
msg('uploading '+f.name+'');
const j=await (await fetch('/api/take/upload',{method:'POST',body:fd})).json();
if(!j.ok&&/already exists/.test(j.msg||'')){
if(!confirm(j.msg+' — overwrite it?'))return;
fd.append('overwrite','1');
const k=await (await fetch('/api/take/upload',{method:'POST',body:fd})).json();
msg(k.ok?('replaced '+k.name):k.msg,k.ok);refreshTakes();return;}
msg(j.ok?('uploaded '+j.name+' ('+Math.round(j.bytes/1024)+' KB)'):j.msg,j.ok);
takefile.value='';refreshTakes();}
refreshPoses();loadArm();refreshCombos();refreshStatus();setInterval(refreshStatus,3000);
pollState();setInterval(pollState,1200);
// One delegated handler covers the fixed keys AND the shape buttons, which are rebuilt
// whenever the shape library changes.
document.addEventListener('click',e=>{const b=e.target.closest&&e.target.closest('.kbtn');
if(b&&!b.disabled)recKey(b.dataset.k);});
recfing.addEventListener('change',renderKeyModes);
renderKeyModes();renderKeyState();
refreshTakes();pollRec();
// Fast while something is running, every ~3s when idle — a take needs a live console, an
// idle dashboard does not.
setInterval(()=>{if(RECBUSY||RECTICK++%4===0)pollRec();},700);
</script></body></html>"""
if __name__ == "__main__":
ap = argparse.ArgumentParser()
ap.add_argument("--port", type=int, default=8088)
a = ap.parse_args()
PORT = a.port # the recorder is told to pull its shapes off this port
fix_sigint() # before any take is spawned — see sigint_ok()
print("Hand dashboard http://0.0.0.0:%d iface=%s" % (a.port, get_iface()))
print(" recorder: %s" % (os.path.join(REC_DIR, REC_SCRIPT) if rec_ready()
else "NOT FOUND (set RECORDER_DIR)"))
app.run(host="0.0.0.0", port=a.port, threaded=True)