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.
1713 lines
86 KiB
Python
Executable File
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 & 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 & 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)
|