Update 2026-06-08 11:03:52
This commit is contained in:
parent
811a391932
commit
ca0de44401
7
.claude/settings.json
Normal file
7
.claude/settings.json
Normal file
@ -0,0 +1,7 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(node -e ' *)"
|
||||
]
|
||||
}
|
||||
}
|
||||
12
G1_Controller/__init__.py
Normal file
12
G1_Controller/__init__.py
Normal file
@ -0,0 +1,12 @@
|
||||
"""G1_Controller — manual dashboard locomotion control (N2 Phase 1).
|
||||
|
||||
`LocoController` wraps the Unitree `LocoClient` + `MotionSwitcherClient` for
|
||||
operator-driven walking, postures and a discrete step pad. It reuses the arm
|
||||
controller's single process-wide DDS init (one `ChannelFactoryInitialize`) and
|
||||
is gated behind an in-memory "Enable movement" arm flag that defaults OFF every
|
||||
boot. See dashboard/routes/controller.py for the REST surface.
|
||||
"""
|
||||
|
||||
from Project.Sanad.G1_Controller.loco_controller import LocoController
|
||||
|
||||
__all__ = ["LocoController"]
|
||||
567
G1_Controller/loco_controller.py
Normal file
567
G1_Controller/loco_controller.py
Normal file
@ -0,0 +1,567 @@
|
||||
"""LocoController — manual G1 locomotion via the Unitree LocoClient (N2 Phase 1).
|
||||
|
||||
Ported from the proven scripts in G1_Lootah/Controller (g1_mode_controller.py,
|
||||
keyboard_controller.py, hanger_boot_sequence.py). Design notes:
|
||||
|
||||
* **One DDS init per process.** The arm controller owns the single
|
||||
`ChannelFactoryInitialize(0, nic)` (motion/arm_controller.py). This class
|
||||
NEVER initialises DDS — it lazily builds its `LocoClient` /
|
||||
`MotionSwitcherClient` only after `arm._initialized` is True.
|
||||
* **Default DISARMED.** `_armed` starts False every boot and gates every WRITE
|
||||
method. Reads (status / fsm / joints), E-STOP and disarm are ALWAYS allowed.
|
||||
* **StopMove watchdog.** Continuous `Move(..., True)` never self-terminates, so a
|
||||
daemon thread StopMoves if no `move()` refresh arrives within
|
||||
`watchdog_timeout_sec`. The frontend re-sends setpoints at ~10 Hz, so a tab
|
||||
close / network drop trips the watchdog within the timeout.
|
||||
* **Velocity caps.** Symmetric clamp on vx/vy/vyaw — Walk 0.6, Run 1.2.
|
||||
* **Allow-anytime-warn.** move/step never hard-block on FSM; if not walk-ready
|
||||
they still execute but return a `warning`.
|
||||
* **Sim fallback.** When `unitree_sdk2py` is absent (workstation), every write
|
||||
returns `{"simulated": True}` (never raises) so the whole UI is testable.
|
||||
|
||||
SDK facts confirmed from source — do not "fix" them:
|
||||
* `LocoClient.Move(vx, vy, vyaw, True)` — the continuous-mode kwarg is misspelled
|
||||
`continous_move` (one n); we pass it POSITIONALLY to avoid a TypeError.
|
||||
* `LocoClient` has NO StandUp()/Squat() — use SetFsmId(4)/SetFsmId(2).
|
||||
* FSM id / mode are read via the private RPC `bot._Call(7001/7002, "{}")`.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from typing import Any, Optional
|
||||
|
||||
from Project.Sanad.core.config_loader import section as _cfg_section
|
||||
from Project.Sanad.core.logger import get_logger
|
||||
|
||||
log = get_logger("loco_controller")
|
||||
|
||||
# -- SDK import (optional) -----------------------------------------------------
|
||||
try:
|
||||
from unitree_sdk2py.g1.loco.g1_loco_client import LocoClient
|
||||
from unitree_sdk2py.comm.motion_switcher.motion_switcher_client import (
|
||||
MotionSwitcherClient,
|
||||
)
|
||||
_HAS_SDK = True
|
||||
except ImportError:
|
||||
LocoClient = None
|
||||
MotionSwitcherClient = None
|
||||
_HAS_SDK = False
|
||||
log.warning("Unitree SDK not available — LocoController in simulation mode")
|
||||
|
||||
# LocoClient general RPC api-ids for FSM read-back (stable across SDK builds).
|
||||
ROBOT_API_ID_LOCO_GET_FSM_ID = 7001
|
||||
ROBOT_API_ID_LOCO_GET_FSM_MODE = 7002
|
||||
|
||||
# G1 29-DoF joint names for indices 12-28 (0-11 legs, 12-14 waist, 15-21 left
|
||||
# arm, 22-28 right arm). Used by the Diagnostics joint read-out.
|
||||
JOINT_NAMES = {
|
||||
12: "WAIST_YAW", 13: "WAIST_ROLL", 14: "WAIST_PITCH",
|
||||
15: "L_SHOULDER_PITCH", 16: "L_SHOULDER_ROLL", 17: "L_SHOULDER_YAW",
|
||||
18: "L_ELBOW", 19: "L_WRIST_ROLL", 20: "L_WRIST_PITCH", 21: "L_WRIST_YAW",
|
||||
22: "R_SHOULDER_PITCH", 23: "R_SHOULDER_ROLL", 24: "R_SHOULDER_YAW",
|
||||
25: "R_ELBOW", 26: "R_WRIST_ROLL", 27: "R_WRIST_PITCH", 28: "R_WRIST_YAW",
|
||||
}
|
||||
|
||||
# Discrete step pad — (vx, vy, vyaw) sign per direction; magnitude is
|
||||
# step_speed_frac * cap_walk (a gentle single step).
|
||||
_STEP_DIRS = {
|
||||
"forward": (1.0, 0.0, 0.0),
|
||||
"backward": (-1.0, 0.0, 0.0),
|
||||
"slide_left": (0.0, 1.0, 0.0),
|
||||
"slide_right": (0.0, -1.0, 0.0),
|
||||
"rotate_left": (0.0, 0.0, 1.0),
|
||||
"rotate_right": (0.0, 0.0, -1.0),
|
||||
}
|
||||
|
||||
_POSTURES = (
|
||||
"zero_torque", "damp", "stand_up", "squat", "sit",
|
||||
"low_stand", "high_stand", "lie_to_stand",
|
||||
)
|
||||
|
||||
|
||||
class LocoController:
|
||||
"""Thread-safe manual locomotion control with a simulation fallback."""
|
||||
|
||||
def __init__(self, arm=None):
|
||||
self._arm = arm # shared ArmController (owns the ONE DDS init)
|
||||
self._bot = None # LocoClient (lazy)
|
||||
self._msc = None # MotionSwitcherClient (lazy)
|
||||
self._lc_ready = False
|
||||
self._lock = threading.RLock() # serialise all loco client WRITE calls
|
||||
self._armed = False # in-memory MANUAL gate — OFF every boot
|
||||
|
||||
self._cur_v = (0.0, 0.0, 0.0) # last commanded (vx, vy, vyaw)
|
||||
self._teleop_active = False
|
||||
self._last_msc_mode: Optional[str] = None
|
||||
|
||||
# watchdog
|
||||
self._last_move_ts = 0.0
|
||||
self._wd_thread: Optional[threading.Thread] = None
|
||||
self._wd_stop = threading.Event()
|
||||
self._wd_stop.set() # not running until armed
|
||||
# Monotonic stop-generation counter, bumped under _lock by
|
||||
# estop/stop/disarm. move()/step()/prep_mode() capture it at start and
|
||||
# bail the instant it changes — so E-STOP preempts an in-flight motion
|
||||
# immediately AND can never be silently "un-cancelled" by a concurrent
|
||||
# command (a lock-free Event clear() could; an int compare under the
|
||||
# lock cannot).
|
||||
self._stop_gen = 0
|
||||
# Serializes the discrete blocking operations (step/prep_mode) so two
|
||||
# can't overlap and interleave Move commands. Continuous teleop move()
|
||||
# is intentionally NOT guarded by this.
|
||||
self._discrete_busy = False
|
||||
|
||||
cfg = _cfg_section("motion", "loco_controller")
|
||||
self._cap_walk = float(cfg.get("cap_walk", 0.6))
|
||||
self._cap_run = float(cfg.get("cap_run", 1.2))
|
||||
self._lin_step = float(cfg.get("lin_step", 0.05))
|
||||
self._ang_step = float(cfg.get("ang_step", 0.2))
|
||||
self._wd_timeout = float(cfg.get("watchdog_timeout_sec", 0.5))
|
||||
self._block_window = float(cfg.get("arm_block_window_sec", 1.5))
|
||||
self._step_dur = float(cfg.get("step_duration_sec", 0.6))
|
||||
self._step_frac = float(cfg.get("step_speed_frac", 0.5))
|
||||
self._loco_timeout = float(cfg.get("loco_timeout_sec", 10.0))
|
||||
self._msc_timeout = float(cfg.get("msc_timeout_sec", 5.0))
|
||||
|
||||
# ── client lifecycle ─────────────────────────────────────────────────────
|
||||
|
||||
def _ensure_client(self) -> bool:
|
||||
"""Lazily build LocoClient + MotionSwitcherClient. Returns readiness.
|
||||
|
||||
Never initialises DDS — requires the shared arm to have already run the
|
||||
single ChannelFactoryInitialize.
|
||||
"""
|
||||
if not _HAS_SDK:
|
||||
return False
|
||||
if self._lc_ready:
|
||||
return True
|
||||
if self._arm is None or not getattr(self._arm, "_initialized", False):
|
||||
return False
|
||||
with self._lock:
|
||||
if self._lc_ready:
|
||||
return True
|
||||
try:
|
||||
bot = LocoClient()
|
||||
bot.SetTimeout(self._loco_timeout)
|
||||
bot.Init()
|
||||
msc = MotionSwitcherClient()
|
||||
msc.SetTimeout(self._msc_timeout)
|
||||
msc.Init()
|
||||
self._bot = bot
|
||||
self._msc = msc
|
||||
self._lc_ready = True
|
||||
log.info("LocoClient + MotionSwitcherClient ready")
|
||||
except Exception as exc:
|
||||
log.error("LocoClient init failed: %s", exc)
|
||||
self._lc_ready = False
|
||||
return self._lc_ready
|
||||
|
||||
def _safe_call(self, name: str, fn, *a, **kw):
|
||||
try:
|
||||
return True, fn(*a, **kw)
|
||||
except Exception as exc:
|
||||
log.error("%s failed: %s", name, exc)
|
||||
return False, None
|
||||
|
||||
def _rpc_get_int(self, api_id: int):
|
||||
bot = self._bot
|
||||
if bot is None:
|
||||
return None
|
||||
try:
|
||||
code, data = bot._Call(api_id, "{}")
|
||||
if code == 0 and data:
|
||||
return json.loads(data).get("data")
|
||||
except Exception:
|
||||
pass
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def _clamp(v: float, cap: float) -> float:
|
||||
return max(-cap, min(cap, float(v)))
|
||||
|
||||
# ── FSM / readiness ──────────────────────────────────────────────────────
|
||||
|
||||
def fsm_id(self):
|
||||
return self._rpc_get_int(ROBOT_API_ID_LOCO_GET_FSM_ID)
|
||||
|
||||
def fsm_mode(self):
|
||||
return self._rpc_get_int(ROBOT_API_ID_LOCO_GET_FSM_MODE)
|
||||
|
||||
def _walk_ready_warning(self) -> Optional[str]:
|
||||
"""allow-anytime-warn: None when ready, else a human message."""
|
||||
if not self._lc_ready:
|
||||
return None
|
||||
fid = self.fsm_id()
|
||||
fmode = self.fsm_mode()
|
||||
if fid == 200 and fmode not in (None, 2):
|
||||
return None
|
||||
return (f"Robot not in walk-ready FSM (id={fid}, mode={fmode}). "
|
||||
f"Command sent anyway.")
|
||||
|
||||
# ── arm flag + watchdog ──────────────────────────────────────────────────
|
||||
|
||||
def is_armed(self) -> bool:
|
||||
return self._armed
|
||||
|
||||
def movement_active(self) -> bool:
|
||||
"""True when the robot may be walking: manual armed, teleop active, OR a
|
||||
move/step issued within the block window. Used as the arm's motion-block
|
||||
predicate so the arm never replays while the robot is (or just was)
|
||||
moving — regardless of whether the MANUAL gate or the GEMINI gate
|
||||
(Phase 3 voice dispatch, which calls move/step directly) triggered it."""
|
||||
if self._armed or self._teleop_active:
|
||||
return True
|
||||
return (time.monotonic() - self._last_move_ts) < self._block_window
|
||||
|
||||
def arm_movement(self) -> dict:
|
||||
"""Unlock manual control. Cancels any in-flight arm motion first so the
|
||||
arm and locomotion are never active simultaneously (movement wins)."""
|
||||
try:
|
||||
if self._arm is not None and getattr(self._arm, "is_busy", False):
|
||||
log.info("arming movement — cancelling in-flight arm motion")
|
||||
self._arm.cancel()
|
||||
except Exception:
|
||||
log.exception("arm.cancel() on arm_movement failed")
|
||||
with self._lock:
|
||||
self._armed = True
|
||||
self._start_watchdog()
|
||||
log.info("movement ARMED")
|
||||
return {"ok": True, "armed": True}
|
||||
|
||||
def disarm_movement(self) -> dict:
|
||||
with self._lock:
|
||||
self._stop_gen += 1 # break any in-flight step/prep/move
|
||||
self._armed = False
|
||||
self._teleop_active = False
|
||||
self._wd_stop.set()
|
||||
try:
|
||||
self._raw_stop()
|
||||
except Exception:
|
||||
log.exception("StopMove on disarm failed")
|
||||
log.info("movement DISARMED")
|
||||
return {"ok": True, "armed": False}
|
||||
|
||||
def _start_watchdog(self):
|
||||
self._wd_stop.clear()
|
||||
if self._wd_thread is None or not self._wd_thread.is_alive():
|
||||
self._wd_thread = threading.Thread(
|
||||
target=self._watchdog_loop, daemon=True, name="loco-watchdog")
|
||||
self._wd_thread.start()
|
||||
|
||||
def _watchdog_loop(self):
|
||||
period = max(0.02, min(0.1, self._wd_timeout / 2.0))
|
||||
while not self._wd_stop.is_set():
|
||||
fire = False
|
||||
# Read-and-decide under the lock (atomic check-then-act); the actual
|
||||
# StopMove runs after release so the critical section stays tiny.
|
||||
with self._lock:
|
||||
if self._teleop_active and (time.monotonic() - self._last_move_ts) > self._wd_timeout:
|
||||
self._teleop_active = False
|
||||
fire = True
|
||||
if fire:
|
||||
log.warning("watchdog: teleop setpoint stale (>%.2fs) — StopMove",
|
||||
self._wd_timeout)
|
||||
try:
|
||||
self._raw_stop()
|
||||
except Exception:
|
||||
log.exception("watchdog StopMove failed")
|
||||
self._wd_stop.wait(period)
|
||||
|
||||
def _raw_stop(self) -> bool:
|
||||
"""Issue StopMove if the client is up; no-op in sim. Lock-light."""
|
||||
if not self._lc_ready or self._bot is None:
|
||||
return False
|
||||
with self._lock:
|
||||
ok, _ = self._safe_call("StopMove", self._bot.StopMove)
|
||||
return ok
|
||||
|
||||
# ── movement ─────────────────────────────────────────────────────────────
|
||||
|
||||
def move(self, vx: float, vy: float, vyaw: float, run: bool = False) -> dict:
|
||||
cap = self._cap_run if run else self._cap_walk
|
||||
cvx, cvy, cvyaw = self._clamp(vx, cap), self._clamp(vy, cap), self._clamp(vyaw, cap)
|
||||
capped = (cvx, cvy, cvyaw) != (float(vx), float(vy), float(vyaw))
|
||||
warning = self._walk_ready_warning()
|
||||
sent = {"vx": cvx, "vy": cvy, "vyaw": cvyaw}
|
||||
|
||||
with self._lock:
|
||||
my_gen = self._stop_gen # capture under lock
|
||||
|
||||
if not self._ensure_client():
|
||||
with self._lock: # sim: record intent for UI/watchdog
|
||||
self._cur_v = (cvx, cvy, cvyaw)
|
||||
self._last_move_ts = time.monotonic()
|
||||
self._teleop_active = True
|
||||
self._start_watchdog()
|
||||
return {"ok": True, "sent": sent, "capped": capped,
|
||||
"warning": warning, "simulated": True}
|
||||
with self._lock:
|
||||
# If an E-STOP / stop / disarm landed since we captured my_gen, do NOT
|
||||
# (re)command velocity — and do NOT stamp the motion flags (so a
|
||||
# cancelled tick doesn't extend the arm-block window).
|
||||
if self._stop_gen != my_gen:
|
||||
return {"ok": False, "cancelled": True, "sent": sent,
|
||||
"capped": capped, "warning": warning, "simulated": False}
|
||||
self._cur_v = (cvx, cvy, cvyaw)
|
||||
self._last_move_ts = time.monotonic()
|
||||
self._teleop_active = True
|
||||
self._safe_call("SetBalanceMode", self._bot.SetBalanceMode, 1)
|
||||
ok, _ = self._safe_call("Move", self._bot.Move, cvx, cvy, cvyaw, True)
|
||||
self._start_watchdog()
|
||||
return {"ok": bool(ok), "sent": sent, "capped": capped,
|
||||
"warning": warning, "simulated": False}
|
||||
|
||||
def stop_move(self) -> dict:
|
||||
"""Halt translation/rotation. Allowed even when disarmed."""
|
||||
with self._lock:
|
||||
self._stop_gen += 1
|
||||
self._teleop_active = False
|
||||
if not self._ensure_client():
|
||||
return {"ok": True, "simulated": True}
|
||||
ok = self._raw_stop()
|
||||
return {"ok": bool(ok), "simulated": False}
|
||||
|
||||
def estop(self) -> dict:
|
||||
"""Emergency stop = StopMove only (no Damp / FSM change → keeps posture).
|
||||
ALWAYS allowed, even disarmed and in sim. Bumps the stop generation so any
|
||||
in-flight move()/step()/prep_mode() bails immediately (no lock wait)."""
|
||||
with self._lock:
|
||||
self._stop_gen += 1
|
||||
self._teleop_active = False
|
||||
self._cur_v = (0.0, 0.0, 0.0)
|
||||
if not self._ensure_client():
|
||||
log.warning("E-STOP (sim)")
|
||||
return {"ok": True, "simulated": True}
|
||||
ok = self._raw_stop()
|
||||
log.warning("E-STOP — StopMove issued")
|
||||
return {"ok": bool(ok), "simulated": False}
|
||||
|
||||
def step(self, direction: str) -> dict:
|
||||
"""Discrete one-step pad: Move for step_duration then StopMove.
|
||||
Blocking (~step_duration); call via asyncio.to_thread from the route.
|
||||
|
||||
The sleep loop does NOT hold self._lock, so E-STOP / StopMove (which take
|
||||
the lock briefly) preempt it immediately; the loop also bails the moment
|
||||
the stop generation changes."""
|
||||
if direction not in _STEP_DIRS:
|
||||
return {"ok": False, "reason": f"unknown direction: {direction}"}
|
||||
sx, sy, syaw = _STEP_DIRS[direction]
|
||||
k = self._cap_walk * self._step_frac
|
||||
vx, vy, vyaw = sx * k, sy * k, syaw * k
|
||||
warning = self._walk_ready_warning()
|
||||
with self._lock:
|
||||
if self._discrete_busy:
|
||||
return {"ok": False, "dir": direction, "reason": "busy",
|
||||
"warning": warning, "simulated": not self._lc_ready}
|
||||
self._discrete_busy = True
|
||||
my_gen = self._stop_gen
|
||||
self._last_move_ts = time.monotonic()
|
||||
self._teleop_active = True
|
||||
self._start_watchdog()
|
||||
if not self._ensure_client():
|
||||
with self._lock:
|
||||
self._teleop_active = False
|
||||
self._discrete_busy = False
|
||||
return {"ok": True, "dir": direction, "warning": warning, "simulated": True}
|
||||
try:
|
||||
with self._lock:
|
||||
if self._stop_gen != my_gen: # stopped before we began
|
||||
return {"ok": False, "dir": direction, "cancelled": True,
|
||||
"warning": warning, "simulated": False}
|
||||
self._safe_call("SetBalanceMode", self._bot.SetBalanceMode, 1)
|
||||
self._safe_call("Move", self._bot.Move, vx, vy, vyaw, True)
|
||||
t_end = time.monotonic() + self._step_dur
|
||||
while time.monotonic() < t_end:
|
||||
if self._stop_gen != my_gen:
|
||||
break
|
||||
with self._lock:
|
||||
self._last_move_ts = time.monotonic() # keep watchdog fed
|
||||
time.sleep(0.05)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._safe_call("StopMove", self._bot.StopMove)
|
||||
self._teleop_active = False
|
||||
self._discrete_busy = False
|
||||
return {"ok": True, "dir": direction, "warning": warning, "simulated": False}
|
||||
|
||||
# ── postures / modes ─────────────────────────────────────────────────────
|
||||
|
||||
def prep_mode(self) -> dict:
|
||||
"""PREP — StopMove → Damp → StandUp(FSM4) → height ramp → BalanceStand(0).
|
||||
Exact order from g1_mode_controller.prep_mode, minus the blocking input().
|
||||
Blocking (~1s); call via asyncio.to_thread."""
|
||||
if not self._ensure_client():
|
||||
return {"ok": True, "mode": "prep", "simulated": True}
|
||||
with self._lock:
|
||||
if self._discrete_busy:
|
||||
return {"ok": False, "mode": "prep", "reason": "busy", "simulated": False}
|
||||
self._discrete_busy = True
|
||||
my_gen = self._stop_gen
|
||||
self._safe_call("StopMove", self._bot.StopMove)
|
||||
self._safe_call("Damp", self._bot.Damp)
|
||||
self._safe_call("SetFsmId(4)", self._bot.SetFsmId, 4)
|
||||
try:
|
||||
# Height ramp OUTSIDE the lock so E-STOP can preempt at any time.
|
||||
h = 0.02
|
||||
while h <= 0.5 + 1e-9:
|
||||
if self._stop_gen != my_gen:
|
||||
log.warning("PREP cancelled (E-STOP)")
|
||||
return {"ok": False, "mode": "prep", "cancelled": True, "simulated": False}
|
||||
with self._lock:
|
||||
self._safe_call("SetStandHeight", self._bot.SetStandHeight, round(h, 3))
|
||||
time.sleep(0.03)
|
||||
h += 0.02
|
||||
with self._lock:
|
||||
self._safe_call("BalanceStand", self._bot.BalanceStand, 0)
|
||||
self._safe_call("SetStandHeight", self._bot.SetStandHeight, 0.22)
|
||||
finally:
|
||||
with self._lock:
|
||||
self._discrete_busy = False
|
||||
log.info("PREP complete")
|
||||
return {"ok": True, "mode": "prep", "simulated": False}
|
||||
|
||||
def ready_start_mode(self) -> dict:
|
||||
"""READY = PREP then Start (FSM 200 / balance engaged)."""
|
||||
self.prep_mode()
|
||||
if not self._ensure_client():
|
||||
return {"ok": True, "mode": "ready", "simulated": True}
|
||||
with self._lock:
|
||||
if hasattr(self._bot, "Start"):
|
||||
ok, _ = self._safe_call("Start", self._bot.Start)
|
||||
else:
|
||||
ok, _ = self._safe_call("SetFsmId(200)", self._bot.SetFsmId, 200)
|
||||
log.info("READY/START complete")
|
||||
return {"ok": bool(ok), "mode": "ready", "simulated": False}
|
||||
|
||||
def posture(self, name: str) -> dict:
|
||||
if name not in _POSTURES:
|
||||
return {"ok": False, "reason": f"unknown posture: {name}"}
|
||||
if not self._ensure_client():
|
||||
return {"ok": True, "posture": name, "simulated": True}
|
||||
bot = self._bot
|
||||
with self._lock:
|
||||
if name == "zero_torque":
|
||||
ok, _ = self._safe_call("ZeroTorque", bot.ZeroTorque)
|
||||
elif name == "damp":
|
||||
ok, _ = self._safe_call("Damp", bot.Damp)
|
||||
elif name == "stand_up":
|
||||
ok, _ = self._safe_call("SetFsmId(4)", bot.SetFsmId, 4)
|
||||
elif name == "squat":
|
||||
ok, _ = self._safe_call("SetFsmId(2)", bot.SetFsmId, 2)
|
||||
elif name == "sit":
|
||||
ok, _ = self._safe_call("Sit", bot.Sit)
|
||||
elif name == "low_stand":
|
||||
ok, _ = self._safe_call("LowStand", bot.LowStand)
|
||||
elif name == "high_stand":
|
||||
ok, _ = self._safe_call("HighStand", bot.HighStand)
|
||||
elif name == "lie_to_stand":
|
||||
if hasattr(bot, "Lie2StandUp"):
|
||||
ok, _ = self._safe_call("Lie2StandUp", bot.Lie2StandUp)
|
||||
else:
|
||||
ok, _ = self._safe_call("SetFsmId(702)", bot.SetFsmId, 702)
|
||||
else: # unreachable (guarded above)
|
||||
ok = False
|
||||
return {"ok": bool(ok), "posture": name, "simulated": False}
|
||||
|
||||
def set_balance_mode(self, mode: int) -> dict:
|
||||
if not self._ensure_client():
|
||||
return {"ok": True, "balance_mode": int(mode), "simulated": True}
|
||||
with self._lock:
|
||||
ok, _ = self._safe_call("SetBalanceMode", self._bot.SetBalanceMode, int(mode))
|
||||
return {"ok": bool(ok), "balance_mode": int(mode), "simulated": False}
|
||||
|
||||
def set_stand_height(self, h: float) -> dict:
|
||||
if not self._ensure_client():
|
||||
return {"ok": True, "height": float(h), "simulated": True}
|
||||
with self._lock:
|
||||
ok, _ = self._safe_call("SetStandHeight", self._bot.SetStandHeight, float(h))
|
||||
return {"ok": bool(ok), "height": float(h), "simulated": False}
|
||||
|
||||
# ── MotionSwitcher ───────────────────────────────────────────────────────
|
||||
|
||||
def msc_check(self) -> dict:
|
||||
if not self._ensure_client() or self._msc is None:
|
||||
return {"mode_name": None, "simulated": not self._lc_ready}
|
||||
try:
|
||||
ret = self._msc.CheckMode()
|
||||
name = None
|
||||
if isinstance(ret, tuple) and len(ret) >= 2 and isinstance(ret[1], dict):
|
||||
name = ret[1].get("name")
|
||||
elif isinstance(ret, dict):
|
||||
name = ret.get("name")
|
||||
self._last_msc_mode = name
|
||||
return {"mode_name": name}
|
||||
except Exception as exc:
|
||||
log.error("msc_check failed: %s", exc)
|
||||
return {"mode_name": None}
|
||||
|
||||
def msc_select_ai(self) -> dict:
|
||||
if not self._ensure_client() or self._msc is None:
|
||||
return {"ok": True, "simulated": True}
|
||||
with self._lock:
|
||||
ok, _ = self._safe_call("SelectMode(ai)", self._msc.SelectMode, "ai")
|
||||
return {"ok": bool(ok), "simulated": False}
|
||||
|
||||
def msc_release(self) -> dict:
|
||||
if not self._ensure_client() or self._msc is None:
|
||||
return {"ok": True, "simulated": True}
|
||||
with self._lock:
|
||||
ok, _ = self._safe_call("ReleaseMode", self._msc.ReleaseMode)
|
||||
return {"ok": bool(ok), "simulated": False}
|
||||
|
||||
def reconnect(self) -> dict:
|
||||
"""Drop and rebuild Loco + MSC clients (does NOT re-init the DDS factory)."""
|
||||
with self._lock:
|
||||
self._bot = None
|
||||
self._msc = None
|
||||
self._lc_ready = False
|
||||
ok = self._ensure_client()
|
||||
return {"ok": bool(ok), "lc_ready": self._lc_ready}
|
||||
|
||||
# ── reads ────────────────────────────────────────────────────────────────
|
||||
|
||||
def joints(self) -> dict:
|
||||
q: list = []
|
||||
try:
|
||||
if self._arm is not None:
|
||||
q = self._arm.get_current_q()
|
||||
except Exception:
|
||||
q = []
|
||||
out = []
|
||||
for idx in range(12, 29):
|
||||
val = q[idx] if idx < len(q) else 0.0
|
||||
out.append({"idx": idx, "name": JOINT_NAMES.get(idx, f"motor_{idx}"),
|
||||
"q": float(val)})
|
||||
return {"joints": out}
|
||||
|
||||
def status(self) -> dict:
|
||||
# Polling /status lazily brings up the client once arm DDS is ready.
|
||||
self._ensure_client()
|
||||
fid = self.fsm_id() if self._lc_ready else None
|
||||
fmode = self.fsm_mode() if self._lc_ready else None
|
||||
walk_ready = bool(self._lc_ready and fid == 200 and fmode not in (None, 2))
|
||||
return {
|
||||
"sdk_available": _HAS_SDK,
|
||||
"lc_ready": self._lc_ready,
|
||||
"armed": self._armed,
|
||||
"fsm_id": fid,
|
||||
"fsm_mode": fmode,
|
||||
"walk_ready": walk_ready,
|
||||
"msc_mode": self._last_msc_mode,
|
||||
"teleop_active": self._teleop_active,
|
||||
"last_velocity": {"vx": self._cur_v[0], "vy": self._cur_v[1], "vyaw": self._cur_v[2]},
|
||||
"caps": {"walk": self._cap_walk, "run": self._cap_run},
|
||||
"arm_initialized": bool(self._arm is not None and getattr(self._arm, "_initialized", False)),
|
||||
}
|
||||
|
||||
# ── shutdown helper ──────────────────────────────────────────────────────
|
||||
|
||||
def shutdown(self):
|
||||
"""Best-effort StopMove + disarm for process shutdown."""
|
||||
try:
|
||||
self.estop()
|
||||
finally:
|
||||
self.disarm_movement()
|
||||
@ -41,7 +41,7 @@
|
||||
"model_ws_uri": "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent",
|
||||
"voice_name": "Charon",
|
||||
"ws_timeout_sec": 30,
|
||||
"default_system_prompt": "You are Sanad (Bousandah), a wise and friendly Emirati assistant. Speak strictly in the UAE dialect (Khaleeji). Be helpful, concise, and use local greetings like 'Marhaba' and 'Ya Khoy'."
|
||||
"default_system_prompt": "You are Bousandah, a wise and friendly Emirati assistant. Speak strictly in the UAE dialect (Khaleeji). Be helpful, concise, and use local greetings like 'Marhaba' and 'Ya Khoy'."
|
||||
},
|
||||
|
||||
"g1_hardware": {
|
||||
@ -53,7 +53,7 @@
|
||||
|
||||
"script_files": {
|
||||
"_comment": "Filenames (under scripts/) used across voice + dashboard",
|
||||
"persona": "sanad_v2",
|
||||
"persona": "sanad_script.txt",
|
||||
"rules": "sanad_rule.txt",
|
||||
"arm_phrases": "sanad_arm.txt"
|
||||
},
|
||||
|
||||
@ -13,6 +13,20 @@
|
||||
"jsonl_id_start": 100
|
||||
},
|
||||
|
||||
"loco_controller": {
|
||||
"_comment": "G1_Controller/loco_controller.py — manual locomotion. NIC is shared from the arm's DDS init (config core.dds / SANAD_DDS_INTERFACE), not set here.",
|
||||
"cap_walk": 0.6,
|
||||
"cap_run": 1.2,
|
||||
"lin_step": 0.05,
|
||||
"ang_step": 0.2,
|
||||
"watchdog_timeout_sec": 0.5,
|
||||
"arm_block_window_sec": 1.5,
|
||||
"step_duration_sec": 0.6,
|
||||
"step_speed_frac": 0.5,
|
||||
"loco_timeout_sec": 10.0,
|
||||
"msc_timeout_sec": 5.0
|
||||
},
|
||||
|
||||
"macro_player": {
|
||||
"_comment": "motion/macro_player.py — JSONL playback",
|
||||
"ramp_in_steps": 60,
|
||||
|
||||
@ -51,9 +51,12 @@ _REST_ROUTES: list[tuple[str, str, str]] = [
|
||||
("live_subprocess", "/api/live-subprocess", "live-subprocess"),
|
||||
("typed_replay", "/api/typed-replay", "typed-replay"),
|
||||
("recognition", "/api/recognition", "recognition"),
|
||||
("zones", "/api/zones", "zones"),
|
||||
("temp_monitor", "/api/temp", "temperature"),
|
||||
("controller", "/api/controller", "controller"),
|
||||
]
|
||||
|
||||
_WS_ROUTES: list[str] = ["log_stream"]
|
||||
_WS_ROUTES: list[str] = ["log_stream", "motor_temps", "terminal"]
|
||||
|
||||
_loaded_routes: list[str] = []
|
||||
_failed_routes: dict[str, str] = {}
|
||||
|
||||
@ -473,3 +473,450 @@ async def apply_audio():
|
||||
pass
|
||||
return result
|
||||
return await asyncio.to_thread(_do)
|
||||
|
||||
|
||||
# ─────────────────────── Reset endpoints (Pulse + USB) ───────────────────────
|
||||
#
|
||||
# Two distinct recovery paths for the dashboard's audio panel:
|
||||
#
|
||||
# POST /api/audio/reset — SOFT: restart pulseaudio / pipewire-pulse.
|
||||
# Fixes Pulse-side state (stuck profile, lost default sink, crashed
|
||||
# module). Cannot recover a kernel-side missing USB capture descriptor
|
||||
# — snd-usb-audio parses those at probe time and Pulse can't influence
|
||||
# that. Use for "devices look weird" failures.
|
||||
#
|
||||
# POST /api/audio/usb-reset — HARD: unbind+rebind snd-usb-audio scoped
|
||||
# to the Anker VID:PID. Forces snd-usb-audio to re-parse UAC1
|
||||
# descriptors → input profile reappears even after the firmware/USB
|
||||
# handshake dropped it. Use for "Anker mic missing from pactl" — the
|
||||
# symptom soft-reset cannot fix.
|
||||
#
|
||||
# Both gate with module-level locks (no concurrent reset), refuse while Live
|
||||
# Gemini is running or a record is mid-playback, and return structured
|
||||
# before/after diagnostics so the dashboard can show meaningful toasts.
|
||||
|
||||
_RESET_LOCK = threading.Lock()
|
||||
_USB_RESET_LOCK = threading.Lock()
|
||||
|
||||
# Anker PowerConf A3321 — used both for VID:PID matching in sysfs and for
|
||||
# logging. Change here if you add support for a different USB conference
|
||||
# device (Hollyland etc).
|
||||
_USB_RESET_TARGETS = (
|
||||
{"vid": "291a", "pid": "3301", "label": "Anker PowerConf"},
|
||||
)
|
||||
|
||||
|
||||
def _refuse_if_busy() -> None:
|
||||
"""Raise HTTPException(409) if Live Gemini is active or a record is playing.
|
||||
|
||||
Used by both reset endpoints — a userspace audio restart mid-stream
|
||||
leaves the active session in a broken state (PortAudio handle pointing
|
||||
at a dead Pulse, in-flight write() raises, etc.). Cheaper to refuse
|
||||
than to recover.
|
||||
"""
|
||||
try:
|
||||
from Project.Sanad.main import live_sub
|
||||
except Exception:
|
||||
live_sub = None
|
||||
if live_sub is not None:
|
||||
try:
|
||||
st = live_sub.status() or {}
|
||||
except Exception:
|
||||
st = {}
|
||||
state = (st.get("state") or "").lower()
|
||||
if st.get("running") or state not in ("", "stopped", "error"):
|
||||
raise HTTPException(
|
||||
409, f"Stop Live Gemini before resetting audio (state={state or '?'}).",
|
||||
)
|
||||
|
||||
try:
|
||||
from Project.Sanad.main import audio_mgr
|
||||
except Exception:
|
||||
audio_mgr = None
|
||||
if audio_mgr is not None and hasattr(audio_mgr, "playback_status"):
|
||||
try:
|
||||
ps = audio_mgr.playback_status() or {}
|
||||
if ps.get("playing"):
|
||||
raise HTTPException(
|
||||
409, "Stop the active playback before resetting audio.",
|
||||
)
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def _detect_pa_flavour() -> str:
|
||||
"""Return 'pipewire' if pipewire-pulse is the active daemon, else 'pulse'."""
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["pgrep", "-x", "pipewire-pulse"],
|
||||
check=False, capture_output=True, text=True, timeout=1.0,
|
||||
)
|
||||
if r.returncode == 0 and (r.stdout or "").strip():
|
||||
return "pipewire"
|
||||
except (FileNotFoundError, subprocess.SubprocessError):
|
||||
pass
|
||||
return "pulse"
|
||||
|
||||
|
||||
def _kill_audio_daemon(flavour: str) -> dict:
|
||||
"""Issue the restart command for the detected daemon. Non-zero exit is a
|
||||
soft warning (some installs return 1 when there's no daemon to kill)."""
|
||||
if flavour == "pipewire":
|
||||
cmd = ["systemctl", "--user", "restart", "pipewire-pulse.service"]
|
||||
else:
|
||||
cmd = ["pulseaudio", "-k"]
|
||||
try:
|
||||
r = subprocess.run(cmd, check=False, capture_output=True,
|
||||
text=True, timeout=5.0)
|
||||
info = {"cmd": " ".join(cmd), "returncode": r.returncode,
|
||||
"stderr": (r.stderr or "").strip()[:300]}
|
||||
if r.returncode != 0:
|
||||
log.warning("audio reset: %s exited %d (%s)",
|
||||
cmd[0], r.returncode, info["stderr"])
|
||||
return info
|
||||
except FileNotFoundError as exc:
|
||||
return {"cmd": " ".join(cmd), "returncode": -1,
|
||||
"stderr": f"binary missing: {exc}"}
|
||||
except subprocess.TimeoutExpired:
|
||||
return {"cmd": " ".join(cmd), "returncode": -1,
|
||||
"stderr": "timeout (>5s)"}
|
||||
|
||||
|
||||
def _wait_for_pactl(deadline_s: float = 5.0, interval_s: float = 0.2) -> bool:
|
||||
"""Poll `pactl info` until it returns 0 or the deadline expires."""
|
||||
import time as _time
|
||||
end = _time.monotonic() + deadline_s
|
||||
while _time.monotonic() < end:
|
||||
if ad.pactl_available():
|
||||
return True
|
||||
_time.sleep(interval_s)
|
||||
return False
|
||||
|
||||
|
||||
@router.post("/reset")
|
||||
async def reset_audio_subsystem():
|
||||
"""SOFT reset — restart pulseaudio/pipewire-pulse and re-resolve devices.
|
||||
|
||||
Use when devices look stuck, pactl is unavailable, or the wrong sink
|
||||
is being selected. **Does NOT recover a kernel-side missing USB capture
|
||||
descriptor** — for that symptom use /api/audio/usb-reset.
|
||||
"""
|
||||
if os.geteuid() == 0:
|
||||
raise HTTPException(
|
||||
403, "Refusing to reset audio as root — Sanad must run as the "
|
||||
"unitree user so the per-user PulseAudio session is reachable.",
|
||||
)
|
||||
if not _RESET_LOCK.acquire(blocking=False):
|
||||
raise HTTPException(429, "Reset already in progress.")
|
||||
try:
|
||||
_refuse_if_busy()
|
||||
log.info(
|
||||
"audio reset requested (uid=%s PULSE_RUNTIME_PATH=%s XDG_RUNTIME_DIR=%s)",
|
||||
os.geteuid(),
|
||||
os.environ.get("PULSE_RUNTIME_PATH") or "-",
|
||||
os.environ.get("XDG_RUNTIME_DIR") or "-",
|
||||
)
|
||||
try:
|
||||
from Project.Sanad.main import audio_mgr
|
||||
except Exception:
|
||||
audio_mgr = None
|
||||
|
||||
def _do() -> dict:
|
||||
before = {"pactl_available": ad.pactl_available(),
|
||||
"selection": ad.current_selection()}
|
||||
|
||||
# Quiesce AudioManager so the next play_wav rebinds cleanly.
|
||||
pya_closed = False
|
||||
if audio_mgr is not None:
|
||||
play_lock = getattr(audio_mgr, "play_lock", None)
|
||||
acquired = False
|
||||
if play_lock is not None:
|
||||
acquired = play_lock.acquire(timeout=2.0)
|
||||
try:
|
||||
try:
|
||||
audio_mgr.close()
|
||||
pya_closed = True
|
||||
except Exception as exc:
|
||||
log.warning("audio reset: audio_mgr.close failed: %s", exc)
|
||||
finally:
|
||||
if acquired and play_lock is not None:
|
||||
play_lock.release()
|
||||
|
||||
flavour = _detect_pa_flavour()
|
||||
kill_info = _kill_audio_daemon(flavour)
|
||||
came_back = _wait_for_pactl(deadline_s=5.0)
|
||||
if not came_back and flavour == "pulse":
|
||||
# autospawn may be disabled — try an explicit start.
|
||||
try:
|
||||
subprocess.run(["pulseaudio", "--start"], check=False,
|
||||
capture_output=True, text=True, timeout=3.0)
|
||||
except (FileNotFoundError, subprocess.SubprocessError) as exc:
|
||||
log.warning("audio reset: pulseaudio --start failed: %s", exc)
|
||||
came_back = _wait_for_pactl(deadline_s=2.0)
|
||||
if not came_back:
|
||||
raise HTTPException(500, {
|
||||
"error": "audio daemon did not return within ~7s",
|
||||
"flavour": flavour, "kill": kill_info,
|
||||
})
|
||||
|
||||
apply_result: dict = {}
|
||||
try:
|
||||
apply_result = ad.apply_current_selection() or {}
|
||||
except Exception as exc:
|
||||
log.warning("audio reset: apply_current_selection failed: %s", exc)
|
||||
apply_result = {"error": str(exc)}
|
||||
|
||||
if audio_mgr is not None:
|
||||
try:
|
||||
import pyaudio
|
||||
audio_mgr.pya = pyaudio.PyAudio()
|
||||
audio_mgr.refresh_devices()
|
||||
except Exception as exc:
|
||||
log.error("audio reset: PyAudio re-init failed: %s", exc)
|
||||
raise HTTPException(
|
||||
500, f"PortAudio re-init failed after daemon restart: {exc}")
|
||||
|
||||
after_sel = ad.current_selection() or {}
|
||||
detected = ad.detect_plugged_profiles() or []
|
||||
after = {
|
||||
"pactl_available": ad.pactl_available(),
|
||||
"selection": after_sel,
|
||||
"detected_profiles": [p.get("profile", {}).get("id") for p in detected],
|
||||
}
|
||||
return {
|
||||
"ok": True, "best_effort": True, "flavour": flavour,
|
||||
"kill": kill_info, "pya_reinitialized": pya_closed,
|
||||
"apply_result": apply_result,
|
||||
"input_recovered": bool(after_sel.get("source")),
|
||||
"output_recovered": bool(after_sel.get("sink")),
|
||||
"before": before, "after": after,
|
||||
"hint": ("Soft reset only fixes Pulse-side state. If "
|
||||
"input_recovered is False, try POST /api/audio/usb-reset "
|
||||
"or physically replug the dongle."),
|
||||
}
|
||||
return await asyncio.to_thread(_do)
|
||||
finally:
|
||||
_RESET_LOCK.release()
|
||||
|
||||
|
||||
def _find_usb_devices_by_vid_pid(vid: str, pid: str) -> list[str]:
|
||||
"""Return sysfs bus-id strings (e.g. '1-3') for every USB device whose
|
||||
idVendor/idProduct match. Empty list when nothing matches.
|
||||
|
||||
We read /sys/bus/usb/devices/* — every USB *device* (not interface) has
|
||||
idVendor/idProduct files. Interfaces (paths with a colon, e.g. '1-3:1.1')
|
||||
do not, so they're naturally skipped.
|
||||
"""
|
||||
import glob
|
||||
hits: list[str] = []
|
||||
for path in glob.glob("/sys/bus/usb/devices/*"):
|
||||
name = os.path.basename(path)
|
||||
if ":" in name:
|
||||
continue
|
||||
try:
|
||||
with open(os.path.join(path, "idVendor")) as f:
|
||||
v = f.read().strip().lower()
|
||||
with open(os.path.join(path, "idProduct")) as f:
|
||||
p = f.read().strip().lower()
|
||||
except OSError:
|
||||
continue
|
||||
if v == vid.lower() and p == pid.lower():
|
||||
hits.append(name)
|
||||
return hits
|
||||
|
||||
|
||||
def _snd_usb_interfaces_for_device(bus_id: str) -> list[str]:
|
||||
"""For USB device `bus_id` (e.g. '1-3'), return all interface names that
|
||||
are currently bound to the snd-usb-audio driver (e.g. ['1-3:1.0']).
|
||||
|
||||
Used so we unbind ONLY the audio interfaces and don't touch HID / HUB
|
||||
interfaces on the same composite device.
|
||||
"""
|
||||
import glob
|
||||
bound: list[str] = []
|
||||
base = f"/sys/bus/usb/devices/{bus_id}"
|
||||
for iface in glob.glob(f"{base}/{bus_id}:*"):
|
||||
driver_link = os.path.join(iface, "driver")
|
||||
if not os.path.islink(driver_link):
|
||||
continue
|
||||
try:
|
||||
driver = os.path.basename(os.readlink(driver_link))
|
||||
except OSError:
|
||||
continue
|
||||
if driver == "snd-usb-audio":
|
||||
bound.append(os.path.basename(iface))
|
||||
return bound
|
||||
|
||||
|
||||
def _write_sysfs(path: str, value: str) -> tuple[bool, str]:
|
||||
"""Write `value` to a sysfs file. Returns (success, error_message).
|
||||
|
||||
Writes to /sys/bus/usb/drivers/snd-usb-audio/{bind,unbind} usually
|
||||
require root. If permission denied, the caller should fall back to
|
||||
invoking shell_scripts/reset_anker_usb.sh via sudo (one-time sudoers
|
||||
setup documented in that script's header).
|
||||
"""
|
||||
try:
|
||||
with open(path, "w") as f:
|
||||
f.write(value)
|
||||
return True, ""
|
||||
except PermissionError as exc:
|
||||
return False, f"permission denied: {path} ({exc})"
|
||||
except OSError as exc:
|
||||
return False, f"write failed: {path} ({exc})"
|
||||
|
||||
|
||||
@router.post("/usb-reset")
|
||||
async def usb_reset_anker():
|
||||
"""HARD reset — unbind+rebind snd-usb-audio for the Anker (VID:PID
|
||||
291a:3301). Forces the kernel to re-parse the USB Audio Class
|
||||
descriptors, which is the only way to recover a missing capture profile
|
||||
on this Jetson without a physical replug.
|
||||
|
||||
Tries two paths:
|
||||
1. Direct sysfs write (no sudo) — works if a udev rule has set
|
||||
`audio` group ownership / world-write on the snd-usb-audio bind
|
||||
files, or if Sanad runs as root (it shouldn't).
|
||||
2. Fallback to `sudo shell_scripts/reset_anker_usb.sh` — works after
|
||||
a one-time sudoers entry; see that script's header for setup.
|
||||
|
||||
Refuses while Live Gemini or a record playback is in flight (same
|
||||
guard as the soft reset).
|
||||
"""
|
||||
if not _USB_RESET_LOCK.acquire(blocking=False):
|
||||
raise HTTPException(429, "USB reset already in progress.")
|
||||
try:
|
||||
_refuse_if_busy()
|
||||
|
||||
# Find candidate Anker USB devices currently enumerated.
|
||||
candidates: list[dict] = []
|
||||
for tgt in _USB_RESET_TARGETS:
|
||||
for bus_id in _find_usb_devices_by_vid_pid(tgt["vid"], tgt["pid"]):
|
||||
candidates.append({"bus_id": bus_id, **tgt})
|
||||
if not candidates:
|
||||
wanted = ", ".join(
|
||||
"{}:{}".format(t["vid"], t["pid"]) for t in _USB_RESET_TARGETS
|
||||
)
|
||||
raise HTTPException(
|
||||
404,
|
||||
f"No matching USB device found (looked for {wanted}). "
|
||||
"Plug the Anker dongle and try again.",
|
||||
)
|
||||
|
||||
log.info("usb reset: candidates=%s", candidates)
|
||||
|
||||
def _do() -> dict:
|
||||
before_detected = [
|
||||
p.get("profile", {}).get("id")
|
||||
for p in (ad.detect_plugged_profiles() or [])
|
||||
]
|
||||
results: list[dict] = []
|
||||
for cand in candidates:
|
||||
bus = cand["bus_id"]
|
||||
ifaces = _snd_usb_interfaces_for_device(bus)
|
||||
attempt = {"bus_id": bus, "label": cand["label"],
|
||||
"snd_interfaces": ifaces, "method": None,
|
||||
"ok": False, "error": ""}
|
||||
if not ifaces:
|
||||
attempt["error"] = ("no snd-usb-audio interfaces bound "
|
||||
"to this device — already unbound or "
|
||||
"kernel didn't claim it")
|
||||
results.append(attempt)
|
||||
continue
|
||||
|
||||
# ─── Path 1: direct sysfs write ───
|
||||
unbind_path = "/sys/bus/usb/drivers/snd-usb-audio/unbind"
|
||||
bind_path = "/sys/bus/usb/drivers/snd-usb-audio/bind"
|
||||
direct_ok = True
|
||||
direct_err = ""
|
||||
for iface in ifaces:
|
||||
ok, err = _write_sysfs(unbind_path, iface)
|
||||
if not ok:
|
||||
direct_ok = False
|
||||
direct_err = err
|
||||
break
|
||||
if direct_ok:
|
||||
import time as _time
|
||||
_time.sleep(0.5)
|
||||
for iface in ifaces:
|
||||
ok, err = _write_sysfs(bind_path, iface)
|
||||
if not ok:
|
||||
direct_ok = False
|
||||
direct_err = err
|
||||
break
|
||||
if direct_ok:
|
||||
attempt.update({"method": "direct-sysfs", "ok": True})
|
||||
results.append(attempt)
|
||||
continue
|
||||
|
||||
# ─── Path 2: sudo helper script ───
|
||||
from pathlib import Path as _Path
|
||||
helper = (_Path(__file__).resolve().parent.parent.parent
|
||||
/ "shell_scripts" / "reset_anker_usb.sh")
|
||||
if not helper.exists():
|
||||
attempt.update({"method": "direct-sysfs",
|
||||
"error": f"{direct_err}; helper not present "
|
||||
f"at {helper}"})
|
||||
results.append(attempt)
|
||||
continue
|
||||
try:
|
||||
r = subprocess.run(
|
||||
["sudo", "-n", str(helper), bus],
|
||||
check=False, capture_output=True, text=True, timeout=10.0,
|
||||
)
|
||||
attempt["method"] = "sudo-helper"
|
||||
if r.returncode == 0:
|
||||
attempt["ok"] = True
|
||||
else:
|
||||
attempt["error"] = (
|
||||
f"sudo helper exited {r.returncode}: "
|
||||
f"{(r.stderr or r.stdout or '').strip()[:300]}"
|
||||
)
|
||||
except subprocess.TimeoutExpired:
|
||||
attempt["error"] = "sudo helper timed out (>10s)"
|
||||
except FileNotFoundError as exc:
|
||||
attempt["error"] = f"sudo not available: {exc}"
|
||||
results.append(attempt)
|
||||
|
||||
# Settle, then re-detect
|
||||
import time as _time
|
||||
_time.sleep(1.0)
|
||||
try:
|
||||
ad.apply_current_selection()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from Project.Sanad.main import audio_mgr
|
||||
if audio_mgr is not None and hasattr(audio_mgr, "refresh_devices"):
|
||||
audio_mgr.refresh_devices()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
after_detected = [
|
||||
p.get("profile", {}).get("id")
|
||||
for p in (ad.detect_plugged_profiles() or [])
|
||||
]
|
||||
any_ok = any(r.get("ok") for r in results)
|
||||
mic_now = any(
|
||||
"anker" in (p.get("profile", {}).get("id") or "").lower()
|
||||
for p in (ad.detect_plugged_profiles() or [])
|
||||
)
|
||||
|
||||
return {
|
||||
"ok": any_ok,
|
||||
"candidates": results,
|
||||
"before_detected_profiles": before_detected,
|
||||
"after_detected_profiles": after_detected,
|
||||
"input_recovered": mic_now,
|
||||
"hint": (
|
||||
"If ok is False, the unbind/rebind path needs sudo. "
|
||||
"Run `bash shell_scripts/reset_anker_usb.sh --setup-sudoers` "
|
||||
"once on the robot to install the sudoers entry, then retry."
|
||||
) if not any_ok else None,
|
||||
}
|
||||
|
||||
return await asyncio.to_thread(_do)
|
||||
finally:
|
||||
_USB_RESET_LOCK.release()
|
||||
|
||||
295
dashboard/routes/controller.py
Normal file
295
dashboard/routes/controller.py
Normal file
@ -0,0 +1,295 @@
|
||||
"""Controller tab — manual dashboard locomotion control (N2 Phase 1/2).
|
||||
|
||||
Routes live under /api/controller. All WRITE actions (move / step / postures /
|
||||
modes / MotionSwitcher) require the in-memory "Enable movement" arm flag and
|
||||
return 409 when disarmed. Reads (/status, /joints, /msc, /status/summary),
|
||||
E-STOP and the arm toggle are ALWAYS available.
|
||||
|
||||
`/status/summary` is the aggregate the dashboard polls for the global subsystem
|
||||
status strip (Camera / Face / Place / Movement). It is kept under /api/controller
|
||||
(final path /api/controller/status/summary) so no second router is needed; note
|
||||
/api/status (no /summary) is already used by the SPA, so the suffix matters.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
from pydantic import BaseModel
|
||||
|
||||
from Project.Sanad.config import BASE_DIR
|
||||
from Project.Sanad.core.logger import get_logger
|
||||
from Project.Sanad.vision import recognition_state
|
||||
|
||||
log = get_logger("controller_routes")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STATE_PATH = BASE_DIR / "data" / ".recognition_state.json"
|
||||
|
||||
|
||||
# ── lazy subsystem accessors ────────────────────────────────
|
||||
|
||||
def _get_loco():
|
||||
try:
|
||||
from Project.Sanad.main import loco_controller # type: ignore
|
||||
return loco_controller
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_camera():
|
||||
try:
|
||||
from Project.Sanad.main import camera # type: ignore
|
||||
return camera
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_live_sub():
|
||||
try:
|
||||
from Project.Sanad.main import live_sub # type: ignore
|
||||
return live_sub
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_dispatch():
|
||||
try:
|
||||
from Project.Sanad.main import movement_dispatch # type: ignore
|
||||
return movement_dispatch
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _require_loco():
|
||||
lc = _get_loco()
|
||||
if lc is None:
|
||||
raise HTTPException(503, "Locomotion controller subsystem unavailable.")
|
||||
return lc
|
||||
|
||||
|
||||
def _require_armed(lc):
|
||||
if not lc.is_armed():
|
||||
raise HTTPException(409, "Movement is disarmed. Enable movement first.")
|
||||
|
||||
|
||||
# ── reads ───────────────────────────────────────────────────
|
||||
|
||||
@router.get("/status")
|
||||
async def get_status():
|
||||
lc = _require_loco()
|
||||
return await asyncio.to_thread(lc.status)
|
||||
|
||||
|
||||
@router.get("/joints")
|
||||
async def get_joints():
|
||||
lc = _require_loco()
|
||||
return await asyncio.to_thread(lc.joints)
|
||||
|
||||
|
||||
@router.get("/msc")
|
||||
async def get_msc():
|
||||
lc = _require_loco()
|
||||
return await asyncio.to_thread(lc.msc_check)
|
||||
|
||||
|
||||
# ── arm flag / E-STOP (always available) ────────────────────
|
||||
|
||||
@router.post("/arm")
|
||||
async def set_arm(on: bool = Query(...)):
|
||||
lc = _require_loco()
|
||||
res = await asyncio.to_thread(lc.arm_movement if on else lc.disarm_movement)
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/gemini-movement")
|
||||
async def set_gemini_movement(on: bool = Query(...)):
|
||||
"""Enable / disable Gemini voice-driven locomotion (N2 Phase 3 gate).
|
||||
|
||||
Writes recognition_state.movement_enabled — SEPARATE from the manual arm
|
||||
flag. The Gemini child announces the toggle (spoken), and the parent
|
||||
MovementDispatcher starts/stops acting on confirmation phrases. Default OFF.
|
||||
"""
|
||||
st = await asyncio.to_thread(recognition_state.mutate, STATE_PATH,
|
||||
movement_enabled=bool(on))
|
||||
# Enabling Gemini movement also clears any E-STOP latch on the dispatcher.
|
||||
if on:
|
||||
md = _get_dispatch()
|
||||
if md is not None:
|
||||
try:
|
||||
md.clear_estop()
|
||||
except Exception:
|
||||
log.exception("clear_estop failed")
|
||||
log.info("gemini-movement %s", "ON" if on else "OFF")
|
||||
return {"ok": True, "movement_enabled": st.movement_enabled}
|
||||
|
||||
|
||||
@router.post("/estop")
|
||||
async def estop():
|
||||
lc = _require_loco()
|
||||
res = await asyncio.to_thread(lc.estop)
|
||||
# Full stop: drop the manual arm flag AND latch the voice dispatcher off, so
|
||||
# no source (teleop, step, or voice dispatch) can keep driving the robot. The
|
||||
# dispatcher latch is used instead of flipping movement_enabled so the Gemini
|
||||
# child does not deliver a spoken "movement disabled" line during an E-STOP.
|
||||
try:
|
||||
await asyncio.to_thread(lc.disarm_movement)
|
||||
except Exception:
|
||||
log.exception("estop disarm failed")
|
||||
md = _get_dispatch()
|
||||
if md is not None:
|
||||
try:
|
||||
md.emergency_stop()
|
||||
except Exception:
|
||||
log.exception("estop dispatcher latch failed")
|
||||
return {"ok": True, **res}
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop():
|
||||
lc = _require_loco()
|
||||
# Allowed even when disarmed — StopMove is always safe.
|
||||
res = await asyncio.to_thread(lc.stop_move)
|
||||
return res
|
||||
|
||||
|
||||
# ── movement (armed) ────────────────────────────────────────
|
||||
|
||||
class MoveBody(BaseModel):
|
||||
vx: float = 0.0
|
||||
vy: float = 0.0
|
||||
vyaw: float = 0.0
|
||||
run: bool = False
|
||||
|
||||
|
||||
@router.post("/move")
|
||||
async def move(body: MoveBody):
|
||||
lc = _require_loco()
|
||||
_require_armed(lc)
|
||||
return await asyncio.to_thread(lc.move, body.vx, body.vy, body.vyaw, body.run)
|
||||
|
||||
|
||||
@router.post("/step")
|
||||
async def step(dir: str = Query(...)):
|
||||
lc = _require_loco()
|
||||
_require_armed(lc)
|
||||
res = await asyncio.to_thread(lc.step, dir)
|
||||
if not res.get("ok"):
|
||||
raise HTTPException(400, res.get("reason", "step failed"))
|
||||
return res
|
||||
|
||||
|
||||
# ── modes / postures (armed) ────────────────────────────────
|
||||
|
||||
@router.post("/mode/prep")
|
||||
async def mode_prep():
|
||||
lc = _require_loco()
|
||||
_require_armed(lc)
|
||||
return await asyncio.to_thread(lc.prep_mode)
|
||||
|
||||
|
||||
@router.post("/mode/ready")
|
||||
async def mode_ready():
|
||||
lc = _require_loco()
|
||||
_require_armed(lc)
|
||||
return await asyncio.to_thread(lc.ready_start_mode)
|
||||
|
||||
|
||||
@router.post("/posture/{name}")
|
||||
async def posture(name: str):
|
||||
lc = _require_loco()
|
||||
_require_armed(lc)
|
||||
res = await asyncio.to_thread(lc.posture, name)
|
||||
if not res.get("ok") and res.get("reason"):
|
||||
raise HTTPException(400, res["reason"])
|
||||
return res
|
||||
|
||||
|
||||
@router.post("/balance")
|
||||
async def balance(mode: int = Query(...)):
|
||||
lc = _require_loco()
|
||||
_require_armed(lc)
|
||||
return await asyncio.to_thread(lc.set_balance_mode, mode)
|
||||
|
||||
|
||||
@router.post("/height")
|
||||
async def height(h: float = Query(...)):
|
||||
lc = _require_loco()
|
||||
_require_armed(lc)
|
||||
return await asyncio.to_thread(lc.set_stand_height, h)
|
||||
|
||||
|
||||
# ── MotionSwitcher / reconnect (armed) ──────────────────────
|
||||
|
||||
@router.post("/msc/select-ai")
|
||||
async def msc_select_ai():
|
||||
lc = _require_loco()
|
||||
_require_armed(lc)
|
||||
return await asyncio.to_thread(lc.msc_select_ai)
|
||||
|
||||
|
||||
@router.post("/msc/release")
|
||||
async def msc_release():
|
||||
lc = _require_loco()
|
||||
_require_armed(lc)
|
||||
return await asyncio.to_thread(lc.msc_release)
|
||||
|
||||
|
||||
@router.post("/reconnect")
|
||||
async def reconnect():
|
||||
lc = _require_loco()
|
||||
_require_armed(lc)
|
||||
return await asyncio.to_thread(lc.reconnect)
|
||||
|
||||
|
||||
# ── aggregate subsystem summary (always available) ──────────
|
||||
|
||||
@router.get("/status/summary")
|
||||
async def status_summary():
|
||||
"""Live on/off state for the header status strip. Never raises."""
|
||||
try:
|
||||
st = recognition_state.read(STATE_PATH)
|
||||
except Exception:
|
||||
st = recognition_state.RecognitionState()
|
||||
|
||||
cam = _get_camera()
|
||||
camera_running = False
|
||||
try:
|
||||
camera_running = bool(cam is not None and cam.is_running())
|
||||
except Exception:
|
||||
camera_running = False
|
||||
|
||||
lc = _get_loco()
|
||||
movement_armed = False
|
||||
try:
|
||||
movement_armed = bool(lc is not None and lc.is_armed())
|
||||
except Exception:
|
||||
movement_armed = False
|
||||
|
||||
sub = _get_live_sub()
|
||||
gemini_running = False
|
||||
try:
|
||||
runner = getattr(sub, "is_running", None)
|
||||
gemini_running = bool(callable(runner) and runner())
|
||||
except Exception:
|
||||
gemini_running = False
|
||||
|
||||
# Effective Gemini-movement = the file flag AND not latched off by an E-STOP.
|
||||
md = _get_dispatch()
|
||||
estopped = False
|
||||
try:
|
||||
estopped = bool(md is not None and md.is_estopped())
|
||||
except Exception:
|
||||
estopped = False
|
||||
|
||||
return {
|
||||
"vision_enabled": st.vision_enabled,
|
||||
"camera_running": camera_running,
|
||||
"face_rec_enabled": st.face_rec_enabled,
|
||||
"zone_rec_enabled": st.zone_rec_enabled,
|
||||
"movement_armed": movement_armed,
|
||||
"gemini_movement_enabled": st.movement_enabled and not estopped,
|
||||
"gemini_running": gemini_running,
|
||||
}
|
||||
@ -15,6 +15,22 @@ log = get_logger("macros_route")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _block_if_movement_armed():
|
||||
"""409 when locomotion movement is armed — arm motion is mutually exclusive
|
||||
with walking. The arm controller's motion-block is the safety net."""
|
||||
try:
|
||||
from Project.Sanad.main import loco_controller # type: ignore
|
||||
armed = loco_controller is not None and loco_controller.is_armed()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
if armed:
|
||||
raise HTTPException(
|
||||
409, "Arm actions are disabled while movement is enabled. "
|
||||
"Disable movement in the Controller tab first.")
|
||||
|
||||
|
||||
class MacroName(BaseModel):
|
||||
name: str
|
||||
|
||||
@ -63,6 +79,7 @@ async def stop_recording():
|
||||
@router.post("/play")
|
||||
async def play_macro(payload: MacroName):
|
||||
from Project.Sanad.main import brain
|
||||
_block_if_movement_armed()
|
||||
return await brain.play_macro(payload.name)
|
||||
|
||||
|
||||
@ -157,6 +174,8 @@ async def play_combined(payload: ComboPlayPayload):
|
||||
has_motion = payload.action_id is not None or bool(payload.motion_file)
|
||||
if not has_audio and not has_motion:
|
||||
raise HTTPException(400, "pick at least one of audio_file / action_id / motion_file")
|
||||
if has_motion:
|
||||
_block_if_movement_armed() # audio-only combos still allowed while armed
|
||||
|
||||
tasks = []
|
||||
result: dict = {
|
||||
|
||||
@ -10,6 +10,23 @@ from pydantic import BaseModel
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _block_if_movement_armed():
|
||||
"""409 if locomotion movement is armed — arm actions are mutually exclusive
|
||||
with walking. The arm controller's own motion-block is the safety net; this
|
||||
just gives the dashboard a clear message instead of a silent no-op."""
|
||||
try:
|
||||
from Project.Sanad.main import loco_controller # type: ignore
|
||||
armed = loco_controller is not None and loco_controller.is_armed()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
if armed:
|
||||
raise HTTPException(
|
||||
409, "Arm actions are disabled while movement is enabled. "
|
||||
"Disable movement in the Controller tab first.")
|
||||
|
||||
|
||||
@router.get("/status")
|
||||
async def motion_status():
|
||||
from Project.Sanad.main import arm
|
||||
@ -33,6 +50,7 @@ async def trigger_action(payload: TriggerPayload):
|
||||
from Project.Sanad.main import arm
|
||||
if arm is None:
|
||||
raise HTTPException(503, "Arm controller not attached.")
|
||||
_block_if_movement_armed()
|
||||
|
||||
speed = max(0.1, min(payload.speed, 5.0))
|
||||
|
||||
|
||||
@ -111,8 +111,42 @@ async def play_record(payload: RecordPlay):
|
||||
|
||||
from Project.Sanad.main import audio_mgr
|
||||
import asyncio
|
||||
await asyncio.to_thread(audio_mgr.play_wav, raw_path)
|
||||
return {"ok": True, "record_name": payload.record_name, "file_kind": payload.file_kind, "path": str(raw_path)}
|
||||
# Fire-and-forget — play_wav blocks for the clip duration on the G1
|
||||
# DDS path, and the dashboard's pause / resume / stop / status calls
|
||||
# need to be served while it's running. Without this, /play wouldn't
|
||||
# return until the clip finished and the UI couldn't interact with
|
||||
# the in-flight playback.
|
||||
asyncio.create_task(asyncio.to_thread(
|
||||
audio_mgr.play_wav, raw_path, payload.record_name,
|
||||
))
|
||||
return {"ok": True, "record_name": payload.record_name,
|
||||
"file_kind": payload.file_kind, "path": str(raw_path)}
|
||||
|
||||
|
||||
@router.post("/pause")
|
||||
async def pause_playback():
|
||||
from Project.Sanad.main import audio_mgr
|
||||
return audio_mgr.pause_playback()
|
||||
|
||||
|
||||
@router.post("/resume")
|
||||
async def resume_playback():
|
||||
from Project.Sanad.main import audio_mgr
|
||||
return audio_mgr.resume_playback()
|
||||
|
||||
|
||||
@router.post("/stop")
|
||||
async def stop_playback():
|
||||
from Project.Sanad.main import audio_mgr
|
||||
import asyncio
|
||||
await asyncio.to_thread(audio_mgr.stop_playback)
|
||||
return {"ok": True, "stopped": True}
|
||||
|
||||
|
||||
@router.get("/playback-status")
|
||||
async def playback_status():
|
||||
from Project.Sanad.main import audio_mgr
|
||||
return audio_mgr.playback_status()
|
||||
|
||||
|
||||
class RecordRename(BaseModel):
|
||||
|
||||
@ -21,6 +21,22 @@ log = get_logger("replay_route")
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _block_if_movement_armed():
|
||||
"""409 when locomotion movement is armed — arm motion (replay / teaching) is
|
||||
mutually exclusive with walking."""
|
||||
try:
|
||||
from Project.Sanad.main import loco_controller # type: ignore
|
||||
armed = loco_controller is not None and loco_controller.is_armed()
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception:
|
||||
return
|
||||
if armed:
|
||||
raise HTTPException(
|
||||
409, "Arm actions are disabled while movement is enabled. "
|
||||
"Disable movement in the Controller tab first.")
|
||||
|
||||
|
||||
# -- models --
|
||||
|
||||
class ReplayRequest(BaseModel):
|
||||
@ -94,6 +110,7 @@ _BG_TASKS: set[asyncio.Task] = set()
|
||||
async def test_replay(payload: ReplayRequest):
|
||||
"""Test-play a motion file at the given speed."""
|
||||
from Project.Sanad.main import arm
|
||||
_block_if_movement_armed()
|
||||
if arm.is_busy:
|
||||
raise HTTPException(409, "Arm is busy.")
|
||||
path = safe_path_under(MOTIONS_DIR, payload.name)
|
||||
@ -142,6 +159,7 @@ async def start_teaching(payload: TeachRequest):
|
||||
from Project.Sanad.main import teacher
|
||||
if teacher is None:
|
||||
raise HTTPException(503, "Teaching module not available.")
|
||||
_block_if_movement_armed()
|
||||
if teacher.is_recording:
|
||||
raise HTTPException(409, "Teaching session already active.")
|
||||
existing = MOTIONS_DIR / f"{payload.name}.jsonl"
|
||||
|
||||
67
dashboard/routes/temp_monitor.py
Normal file
67
dashboard/routes/temp_monitor.py
Normal file
@ -0,0 +1,67 @@
|
||||
"""REST endpoints backing the 3D motor-temperature dashboard (N1).
|
||||
|
||||
Serves the motor name/mesh mapping + thresholds, and a one-shot temperature
|
||||
snapshot (the front-end's initial fetch fallback). The live stream is over
|
||||
/ws/motor-temps (dashboard/websockets/motor_temps.py). The 3D view itself is
|
||||
the static page at /static/temp3d/index.html.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter
|
||||
|
||||
from Project.Sanad.dashboard.temp_motor_map import (
|
||||
MOTOR_NAMES,
|
||||
MOTOR_TO_MESH,
|
||||
TEMP_HOT_THRESHOLD,
|
||||
TEMP_MAX,
|
||||
TEMP_MIN,
|
||||
TEMP_WARM_THRESHOLD,
|
||||
build_payload,
|
||||
)
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _get_arm():
|
||||
"""Lazy import — avoids a circular import on dashboard load."""
|
||||
try:
|
||||
from Project.Sanad.main import arm # type: ignore
|
||||
return arm
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/mapping")
|
||||
async def motor_mapping():
|
||||
"""Motor id → name / mesh map + the temperature gradient thresholds."""
|
||||
return {
|
||||
"motor_names": MOTOR_NAMES,
|
||||
"motor_to_mesh": MOTOR_TO_MESH,
|
||||
"thresholds": {
|
||||
"min": TEMP_MIN,
|
||||
"max": TEMP_MAX,
|
||||
"warm": TEMP_WARM_THRESHOLD,
|
||||
"hot": TEMP_HOT_THRESHOLD,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.get("/motors")
|
||||
async def motors_snapshot():
|
||||
"""One-shot motor temperature + position snapshot (Marcus payload shape)."""
|
||||
arm = _get_arm()
|
||||
temps: list = []
|
||||
positions: list = []
|
||||
if arm is not None:
|
||||
try:
|
||||
temps = arm.get_motor_temps()
|
||||
except Exception:
|
||||
temps = []
|
||||
try:
|
||||
positions = arm.get_current_q()
|
||||
except Exception:
|
||||
positions = []
|
||||
return build_payload(temps, positions, time.time())
|
||||
421
dashboard/routes/zones.py
Normal file
421
dashboard/routes/zones.py
Normal file
@ -0,0 +1,421 @@
|
||||
"""Zones tab — zone → place → linked-faces management + "go here" destination.
|
||||
|
||||
Hierarchy (replaces the old flat places):
|
||||
Zone (name + description)
|
||||
└─ Place (name + description + optional reference photos + linked face ids)
|
||||
|
||||
Routes live under /api/zones. Toggle + CRUD changes write
|
||||
data/.recognition_state.json (the SAME file faces use); the Gemini child polls
|
||||
it at 1 Hz and re-primes / announces mid-session. The "go here" endpoints set a
|
||||
navigation target the robot will head to once N2 locomotion is wired — for now
|
||||
they just record the target and feed Gemini the place's reference.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
from typing import Optional
|
||||
|
||||
from fastapi import APIRouter, File, HTTPException, Query, UploadFile
|
||||
from fastapi.responses import FileResponse, StreamingResponse
|
||||
from pydantic import BaseModel
|
||||
|
||||
from Project.Sanad.config import BASE_DIR
|
||||
from Project.Sanad.core.logger import get_logger
|
||||
from Project.Sanad.dashboard.routes._safe_io import check_upload_size
|
||||
from Project.Sanad.vision import recognition_state
|
||||
|
||||
log = get_logger("zones_routes")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
STATE_PATH = BASE_DIR / "data" / ".recognition_state.json"
|
||||
|
||||
|
||||
# ── lazy subsystem accessors ────────────────────────────────
|
||||
|
||||
def _get_camera():
|
||||
try:
|
||||
from Project.Sanad.main import camera # type: ignore
|
||||
return camera
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_zone_gallery():
|
||||
try:
|
||||
from Project.Sanad.main import zone_gallery # type: ignore
|
||||
return zone_gallery
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _get_face_gallery():
|
||||
try:
|
||||
from Project.Sanad.main import gallery # type: ignore
|
||||
return gallery
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def _require_zones():
|
||||
g = _get_zone_gallery()
|
||||
if g is None:
|
||||
raise HTTPException(503, "Zone gallery subsystem unavailable.")
|
||||
return g
|
||||
|
||||
|
||||
def _bump_zones_version() -> int:
|
||||
cur = recognition_state.read(STATE_PATH)
|
||||
v = cur.zones_version + 1
|
||||
recognition_state.mutate(STATE_PATH, zones_version=v)
|
||||
return v
|
||||
|
||||
|
||||
def _validate_image(content: bytes, filename: str | None = None) -> None:
|
||||
check_upload_size(content)
|
||||
if len(content) < 16:
|
||||
raise HTTPException(400, "Image too small / empty.")
|
||||
if not (content[:3] == b"\xff\xd8\xff" or content[:8] == b"\x89PNG\r\n\x1a\n"):
|
||||
raise HTTPException(400, f"Only JPEG/PNG accepted (got {filename or 'unknown'}).")
|
||||
|
||||
|
||||
def _safe_photo_name(name: str) -> None:
|
||||
if "/" in name or ".." in name or "\x00" in name:
|
||||
raise HTTPException(400, "Invalid photo name.")
|
||||
|
||||
|
||||
def _resolve_faces(face_ids: list[int]) -> list[dict]:
|
||||
"""Turn linked face ids into [{id, name}] using the face gallery."""
|
||||
g = _get_face_gallery()
|
||||
out = []
|
||||
for fid in face_ids:
|
||||
name = None
|
||||
if g is not None:
|
||||
try:
|
||||
e = g.get(fid)
|
||||
name = e.name if e else None
|
||||
except Exception:
|
||||
name = None
|
||||
out.append({"id": fid, "name": name})
|
||||
return out
|
||||
|
||||
|
||||
def _place_to_dict(p) -> dict:
|
||||
d = p.to_dict()
|
||||
d["faces"] = _resolve_faces(p.face_ids)
|
||||
return d
|
||||
|
||||
|
||||
def _zone_to_dict(z) -> dict:
|
||||
return {
|
||||
"id": z.id, "name": z.name, "description": z.description,
|
||||
"added_at": z.added_at,
|
||||
"places": [_place_to_dict(p) for p in z.places],
|
||||
}
|
||||
|
||||
|
||||
def _nav_target_dict(st, gallery) -> Optional[dict]:
|
||||
zid, pid = st.nav_target_zone_id, st.nav_target_place_id
|
||||
if not zid or not pid:
|
||||
return None
|
||||
zone_name = place_name = None
|
||||
if gallery is not None:
|
||||
try:
|
||||
z = gallery.get_zone(zid)
|
||||
zone_name = z.name if z else None
|
||||
p = gallery.get_place(zid, pid)
|
||||
place_name = p.name if p else None
|
||||
except Exception:
|
||||
pass
|
||||
return {"zone_id": zid, "place_id": pid,
|
||||
"zone_name": zone_name, "place_name": place_name}
|
||||
|
||||
|
||||
# ── state + toggle ──────────────────────────────────────────
|
||||
|
||||
@router.get("/state")
|
||||
async def get_state():
|
||||
st = recognition_state.read(STATE_PATH)
|
||||
g = _get_zone_gallery()
|
||||
zones_count = places_count = 0
|
||||
if g is not None:
|
||||
try:
|
||||
zones = g.list_zones()
|
||||
zones_count = len(zones)
|
||||
places_count = sum(len(z.places) for z in zones)
|
||||
except Exception:
|
||||
pass
|
||||
return {
|
||||
"zone_rec_enabled": st.zone_rec_enabled,
|
||||
"zones_version": st.zones_version,
|
||||
"zones_count": zones_count,
|
||||
"places_count": places_count,
|
||||
"nav_target": _nav_target_dict(st, g),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/zone-rec")
|
||||
async def set_zone_rec(on: bool = Query(...)):
|
||||
"""Enable / disable the robot's knowledge of zones & places (hot)."""
|
||||
st = recognition_state.mutate(STATE_PATH, zone_rec_enabled=bool(on))
|
||||
log.info("zone recognition %s", "ON" if on else "OFF")
|
||||
return {"ok": True, "zone_rec_enabled": st.zone_rec_enabled}
|
||||
|
||||
|
||||
@router.post("/sync")
|
||||
async def sync_zones():
|
||||
v = _bump_zones_version()
|
||||
log.info("zones sync requested → v.%d", v)
|
||||
return {"ok": True, "zones_version": v}
|
||||
|
||||
|
||||
# ── zones CRUD ──────────────────────────────────────────────
|
||||
|
||||
class NamePayload(BaseModel):
|
||||
name: Optional[str] = None
|
||||
|
||||
|
||||
class DescribePayload(BaseModel):
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class FacesPayload(BaseModel):
|
||||
face_ids: list[int] = []
|
||||
|
||||
|
||||
@router.get("")
|
||||
async def list_zones():
|
||||
g = _require_zones()
|
||||
zones = g.list_zones()
|
||||
return {"zones": [_zone_to_dict(z) for z in zones], "total": len(zones)}
|
||||
|
||||
|
||||
@router.post("/create")
|
||||
async def create_zone(name: Optional[str] = Query(default=None),
|
||||
description: Optional[str] = Query(default=None)):
|
||||
g = _require_zones()
|
||||
if not (name or "").strip() and not (description or "").strip():
|
||||
raise HTTPException(400, "A zone needs at least a name or a description.")
|
||||
z = g.create_zone(name=name, description=description)
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "zone": _zone_to_dict(z)}
|
||||
|
||||
|
||||
@router.post("/{zone_id}/rename")
|
||||
async def rename_zone(zone_id: int, payload: NamePayload):
|
||||
g = _require_zones()
|
||||
try:
|
||||
g.rename_zone(zone_id, payload.name)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "zone": _zone_to_dict(g.get_zone(zone_id))}
|
||||
|
||||
|
||||
@router.post("/{zone_id}/describe")
|
||||
async def describe_zone(zone_id: int, payload: DescribePayload):
|
||||
g = _require_zones()
|
||||
try:
|
||||
g.describe_zone(zone_id, payload.description)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "zone": _zone_to_dict(g.get_zone(zone_id))}
|
||||
|
||||
|
||||
@router.delete("/{zone_id}")
|
||||
async def delete_zone(zone_id: int):
|
||||
g = _require_zones()
|
||||
try:
|
||||
g.delete_zone(zone_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
# If the active destination was inside this zone, clear it.
|
||||
st = recognition_state.read(STATE_PATH)
|
||||
if st.nav_target_zone_id == zone_id:
|
||||
recognition_state.mutate(STATE_PATH, nav_target_zone_id=0, nav_target_place_id=0)
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "deleted": zone_id}
|
||||
|
||||
|
||||
# ── places CRUD (within a zone) ─────────────────────────────
|
||||
|
||||
@router.post("/{zone_id}/places/create")
|
||||
async def create_place(
|
||||
zone_id: int,
|
||||
name: Optional[str] = Query(default=None),
|
||||
description: Optional[str] = Query(default=None),
|
||||
face_ids: list[int] = Query(default=[]),
|
||||
files: Optional[list[UploadFile]] = File(default=None),
|
||||
):
|
||||
g = _require_zones()
|
||||
if g.get_zone(zone_id) is None:
|
||||
raise HTTPException(404, f"zone_{zone_id} not found")
|
||||
if not (name or "").strip() and not (description or "").strip():
|
||||
raise HTTPException(400, "A place needs at least a name or a description.")
|
||||
image_bytes: list[bytes] = []
|
||||
for f in (files or []):
|
||||
content = await f.read()
|
||||
if not content:
|
||||
continue
|
||||
_validate_image(content, f.filename)
|
||||
image_bytes.append(content)
|
||||
p = g.create_place(zone_id, name=name, description=description,
|
||||
face_ids=face_ids, image_bytes_list=image_bytes or None)
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "place": _place_to_dict(p)}
|
||||
|
||||
|
||||
@router.post("/{zone_id}/places/{place_id}/rename")
|
||||
async def rename_place(zone_id: int, place_id: int, payload: NamePayload):
|
||||
g = _require_zones()
|
||||
try:
|
||||
g.rename_place(zone_id, place_id, payload.name)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))}
|
||||
|
||||
|
||||
@router.post("/{zone_id}/places/{place_id}/describe")
|
||||
async def describe_place(zone_id: int, place_id: int, payload: DescribePayload):
|
||||
g = _require_zones()
|
||||
try:
|
||||
g.describe_place(zone_id, place_id, payload.description)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))}
|
||||
|
||||
|
||||
@router.post("/{zone_id}/places/{place_id}/faces")
|
||||
async def set_place_faces(zone_id: int, place_id: int, payload: FacesPayload):
|
||||
"""Replace the set of saved faces linked to this place."""
|
||||
g = _require_zones()
|
||||
try:
|
||||
g.set_place_faces(zone_id, place_id, payload.face_ids)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "place": _place_to_dict(g.get_place(zone_id, place_id))}
|
||||
|
||||
|
||||
@router.post("/{zone_id}/places/{place_id}/capture")
|
||||
async def capture_to_place(zone_id: int, place_id: int):
|
||||
g = _require_zones()
|
||||
cam = _get_camera()
|
||||
if cam is None or not cam.is_running():
|
||||
raise HTTPException(409, "Camera is not running. Toggle Vision ON first.")
|
||||
jpeg = cam.get_fresh_frame(max_age_s=0.5, timeout_s=1.5)
|
||||
if not jpeg:
|
||||
raise HTTPException(409, "Camera has no frame yet.")
|
||||
try:
|
||||
fname = g.add_photo(zone_id, place_id, jpeg)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "added": fname, "place": _place_to_dict(g.get_place(zone_id, place_id))}
|
||||
|
||||
|
||||
@router.post("/{zone_id}/places/{place_id}/upload")
|
||||
async def upload_to_place(zone_id: int, place_id: int,
|
||||
files: list[UploadFile] = File(...)):
|
||||
g = _require_zones()
|
||||
if g.get_place(zone_id, place_id) is None:
|
||||
raise HTTPException(404, f"zone_{zone_id}/place_{place_id} not found")
|
||||
added: list[str] = []
|
||||
for f in files:
|
||||
content = await f.read()
|
||||
_validate_image(content, f.filename)
|
||||
try:
|
||||
added.append(g.add_photo(zone_id, place_id, content))
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "added": added, "place": _place_to_dict(g.get_place(zone_id, place_id))}
|
||||
|
||||
|
||||
@router.delete("/{zone_id}/places/{place_id}")
|
||||
async def delete_place(zone_id: int, place_id: int):
|
||||
g = _require_zones()
|
||||
try:
|
||||
g.delete_place(zone_id, place_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
st = recognition_state.read(STATE_PATH)
|
||||
if st.nav_target_zone_id == zone_id and st.nav_target_place_id == place_id:
|
||||
recognition_state.mutate(STATE_PATH, nav_target_zone_id=0, nav_target_place_id=0)
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "deleted": place_id}
|
||||
|
||||
|
||||
@router.delete("/{zone_id}/places/{place_id}/photo/{photo_name}")
|
||||
async def delete_place_photo(zone_id: int, place_id: int, photo_name: str):
|
||||
g = _require_zones()
|
||||
_safe_photo_name(photo_name)
|
||||
try:
|
||||
g.delete_photo(zone_id, place_id, photo_name)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
_bump_zones_version()
|
||||
return {"ok": True, "deleted": photo_name}
|
||||
|
||||
|
||||
@router.get("/{zone_id}/places/{place_id}/photo/{photo_name}")
|
||||
async def get_place_photo(zone_id: int, place_id: int, photo_name: str,
|
||||
download: int = Query(default=0)):
|
||||
g = _require_zones()
|
||||
_safe_photo_name(photo_name)
|
||||
path = g.get_photo(zone_id, place_id, photo_name)
|
||||
if path is None:
|
||||
raise HTTPException(404, "Photo not found.")
|
||||
media = "image/png" if path.suffix.lower() == ".png" else "image/jpeg"
|
||||
headers = {}
|
||||
if download:
|
||||
headers["Content-Disposition"] = (
|
||||
f'attachment; filename="zone_{zone_id}_place_{place_id}_{photo_name}"')
|
||||
return FileResponse(path, media_type=media, headers=headers)
|
||||
|
||||
|
||||
@router.get("/{zone_id}/places/{place_id}/download.zip")
|
||||
async def download_place_zip(zone_id: int, place_id: int):
|
||||
g = _require_zones()
|
||||
try:
|
||||
data = g.zip_place(zone_id, place_id)
|
||||
except FileNotFoundError as exc:
|
||||
raise HTTPException(404, str(exc))
|
||||
return StreamingResponse(
|
||||
io.BytesIO(data), media_type="application/zip",
|
||||
headers={
|
||||
"Content-Disposition": f'attachment; filename="zone_{zone_id}_place_{place_id}.zip"',
|
||||
"Content-Length": str(len(data)),
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
# ── "go here" navigation target ─────────────────────────────
|
||||
|
||||
@router.post("/{zone_id}/places/{place_id}/go")
|
||||
async def go_to_place(zone_id: int, place_id: int):
|
||||
"""Set this place as the active destination. Records the target and lets
|
||||
the Gemini child pick it up (reference photo + goal). Actual robot motion
|
||||
is wired by N2 locomotion — until then this just establishes the goal."""
|
||||
g = _require_zones()
|
||||
p = g.get_place(zone_id, place_id)
|
||||
if p is None:
|
||||
raise HTTPException(404, f"zone_{zone_id}/place_{place_id} not found")
|
||||
recognition_state.mutate(STATE_PATH,
|
||||
nav_target_zone_id=zone_id,
|
||||
nav_target_place_id=place_id)
|
||||
log.info("nav target set → zone_%d/place_%d (%s)", zone_id, place_id,
|
||||
p.name or "(unnamed)")
|
||||
return {"ok": True, "nav_target": {"zone_id": zone_id, "place_id": place_id,
|
||||
"place_name": p.name}}
|
||||
|
||||
|
||||
@router.post("/nav/clear")
|
||||
async def clear_nav_target():
|
||||
recognition_state.mutate(STATE_PATH, nav_target_zone_id=0, nav_target_place_id=0)
|
||||
log.info("nav target cleared")
|
||||
return {"ok": True, "nav_target": None}
|
||||
@ -5,7 +5,7 @@
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>Sanad Dashboard</title>
|
||||
<style>
|
||||
:root{--bg:#0a0f1a;--panel:#111827;--panel2:#1a2332;--accent:#0ea5e9;--accent2:#6366f1;--text:#e2e8f0;--muted:#64748b;--dim:#475569;--danger:#ef4444;--success:#22c55e;--warn:#f59e0b;--border:#1e293b;--glow:0 0 20px rgba(14,165,233,.08);--radius:12px}
|
||||
:root{--bg:#0a0f1a;--panel:#111827;--panel2:#1a2332;--accent:#0ea5e9;--accent2:#6366f1;--text:#e2e8f0;--muted:#64748b;--dim:#475569;--danger:#ef4444;--success:#22c55e;--warn:#f59e0b;--border:#1e293b;--glow:0 0 20px rgba(14,165,233,.08);--radius:12px;--err:#ef4444}
|
||||
*{box-sizing:border-box;margin:0;padding:0}
|
||||
body{font-family:'Inter','Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||
/* Header */
|
||||
@ -17,6 +17,16 @@
|
||||
.hdr-badge{padding:2px 7px;border-radius:4px;font-size:.68rem;font-weight:600;text-transform:uppercase;letter-spacing:.04em}
|
||||
.hdr-badge-err{background:rgba(239,68,68,.15);color:var(--danger);border:1px solid rgba(239,68,68,.3)}
|
||||
.hdr-badge-ok{background:rgba(34,197,94,.12);color:var(--success);border:1px solid rgba(34,197,94,.25)}
|
||||
/* N2 — global subsystem status strip + Controller tab */
|
||||
.status-pills{display:flex;gap:.45rem;align-items:center;padding:.3rem 1.5rem;background:var(--panel);border-bottom:1px solid var(--border);flex-wrap:wrap}
|
||||
.pill-off{background:rgba(100,116,139,.12);color:var(--muted);border:1px solid var(--border)}
|
||||
.pill-on{background:rgba(34,197,94,.14);color:var(--success);border:1px solid rgba(34,197,94,.3)}
|
||||
.pill-soon{opacity:.45;cursor:not-allowed;background:rgba(100,116,139,.08);color:var(--muted);border:1px dashed var(--border)}
|
||||
.steppad{display:grid;grid-template-columns:repeat(3,1fr);gap:.3rem;max-width:230px}
|
||||
.steppad button{font-size:1rem;padding:.5rem 0}
|
||||
.ctrl-strip{padding-left:0;border:none;background:transparent}
|
||||
.motion-locked{opacity:.5;pointer-events:none;filter:grayscale(.4)}
|
||||
#motion-lock-banner{display:none;align-items:center;gap:.5rem;margin-bottom:.6rem;padding:.5rem .7rem;background:rgba(245,158,11,.12);border:1px solid rgba(245,158,11,.35);border-radius:8px;color:var(--warn);font-size:.74rem}
|
||||
#estop{background:var(--danger);color:#fff;border:none;padding:.35rem .9rem;border-radius:6px;font-weight:700;font-size:.75rem;cursor:pointer;letter-spacing:.03em;box-shadow:0 0 12px rgba(239,68,68,.3);transition:all .15s}
|
||||
#estop:hover{box-shadow:0 0 20px rgba(239,68,68,.5);transform:scale(1.04)}
|
||||
/* Tabs */
|
||||
@ -118,13 +128,24 @@
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- N2 — global subsystem status strip (visible on every tab) -->
|
||||
<div id="status-pills" class="status-pills">
|
||||
<span class="hdr-badge pill-off" id="pill-camera" title="Camera / vision">CAM</span>
|
||||
<span class="hdr-badge pill-off" id="pill-face" title="Face recognition">FACE</span>
|
||||
<span class="hdr-badge pill-off" id="pill-place" title="Place / zone recognition">PLACE</span>
|
||||
<span class="hdr-badge pill-off" id="pill-movement" title="Movement (manual locomotion armed)">MOVE</span>
|
||||
</div>
|
||||
|
||||
<!-- Tabs -->
|
||||
<div class="tabs">
|
||||
<div class="tab active" onclick="switchTab('operations')">Operations</div>
|
||||
<div class="tab" onclick="switchTab('voice')">Voice & Audio</div>
|
||||
<div class="tab" onclick="switchTab('motion')">Motion & Replay</div>
|
||||
<div class="tab" onclick="switchTab('controller')">Controller</div>
|
||||
<div class="tab" onclick="switchTab('recognition')">Recognition</div>
|
||||
<div class="tab" onclick="switchTab('recordings')">Recordings</div>
|
||||
<div class="tab" onclick="switchTab('temp')">Temperature</div>
|
||||
<div class="tab" onclick="switchTab('terminal')">Terminal</div>
|
||||
<div class="tab" onclick="switchTab('settings')">Settings & Logs</div>
|
||||
</div>
|
||||
|
||||
@ -193,6 +214,8 @@
|
||||
</select>
|
||||
<button class="btn btn-primary btn-sm" onclick="applyAudioProfile(this)" title="Apply selected profile to PulseAudio">Apply</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="scanAudioDevices(this)" title="Scan all USB ports for audio devices">Scan</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="resetAudioSubsystem(this)" title="SOFT reset: restart PulseAudio/pipewire-pulse. Use when devices look stuck. Does NOT recover a missing USB mic — for that use USB Reset.">Reset PA</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="usbResetAnker(this)" title="HARD reset: unbind+rebind snd-usb-audio for the Anker (VID:PID 291a:3301). Use when Anker is plugged but the mic profile is missing from pactl. Needs a one-time sudoers setup — see hint in the error toast if it fails." style="color:var(--warn,#f5a623)">USB Reset</button>
|
||||
</div>
|
||||
<div id="audio-detected" style="margin-top:.3rem;font-size:.65rem;color:var(--dim)"></div>
|
||||
</div>
|
||||
@ -350,7 +373,8 @@
|
||||
|
||||
<!-- ==================== TAB: Motion & Replay ==================== -->
|
||||
<div class="tab-content" id="tab-motion">
|
||||
<div class="grid">
|
||||
<div id="motion-lock-banner">🔒 Arm actions are disabled while <b>movement</b> is enabled (Controller tab). Disable movement to replay / trigger / teach.</div>
|
||||
<div class="grid" id="motion-grid">
|
||||
|
||||
<!-- Full Motion Control -->
|
||||
<div class="card card-full">
|
||||
@ -552,6 +576,167 @@
|
||||
<div id="rec-faces-list" style="margin-top:.6rem"><div class="empty">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
<!-- Zone Recognition toggle + active destination -->
|
||||
<div class="card card-full">
|
||||
<h3>Zones & Places</h3>
|
||||
<div class="row" style="gap:1rem;flex-wrap:wrap;align-items:center">
|
||||
<div class="row" style="gap:.4rem">
|
||||
<label style="min-width:7rem">Zone Recognition</label>
|
||||
<label class="switch">
|
||||
<input type="checkbox" id="rec-zonerec-toggle" onchange="setZoneRecEnabled(this.checked)">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
<span id="rec-zonerec-status" class="badge" style="margin-left:.5rem">--</span>
|
||||
</div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="syncZones(this)" title="Re-send zones/places to live Gemini session">↻ Sync</button>
|
||||
<span style="margin-left:auto;font-size:.65rem;color:var(--dim)" id="rec-zones-version"></span>
|
||||
</div>
|
||||
<div class="row" style="margin-top:.5rem;gap:.4rem;align-items:center">
|
||||
<span style="font-size:.72rem;color:var(--dim)">Destination:</span>
|
||||
<span id="rec-nav-target" style="font-size:.78rem">none</span>
|
||||
<button class="btn btn-ghost btn-sm" id="rec-nav-clear" onclick="clearNavTarget(this)" style="display:none">Clear destination</button>
|
||||
</div>
|
||||
<div style="margin-top:.4rem;font-size:.7rem;color:var(--dim)">
|
||||
Group locations into <b>zones</b>, add <b>places</b> inside each (name + description +
|
||||
optional reference photos), and link saved <b>faces</b> to a place. “Go here” sets a
|
||||
destination and shows Gemini the place — the robot drives there once movement
|
||||
(locomotion) is enabled.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Add New Zone -->
|
||||
<div class="card card-full">
|
||||
<h3>Add New Zone</h3>
|
||||
<div class="row" style="flex-wrap:wrap;gap:.4rem">
|
||||
<input id="rec-newzone-name" placeholder="Zone name (e.g. Ground Floor)" style="flex:1;min-width:12rem">
|
||||
<input id="rec-newzone-desc" placeholder="Description (optional)" style="flex:2;min-width:12rem">
|
||||
<button class="btn btn-success btn-sm" onclick="createZone(this)">+ Add zone</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Zones list -->
|
||||
<div class="card card-full">
|
||||
<h3>Zones <span id="rec-zones-count" style="font-weight:normal;color:var(--dim);font-size:.75rem"></span></h3>
|
||||
<div class="row">
|
||||
<button class="btn btn-ghost btn-sm" onclick="refreshZones()">↻ Refresh</button>
|
||||
</div>
|
||||
<div id="rec-zones-list" style="margin-top:.6rem"><div class="empty">Loading…</div></div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== TAB: Temperature ==================== -->
|
||||
<div class="tab-content" id="tab-temp">
|
||||
<div class="card card-full" style="padding:0;overflow:hidden">
|
||||
<iframe id="temp3d-frame" title="G1 Motor Temperature (3D)"
|
||||
style="width:100%;height:80vh;border:0;display:block;background:#0b0d12"
|
||||
src="about:blank"></iframe>
|
||||
</div>
|
||||
<div style="margin-top:.4rem;font-size:.65rem;color:var(--dim)">
|
||||
Live motor surface/winding temperatures from <code>rt/lowstate</code> on the full
|
||||
G1 (29 DOF). Blue ≈ 30°C → red ≈ 120°C. Drag to orbit, scroll to zoom.
|
||||
Streamed over <code>/ws/motor-temps</code> — no second DDS subscriber.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== TAB: Controller (N2) ==================== -->
|
||||
<div class="tab-content" id="tab-controller">
|
||||
|
||||
<!-- Sticky status bar -->
|
||||
<div class="card card-full" id="ctrl-statusbar" style="position:sticky;top:0;z-index:50">
|
||||
<div class="row" style="justify-content:space-between;flex-wrap:wrap;gap:.5rem">
|
||||
<div class="row" style="gap:.6rem;align-items:center">
|
||||
<span class="badge" id="ctrl-fsm-badge">FSM —</span>
|
||||
<span class="dot" id="ctrl-ready-dot"></span><span id="ctrl-ready-text" style="font-size:.72rem">unknown</span>
|
||||
<span class="badge badge-info" id="ctrl-msc-badge">MSC —</span>
|
||||
<span class="badge" id="ctrl-sdk-badge">SDK —</span>
|
||||
</div>
|
||||
<div class="row" style="gap:.8rem;align-items:center">
|
||||
<label class="row" style="margin:0;gap:.4rem;align-items:center;text-transform:none">
|
||||
<input type="checkbox" id="ctrl-arm-toggle" style="width:auto" onchange="ctrlSetArmed(this.checked)"> Enable movement
|
||||
</label>
|
||||
<label class="row" style="margin:0;gap:.4rem;align-items:center;text-transform:none" title="Voice-driven locomotion — Gemini moves the robot when asked (EN/AR)">
|
||||
<input type="checkbox" id="ctrl-gmove-toggle" style="width:auto" onchange="ctrlSetGeminiMove(this.checked)"> Enable Gemini movement
|
||||
</label>
|
||||
<button class="btn btn-danger" onclick="ctrlEstop(this)">E-STOP</button>
|
||||
</div>
|
||||
</div>
|
||||
<!-- mirrored subsystem strip + coming-soon gates -->
|
||||
<div class="status-pills ctrl-strip" style="margin-top:.5rem">
|
||||
<span class="hdr-badge pill-off" id="ctrl-pill-camera">CAM</span>
|
||||
<span class="hdr-badge pill-off" id="ctrl-pill-face">FACE</span>
|
||||
<span class="hdr-badge pill-off" id="ctrl-pill-place">PLACE</span>
|
||||
<span class="hdr-badge pill-off" id="ctrl-pill-movement">MOVE</span>
|
||||
<span class="hdr-badge pill-off" id="ctrl-pill-gmove" title="Gemini voice-driven locomotion">GEMINI-MOVE</span>
|
||||
<span class="hdr-badge pill-soon" title="Phase 4 — autonomous navigation">EXPLORE · soon</span>
|
||||
</div>
|
||||
<div style="font-size:.66rem;color:var(--dim);margin-top:.45rem">
|
||||
Manual operator control. Robot is assumed standing in walking mode — use <b>Ready/Start</b> only if needed.
|
||||
All controls below are locked until <b>Enable movement</b> is on; <b>E-STOP</b> always works.
|
||||
While movement is on, arm replays/actions are disabled (and vice-versa).
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="grid">
|
||||
<!-- Locomotion / Teleop -->
|
||||
<div class="card">
|
||||
<h3>Locomotion / Teleop</h3>
|
||||
<div style="font-size:.68rem;color:var(--muted);margin-bottom:.4rem">Discrete step pad</div>
|
||||
<div id="ctrl-steppad" class="steppad">
|
||||
<button class="btn btn-ghost" onclick="ctrlStep('rotate_left',this)" title="Rotate left">⟲</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlStep('forward',this)" title="Forward">▲</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlStep('rotate_right',this)" title="Rotate right">⟳</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlStep('slide_left',this)" title="Slide left">◀</button>
|
||||
<button class="btn btn-danger" onclick="ctrlStop(this)" title="Stop">■</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlStep('slide_right',this)" title="Slide right">▶</button>
|
||||
<button class="btn btn-ghost" style="visibility:hidden"></button>
|
||||
<button class="btn btn-ghost" onclick="ctrlStep('backward',this)" title="Backward">▼</button>
|
||||
<button class="btn btn-ghost" style="visibility:hidden"></button>
|
||||
</div>
|
||||
<div class="row" style="margin-top:.7rem;flex-wrap:wrap;gap:.5rem;align-items:center">
|
||||
<button class="btn btn-primary" id="ctrl-teleop-btn" onclick="ctrlToggleTeleop()">Start teleop (WASD / Q-E)</button>
|
||||
<label class="row" style="margin:0;gap:.35rem;align-items:center;text-transform:none"><input type="checkbox" id="ctrl-run-toggle" style="width:auto"> Run (1.2)</label>
|
||||
</div>
|
||||
<div id="ctrl-vel-readout" style="font-size:.7rem;color:var(--muted);margin-top:.4rem">vx 0.00 · vy 0.00 · ω 0.00</div>
|
||||
<div style="font-size:.64rem;color:var(--dim);margin-top:.25rem">W/S forward·back · Q/E strafe · A/D rotate · Space halt</div>
|
||||
</div>
|
||||
|
||||
<!-- Postures & Modes -->
|
||||
<div class="card">
|
||||
<h3>Postures & Modes</h3>
|
||||
<div class="row" style="flex-wrap:wrap;gap:.3rem">
|
||||
<button class="btn btn-ghost" onclick="ctrlMode('prep',this)" title="StopMove→Damp→StandUp→height ramp">PREP</button>
|
||||
<button class="btn btn-primary" onclick="ctrlMode('ready',this)" title="PREP + Start (FSM 200)">READY / START</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlPosture('stand_up',this)">StandUp</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlPosture('squat',this)">Squat</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlPosture('sit',this)">Sit</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlPosture('low_stand',this)">LowStand</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlPosture('high_stand',this)">HighStand</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlPosture('lie_to_stand',this)">Lie→Stand</button>
|
||||
<button class="btn btn-danger" onclick="ctrlPosture('damp',this)">Damp</button>
|
||||
<button class="btn btn-danger" onclick="ctrlPosture('zero_torque',this)">ZeroTorque</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- MotionSwitcher / Low-Level -->
|
||||
<div class="card">
|
||||
<h3>MotionSwitcher / Low-Level</h3>
|
||||
<div class="row" style="flex-wrap:wrap;gap:.3rem">
|
||||
<button class="btn btn-ghost" onclick="ctrlMscSelectAi(this)">Select AI</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlMscRelease(this)">Release</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlMscShow(this)">Show mode</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlBalance(0,this)">Balance: static</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlBalance(1,this)">Balance: gait</button>
|
||||
<button class="btn btn-ghost" onclick="ctrlReconnect(this)">Reconnect</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Diagnostics -->
|
||||
<div class="card">
|
||||
<h3>Diagnostics — joints 12–28</h3>
|
||||
<pre id="ctrl-joints" style="font-size:.66rem;max-height:240px;overflow:auto;background:var(--panel2);border-radius:6px;padding:.5rem;margin:0"></pre>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -569,6 +754,16 @@
|
||||
<!-- Saved Records -->
|
||||
<div class="card card-full">
|
||||
<h3>Saved Records</h3>
|
||||
<!-- Now-Playing control bar — auto-shown when audio_mgr has an active
|
||||
G1 playback, hidden when idle. Pause/Resume/Stop act on whatever
|
||||
is currently playing (one playback at a time). -->
|
||||
<div id="rec-playback-bar" class="row" style="display:none;gap:.4rem;align-items:center;margin-bottom:.5rem;padding:.45rem .6rem;background:rgba(80,180,255,.08);border-radius:.4rem">
|
||||
<span style="font-size:.78rem">▶ <strong id="rec-playback-name">--</strong></span>
|
||||
<span id="rec-playback-time" style="font-size:.7rem;color:var(--dim)">0.0 / 0.0 s</span>
|
||||
<button class="btn btn-ghost btn-sm" id="rec-pause-btn" onclick="pauseRecord(this)" style="margin-left:auto">⏸ Pause</button>
|
||||
<button class="btn btn-success btn-sm" id="rec-resume-btn" onclick="resumeRecord(this)" style="display:none">▶ Resume</button>
|
||||
<button class="btn btn-danger btn-sm" onclick="stopRecord(this)">⏹ Stop</button>
|
||||
</div>
|
||||
<div id="records-list"><div class="empty">No records saved</div></div>
|
||||
<button class="btn btn-ghost btn-sm" onclick="refreshRecords()" style="margin-top:.3rem">Refresh</button>
|
||||
</div>
|
||||
@ -576,6 +771,30 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== TAB: Terminal ==================== -->
|
||||
<!-- In-browser shell on the robot. WebSocket → PTY bridge in
|
||||
dashboard/websockets/terminal.py. Click "SSH" to spawn a shell;
|
||||
click again or close the tab to terminate.
|
||||
xterm.js + xterm-addon-fit loaded from jsdelivr (no bundler needed). -->
|
||||
<div class="tab-content" id="tab-terminal">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/xterm@5.3.0/css/xterm.min.css">
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm@5.3.0/lib/xterm.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/xterm-addon-fit@0.8.0/lib/xterm-addon-fit.min.js"></script>
|
||||
<div class="card" style="display:flex;flex-direction:column;height:calc(100vh - 220px);min-height:480px">
|
||||
<div class="row" style="align-items:center;gap:.4rem">
|
||||
<h3 style="margin:0;flex:1">Terminal — unitree@robot</h3>
|
||||
<span id="term-status" style="font-size:.7rem;color:var(--dim)">disconnected</span>
|
||||
<button id="term-ssh-btn" class="btn btn-primary btn-sm" onclick="termConnect(this)">SSH</button>
|
||||
<button id="term-stop-btn" class="btn btn-danger btn-sm" onclick="termDisconnect(this)" disabled>Disconnect</button>
|
||||
<button class="btn btn-ghost btn-sm" onclick="termClear()" title="Clear screen (Ctrl+L also works)">Clear</button>
|
||||
</div>
|
||||
<div style="font-size:.65rem;color:var(--dim);margin-top:.2rem">
|
||||
Runs as the dashboard's user on the robot (typically <code>unitree</code>). No SSH handshake — the dashboard is already on the robot. Works on whichever Wi-Fi the robot is connected to.
|
||||
</div>
|
||||
<div id="term-host" style="flex:1;margin-top:.5rem;background:#000;border-radius:6px;padding:.3rem;overflow:hidden"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ==================== TAB: Settings & Logs ==================== -->
|
||||
<div class="tab-content" id="tab-settings">
|
||||
<div class="grid">
|
||||
@ -838,6 +1057,61 @@ async function scanAudioDevices(b){
|
||||
refreshAudioDevices();
|
||||
refreshAudio();
|
||||
}
|
||||
|
||||
// SOFT reset — restart pulseaudio/pipewire-pulse. Fixes Pulse-side state.
|
||||
// Does NOT recover a kernel-side missing USB mic descriptor — for that
|
||||
// use usbResetAnker.
|
||||
async function resetAudioSubsystem(b){
|
||||
if(!confirm('Reset PulseAudio?\n\nThis restarts the audio daemon on the robot.\n\nRequirements:\n - Live Gemini must be stopped\n - No record can be playing\n\nThis fixes stuck PulseAudio state. It does NOT recover a missing\nUSB mic profile — if the Anker mic still does not appear afterwards,\nuse the USB Reset button instead.'))return;
|
||||
btnLoad(b);
|
||||
try{
|
||||
const r=await api('POST','/api/audio/reset');
|
||||
if(r&&r.ok){
|
||||
const inOk=r.input_recovered, outOk=r.output_recovered;
|
||||
if(inOk&&outOk){
|
||||
toast('Audio subsystem reset · '+(r.flavour||'pulse')+' OK','ok');
|
||||
}else if(outOk){
|
||||
toast('Reset done but mic still missing — try USB Reset','err');
|
||||
}else{
|
||||
toast('Reset done but no devices detected — check USB','err');
|
||||
}
|
||||
}else{
|
||||
toast('Reset returned no result','err');
|
||||
}
|
||||
}catch(e){
|
||||
toast('Reset failed: '+((e&&e.message)||'unknown'),'err');
|
||||
}
|
||||
btnDone(b);
|
||||
refreshAudioDevices();
|
||||
refreshAudio();
|
||||
}
|
||||
|
||||
// HARD reset — snd-usb-audio unbind+rebind scoped to Anker VID:PID.
|
||||
// Forces the kernel to re-parse UAC1 descriptors. Needs sudoers entry
|
||||
// installed once via:
|
||||
// sudo bash shell_scripts/reset_anker_usb.sh --setup-sudoers
|
||||
async function usbResetAnker(b){
|
||||
if(!confirm('USB Reset Anker?\n\nThis unbinds and re-binds the snd-usb-audio driver\nfor the Anker dongle, forcing the kernel to re-parse\nthe USB Audio Class descriptors.\n\nUse this when the Anker is plugged but the mic profile\nis missing from the dashboard (PulseAudio shows the sink\nbut no source).\n\nRequirements:\n - Live Gemini must be stopped\n - No record can be playing\n\nIf this fails with "permission denied", run on the robot ONCE:\n sudo bash shell_scripts/reset_anker_usb.sh --setup-sudoers'))return;
|
||||
btnLoad(b);
|
||||
try{
|
||||
const r=await api('POST','/api/audio/usb-reset');
|
||||
if(r&&r.ok){
|
||||
if(r.input_recovered){
|
||||
toast('USB reset OK · Anker mic recovered','ok');
|
||||
}else{
|
||||
toast('USB reset done but mic not in pactl yet — give it 2s and click Scan','err');
|
||||
}
|
||||
}else{
|
||||
const hint=(r&&r.hint)?(' · '+r.hint):'';
|
||||
toast('USB reset failed'+hint,'err');
|
||||
}
|
||||
}catch(e){
|
||||
toast('USB reset failed: '+((e&&e.message)||'unknown'),'err');
|
||||
}
|
||||
btnDone(b);
|
||||
refreshAudioDevices();
|
||||
refreshAudio();
|
||||
}
|
||||
async function refreshAudioDevices(b){
|
||||
if(b)btnLoad(b);
|
||||
try{
|
||||
@ -1098,8 +1372,54 @@ async function reloadPrompt(){try{const r=await api('POST','/api/prompt/reload')
|
||||
|
||||
// Records
|
||||
async function refreshRecords(){try{const r=await api('GET','/api/records/');const el=document.getElementById('records-list');if(!(r.records||[]).length){el.innerHTML='<div class="empty">No records saved</div>';return;}el.innerHTML=`<div style="font-size:.7rem;color:var(--dim);margin-bottom:.3rem">Total: ${r.total_records} | Updated: ${r.last_updated||'--'}</div><table><tr><th>Name</th><th>Text</th><th>Replays</th><th></th></tr>`+(r.records||[]).map(rec=>{const n=esc(rec.record_name);return`<tr><td>${n}</td><td style="max-width:200px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(rec.text||'')}</td><td>${rec.replay_count||0}</td><td><button class="btn btn-primary btn-sm" onclick="playRecord('${n}','speaker')">Play</button> <button class="btn btn-ghost btn-sm" onclick="playRecord('${n}','raw')">Raw</button> <button class="btn btn-danger btn-sm" onclick="deleteRecord('${n}')">Del</button></td></tr>`;}).join('')+'</table>';}catch(e){}}
|
||||
async function playRecord(name,kind){try{await api('POST','/api/records/play',{record_name:name,file_kind:kind});toast('Playing: '+name,'ok');}catch(e){}}
|
||||
async function playRecord(name,kind){try{await api('POST','/api/records/play',{record_name:name,file_kind:kind});toast('Playing: '+name,'ok');refreshPlaybackStatus();}catch(e){}}
|
||||
async function deleteRecord(name){if(confirm('Delete '+name+'?'))try{await api('POST','/api/records/delete',{record_name:name});toast('Deleted','ok');refreshRecords();}catch(e){}}
|
||||
// Saved-record playback controls — operate on the active G1 playback
|
||||
// (one at a time). The Pause/Resume buttons swap in refreshPlaybackStatus
|
||||
// based on what audio_mgr reports; the bar hides itself when nothing plays.
|
||||
async function pauseRecord(b){
|
||||
if(b) btnLoad(b);
|
||||
try{await api('POST','/api/records/pause');refreshPlaybackStatus();}
|
||||
catch(e){toast('Pause failed: '+(e.message||e),'err');}
|
||||
if(b) btnDone(b);
|
||||
}
|
||||
async function resumeRecord(b){
|
||||
if(b) btnLoad(b);
|
||||
try{await api('POST','/api/records/resume');refreshPlaybackStatus();}
|
||||
catch(e){toast('Resume failed: '+(e.message||e),'err');}
|
||||
if(b) btnDone(b);
|
||||
}
|
||||
async function stopRecord(b){
|
||||
if(b) btnLoad(b);
|
||||
try{await api('POST','/api/records/stop');toast('Stopped','info');refreshPlaybackStatus();}
|
||||
catch(e){toast('Stop failed: '+(e.message||e),'err');}
|
||||
if(b) btnDone(b);
|
||||
}
|
||||
async function refreshPlaybackStatus(){
|
||||
try{
|
||||
const s=await api('GET','/api/records/playback-status');
|
||||
const bar=document.getElementById('rec-playback-bar');
|
||||
if(!bar) return;
|
||||
if(!s.playing){
|
||||
bar.style.display='none';
|
||||
return;
|
||||
}
|
||||
bar.style.display='flex';
|
||||
document.getElementById('rec-playback-name').textContent =
|
||||
s.record_name || '(unnamed)';
|
||||
document.getElementById('rec-playback-time').textContent =
|
||||
(s.position_sec||0).toFixed(1)+' / '+(s.duration_sec||0).toFixed(1)+' s';
|
||||
const pauseBtn=document.getElementById('rec-pause-btn');
|
||||
const resumeBtn=document.getElementById('rec-resume-btn');
|
||||
if(s.paused){
|
||||
pauseBtn.style.display='none';
|
||||
resumeBtn.style.display='inline-block';
|
||||
}else{
|
||||
pauseBtn.style.display='inline-block';
|
||||
resumeBtn.style.display='none';
|
||||
}
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
// Live Voice
|
||||
async function startLiveVoice(b){
|
||||
@ -1448,7 +1768,7 @@ function stopRecPreview(){if(_recPreviewTimer){clearInterval(_recPreviewTimer);_
|
||||
const origSwitchTab=window.switchTab;
|
||||
window.switchTab=function(name){
|
||||
origSwitchTab(name);
|
||||
if(name==='recognition'){refreshRecognition();refreshFaces();startRecPreview();}
|
||||
if(name==='recognition'){refreshRecognition();refreshFaces();refreshZones();startRecPreview();}
|
||||
else{stopRecPreview();}
|
||||
};
|
||||
})();
|
||||
@ -1591,10 +1911,437 @@ async function deleteFace(id){
|
||||
}catch(e){toast('Delete failed','err');}
|
||||
}
|
||||
|
||||
// ── Zones → Places → linked Faces (+ "go here" destination) ──
|
||||
let _facesCache=[]; // [{id,name}] from the face gallery, for the link picker
|
||||
let _navTarget=null; // active destination {zone_id,place_id,zone_name,place_name}
|
||||
async function setZoneRecEnabled(on){
|
||||
try{await api('POST','/api/zones/zone-rec?on='+(on?'1':'0'));toast(on?'Zone Recognition ON':'Zone Recognition OFF','ok');refreshZones();}
|
||||
catch(e){toast('Zone Rec toggle failed: '+(e.message||e),'err');refreshZones();}
|
||||
}
|
||||
async function syncZones(b){
|
||||
if(b) btnLoad(b);
|
||||
try{await api('POST','/api/zones/sync');toast('Zones sync requested','ok');refreshZones();}
|
||||
catch(e){toast('Sync failed','err');}
|
||||
if(b) btnDone(b);
|
||||
}
|
||||
async function clearNavTarget(b){
|
||||
if(b) btnLoad(b);
|
||||
try{await api('POST','/api/zones/nav/clear');toast('Destination cleared','ok');refreshZones();}
|
||||
catch(e){toast('Clear failed','err');}
|
||||
if(b) btnDone(b);
|
||||
}
|
||||
async function refreshZones(){
|
||||
try{const fr=await api('GET','/api/recognition/faces');_facesCache=(fr.faces||[]).map(f=>({id:f.id,name:f.name||('face_'+f.id)}));}catch(e){_facesCache=[];}
|
||||
try{
|
||||
const r=await api('GET','/api/zones/state');
|
||||
const t=document.getElementById('rec-zonerec-toggle'); if(t) t.checked=!!r.zone_rec_enabled;
|
||||
const zs=document.getElementById('rec-zonerec-status'); if(zs){zs.textContent=r.zone_rec_enabled?'on':'off';zs.className='badge '+(r.zone_rec_enabled?'badge-ok':'');}
|
||||
const zc=document.getElementById('rec-zones-count'); if(zc) zc.textContent=`(${r.zones_count} zones, ${r.places_count} places)`;
|
||||
const zv=document.getElementById('rec-zones-version'); if(zv) zv.textContent='v.'+r.zones_version;
|
||||
_navTarget=r.nav_target||null;
|
||||
const nt=document.getElementById('rec-nav-target'), nc=document.getElementById('rec-nav-clear');
|
||||
if(nt){
|
||||
if(_navTarget){nt.textContent=(_navTarget.place_name||('place_'+_navTarget.place_id))+' · '+(_navTarget.zone_name||('zone_'+_navTarget.zone_id));nt.style.color='var(--accent)';}
|
||||
else{nt.textContent='none';nt.style.color='var(--dim)';}
|
||||
}
|
||||
if(nc) nc.style.display=_navTarget?'':'none';
|
||||
}catch(e){}
|
||||
const el=document.getElementById('rec-zones-list'); if(!el) return;
|
||||
try{
|
||||
const r=await api('GET','/api/zones');
|
||||
if(!r.zones||!r.zones.length){el.innerHTML='<div class="empty">No zones yet — add one above</div>';return;}
|
||||
el.innerHTML=r.zones.map(z=>renderZoneCard(z)).join('');
|
||||
}catch(e){el.innerHTML='<div class="empty">(zone gallery not available)</div>';}
|
||||
}
|
||||
function _faceOptions(selectedIds){
|
||||
const sel=new Set((selectedIds||[]).map(Number));
|
||||
if(!_facesCache.length) return '<option disabled>(no saved faces)</option>';
|
||||
return _facesCache.map(f=>`<option value="${f.id}" ${sel.has(f.id)?'selected':''}>${esc(f.name)}</option>`).join('');
|
||||
}
|
||||
function renderZoneCard(z){
|
||||
const zname=z.name||`(zone_${z.id})`;
|
||||
const places=(z.places||[]).map(p=>renderPlaceCard(z.id,p)).join('') || '<div class="empty" style="margin:.3rem 0">No places in this zone yet</div>';
|
||||
return `<div class="card" style="margin-top:.6rem;border-left:3px solid var(--accent2)">
|
||||
<div class="row" style="align-items:center;gap:.3rem">
|
||||
<strong>📍 ${esc(zname)}</strong>
|
||||
<button class="btn btn-ghost btn-sm" onclick="renameZone(${z.id})" title="Rename zone">✏</button>
|
||||
<span style="flex:1;color:var(--muted);font-size:.72rem">${z.description?esc(z.description):'<span style=color:var(--dim)>(no description)</span>'}</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="describeZone(${z.id})" title="Edit zone description">📝</button>
|
||||
<span style="color:var(--dim);font-size:.7rem">${(z.places||[]).length} place(s)</span>
|
||||
<button class="btn btn-danger btn-sm" onclick="deleteZone(${z.id})" title="Delete zone + its places">🗑</button>
|
||||
</div>
|
||||
<div style="margin-top:.4rem;padding-left:.6rem">${places}</div>
|
||||
<div class="row" style="margin-top:.4rem;padding-left:.6rem;gap:.3rem;flex-wrap:wrap">
|
||||
<input id="z${z.id}-np-name" placeholder="New place name" style="flex:1;min-width:8rem;font-size:.78rem">
|
||||
<input id="z${z.id}-np-desc" placeholder="Description (optional)" style="flex:2;min-width:8rem;font-size:.78rem">
|
||||
<button class="btn btn-success btn-sm" onclick="createPlaceInZone(${z.id},this)">+ place</button>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
function renderPlaceCard(zid,p){
|
||||
const pname=p.name||`(place_${p.id})`;
|
||||
const photos=(p.photos||[]).map(ph=>{
|
||||
const url=`/api/zones/${zid}/places/${p.id}/photo/${encodeURIComponent(ph.name)}`;
|
||||
return `<div style="display:inline-block;margin:.15rem;text-align:center">
|
||||
<img src="${url}?t=${Date.now()}" alt="${esc(ph.name)}" style="width:64px;height:64px;object-fit:cover;border-radius:.3rem;background:#222"/>
|
||||
<div style="font-size:.55rem"><a href="#" onclick="deletePlacePhoto(${zid},${p.id},'${esc(ph.name)}');return false" style="color:var(--err);text-decoration:none">🗑</a></div>
|
||||
</div>`;
|
||||
}).join('');
|
||||
const chips=(p.faces||[]).map(f=>`<span class="badge" style="margin-right:.2rem">${esc(f.name||('face_'+f.id))}</span>`).join('') || '<span style="color:var(--dim);font-size:.7rem">none</span>';
|
||||
const isDest=_navTarget&&_navTarget.zone_id===zid&&_navTarget.place_id===p.id;
|
||||
return `<div class="card" style="margin-top:.35rem;background:var(--panel2)">
|
||||
<div class="row" style="align-items:center;gap:.3rem">
|
||||
<span>🏷</span><span id="rec-pname-${zid}-${p.id}" style="flex:1">${esc(pname)}</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="renamePlace(${zid},${p.id})" title="Rename">✏</button>
|
||||
${isDest?'<span class="badge badge-ok">destination</span>':`<button class="btn btn-primary btn-sm" onclick="goToPlace(${zid},${p.id},this)" title="Set as destination">▶ Go here</button>`}
|
||||
<button class="btn btn-danger btn-sm" onclick="deletePlace(${zid},${p.id})">🗑</button>
|
||||
</div>
|
||||
<div style="margin-top:.2rem;font-size:.72rem"><span style="color:var(--dim)">Description:</span>
|
||||
<span id="rec-pdesc-${zid}-${p.id}" style="color:var(--muted)">${p.description?esc(p.description):'<span style=color:var(--dim)>(none)</span>'}</span>
|
||||
<button class="btn btn-ghost btn-sm" onclick="describePlace(${zid},${p.id})" title="Edit description">✏</button>
|
||||
</div>
|
||||
<div style="margin-top:.25rem;font-size:.72rem"><span style="color:var(--dim)">People here:</span> ${chips}</div>
|
||||
<div class="row" style="margin-top:.2rem;gap:.3rem;align-items:center">
|
||||
<select id="pf-${zid}-${p.id}" multiple size="3" style="font-size:.72rem;min-width:9rem">${_faceOptions(p.face_ids)}</select>
|
||||
<button class="btn btn-ghost btn-sm" onclick="savePlaceFaces(${zid},${p.id})" title="Link selected saved faces to this place">Save people</button>
|
||||
</div>
|
||||
<div style="margin-top:.25rem">${photos}</div>
|
||||
<div class="row" style="margin-top:.3rem;gap:.3rem">
|
||||
<button class="btn btn-success btn-sm" onclick="captureToPlace(${zid},${p.id},this)">📷 Capture</button>
|
||||
<label class="btn btn-primary btn-sm" style="cursor:pointer;margin:0">📁 Upload<input type="file" multiple accept="image/jpeg,image/png" style="display:none" onchange="uploadToPlace(${zid},${p.id},this)"></label>
|
||||
<a class="btn btn-ghost btn-sm" href="/api/zones/${zid}/places/${p.id}/download.zip" download>⬇ ZIP</a>
|
||||
</div>
|
||||
</div>`;
|
||||
}
|
||||
async function createZone(b){
|
||||
const name=document.getElementById('rec-newzone-name').value.trim();
|
||||
const desc=document.getElementById('rec-newzone-desc').value.trim();
|
||||
if(!name&&!desc){toast('Enter a zone name or description','err');return;}
|
||||
const qs=[]; if(name)qs.push('name='+encodeURIComponent(name)); if(desc)qs.push('description='+encodeURIComponent(desc));
|
||||
btnLoad(b);
|
||||
try{await api('POST','/api/zones/create?'+qs.join('&'));toast('Zone added','ok');
|
||||
document.getElementById('rec-newzone-name').value='';document.getElementById('rec-newzone-desc').value='';refreshZones();}
|
||||
catch(e){toast('Add zone failed: '+(e.message||e),'err');}
|
||||
btnDone(b);
|
||||
}
|
||||
async function renameZone(zid){
|
||||
const next=prompt('New zone name (blank to clear):'); if(next===null)return;
|
||||
try{await api('POST','/api/zones/'+zid+'/rename',{name:next});toast('Renamed','ok');refreshZones();}catch(e){toast('Rename failed','err');}
|
||||
}
|
||||
async function describeZone(zid){
|
||||
const next=prompt('Zone description (blank to clear):'); if(next===null)return;
|
||||
try{await api('POST','/api/zones/'+zid+'/describe',{description:next});toast('Saved','ok');refreshZones();}catch(e){toast('Save failed','err');}
|
||||
}
|
||||
async function deleteZone(zid){
|
||||
if(!confirm('Delete this zone AND all its places?'))return;
|
||||
try{await api('DELETE','/api/zones/'+zid);toast('Zone deleted','ok');refreshZones();}catch(e){toast('Delete failed','err');}
|
||||
}
|
||||
async function createPlaceInZone(zid,b){
|
||||
const nameEl=document.getElementById('z'+zid+'-np-name'), descEl=document.getElementById('z'+zid+'-np-desc');
|
||||
const name=(nameEl?nameEl.value:'').trim(), desc=(descEl?descEl.value:'').trim();
|
||||
if(!name&&!desc){toast('Enter a place name or description','err');return;}
|
||||
const qs=[]; if(name)qs.push('name='+encodeURIComponent(name)); if(desc)qs.push('description='+encodeURIComponent(desc));
|
||||
btnLoad(b);
|
||||
try{await api('POST','/api/zones/'+zid+'/places/create?'+qs.join('&'));toast('Place added','ok');refreshZones();}
|
||||
catch(e){toast('Add place failed: '+(e.message||e),'err');}
|
||||
btnDone(b);
|
||||
}
|
||||
async function renamePlace(zid,pid){
|
||||
const next=prompt('New place name (blank to clear):'); if(next===null)return;
|
||||
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/rename',{name:next});toast('Renamed','ok');refreshZones();}catch(e){toast('Rename failed','err');}
|
||||
}
|
||||
async function describePlace(zid,pid){
|
||||
const next=prompt('Place description for Gemini (blank to clear):'); if(next===null)return;
|
||||
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/describe',{description:next});toast('Saved','ok');refreshZones();}catch(e){toast('Save failed','err');}
|
||||
}
|
||||
async function deletePlace(zid,pid){
|
||||
if(!confirm('Delete this place and its photos?'))return;
|
||||
try{await api('DELETE','/api/zones/'+zid+'/places/'+pid);toast('Place deleted','ok');refreshZones();}catch(e){toast('Delete failed','err');}
|
||||
}
|
||||
async function savePlaceFaces(zid,pid){
|
||||
const sel=document.getElementById('pf-'+zid+'-'+pid); if(!sel)return;
|
||||
const ids=Array.from(sel.selectedOptions).map(o=>parseInt(o.value)).filter(n=>!isNaN(n));
|
||||
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/faces',{face_ids:ids});toast('People linked: '+ids.length,'ok');refreshZones();}
|
||||
catch(e){toast('Save people failed: '+(e.message||e),'err');}
|
||||
}
|
||||
async function captureToPlace(zid,pid,b){
|
||||
btnLoad(b);
|
||||
try{await api('POST','/api/zones/'+zid+'/places/'+pid+'/capture');toast('Added photo','ok');refreshZones();}
|
||||
catch(e){toast('Capture failed: '+(e.message||e),'err');}
|
||||
btnDone(b);
|
||||
}
|
||||
async function uploadToPlace(zid,pid,input){
|
||||
const files=input.files;if(!files||!files.length)return;
|
||||
const fd=new FormData();for(const f of files) fd.append('files',f);
|
||||
try{const resp=await fetch('/api/zones/'+zid+'/places/'+pid+'/upload',{method:'POST',body:fd});
|
||||
if(!resp.ok)throw new Error(await resp.text());toast('Uploaded '+files.length+' photo(s)','ok');input.value='';refreshZones();}
|
||||
catch(e){toast('Upload failed: '+(e.message||e),'err');}
|
||||
}
|
||||
async function deletePlacePhoto(zid,pid,name){
|
||||
if(!confirm('Delete photo '+name+'?'))return;
|
||||
try{await api('DELETE','/api/zones/'+zid+'/places/'+pid+'/photo/'+encodeURIComponent(name));toast('Photo deleted','ok');refreshZones();}
|
||||
catch(e){toast('Delete failed: '+(e.message||e),'err');}
|
||||
}
|
||||
async function goToPlace(zid,pid,b){
|
||||
if(b) btnLoad(b);
|
||||
try{const r=await api('POST','/api/zones/'+zid+'/places/'+pid+'/go');toast('Destination set: '+((r.nav_target&&r.nav_target.place_name)||('place_'+pid)),'ok');refreshZones();}
|
||||
catch(e){toast('Set destination failed: '+(e.message||e),'err');}
|
||||
if(b) btnDone(b);
|
||||
}
|
||||
|
||||
// ==================== Controller tab (N2) ====================
|
||||
let ctrlArmed=false, ctrlTeleop=false, ctrlVel={vx:0,vy:0,vyaw:0}, ctrlKeys=new Set(), ctrlTimer=null;
|
||||
const CTRL_LIN=0.05, CTRL_ANG=0.2;
|
||||
|
||||
// silent POST (no toast) for high-frequency teleop / stop
|
||||
function ctrlPost(path,body){return fetch(API+path,{method:'POST',headers:{'Content-Type':'application/json'},body:body?JSON.stringify(body):undefined}).then(r=>r.json().catch(()=>({}))).catch(()=>({}));}
|
||||
function setPill(id,on){const el=document.getElementById(id);if(!el)return;el.classList.toggle('pill-on',!!on);el.classList.toggle('pill-off',!on);}
|
||||
|
||||
function ctrlSetArmed(on){
|
||||
ctrlPost('/api/controller/arm?on='+(on?'1':'0')).then(r=>{
|
||||
ctrlArmed=!!r.armed; if(!ctrlArmed) ctrlStopTeleop();
|
||||
ctrlRenderArmed(); refreshStatusStrip();
|
||||
toast('Movement '+(ctrlArmed?'ENABLED':'disabled'), ctrlArmed?'ok':'info');
|
||||
});
|
||||
}
|
||||
function ctrlRenderArmed(){
|
||||
const t=document.getElementById('ctrl-arm-toggle'); if(t) t.checked=ctrlArmed;
|
||||
document.querySelectorAll('#tab-controller .btn').forEach(b=>{
|
||||
const oc=b.getAttribute('onclick')||'';
|
||||
if(/ctrlEstop|ctrlStop\b/.test(oc)) return; // E-STOP + Stop always live
|
||||
b.disabled=!ctrlArmed;
|
||||
});
|
||||
setPill('ctrl-pill-movement',ctrlArmed);
|
||||
}
|
||||
|
||||
async function ctrlEstop(b){btnLoad(b);try{await ctrlPost('/api/controller/estop');ctrlStopTeleop();toast('E-STOP sent','err');}catch(e){}btnDone(b);refreshController();}
|
||||
function ctrlStop(b){btnLoad(b);ctrlPost('/api/controller/stop').then(()=>btnDone(b));}
|
||||
async function ctrlStep(dir,b){btnLoad(b);try{const r=await api('POST','/api/controller/step?dir='+dir);if(r&&r.warning)toast(r.warning,'warn');}catch(e){}btnDone(b);}
|
||||
async function ctrlMode(m,b){btnLoad(b);try{await api('POST','/api/controller/mode/'+m);toast(m.toUpperCase()+' done','ok');}catch(e){}btnDone(b);refreshController();}
|
||||
async function ctrlPosture(p,b){btnLoad(b);try{const r=await api('POST','/api/controller/posture/'+p);if(r&&r.warning)toast(r.warning,'warn');else toast(p+' sent','ok');}catch(e){}btnDone(b);refreshController();}
|
||||
async function ctrlBalance(m,b){btnLoad(b);try{await api('POST','/api/controller/balance?mode='+m);toast('balance '+(m?'gait':'static'),'info');}catch(e){}btnDone(b);}
|
||||
async function ctrlMscSelectAi(b){btnLoad(b);try{await api('POST','/api/controller/msc/select-ai');toast('MSC → ai','ok');}catch(e){}btnDone(b);refreshController();}
|
||||
async function ctrlMscRelease(b){btnLoad(b);try{await api('POST','/api/controller/msc/release');toast('MSC released','ok');}catch(e){}btnDone(b);refreshController();}
|
||||
async function ctrlMscShow(b){btnLoad(b);try{const r=await api('GET','/api/controller/msc');toast('MSC mode: '+(r.mode_name||'?'),'info');}catch(e){}btnDone(b);}
|
||||
async function ctrlReconnect(b){btnLoad(b);try{await api('POST','/api/controller/reconnect');toast('reconnected','ok');}catch(e){}btnDone(b);refreshController();}
|
||||
function ctrlSetGeminiMove(on){
|
||||
ctrlPost('/api/controller/gemini-movement?on='+(on?'1':'0')).then(r=>{
|
||||
toast('Gemini movement '+(r.movement_enabled?'ENABLED':'disabled'), r.movement_enabled?'ok':'info');
|
||||
refreshStatusStrip();
|
||||
});
|
||||
}
|
||||
|
||||
// continuous teleop @10 Hz — held keys ramp velocity; cap enforced server-side
|
||||
function ctrlToggleTeleop(){ctrlTeleop?ctrlStopTeleop():ctrlStartTeleop();}
|
||||
function ctrlStartTeleop(){
|
||||
if(!ctrlArmed){toast('Enable movement first','warn');return;}
|
||||
ctrlTeleop=true; const btn=document.getElementById('ctrl-teleop-btn'); if(btn)btn.textContent='Stop teleop';
|
||||
window.addEventListener('keydown',ctrlKeyDown); window.addEventListener('keyup',ctrlKeyUp);
|
||||
ctrlTimer=setInterval(ctrlTick,100);
|
||||
}
|
||||
function ctrlStopTeleop(){
|
||||
if(!ctrlTeleop && !ctrlTimer) return;
|
||||
ctrlTeleop=false; ctrlKeys.clear(); ctrlVel={vx:0,vy:0,vyaw:0};
|
||||
if(ctrlTimer){clearInterval(ctrlTimer);ctrlTimer=null;}
|
||||
window.removeEventListener('keydown',ctrlKeyDown); window.removeEventListener('keyup',ctrlKeyUp);
|
||||
const btn=document.getElementById('ctrl-teleop-btn'); if(btn)btn.textContent='Start teleop (WASD / Q-E)';
|
||||
const r=document.getElementById('ctrl-vel-readout'); if(r)r.textContent='vx 0.00 · vy 0.00 · ω 0.00';
|
||||
ctrlPost('/api/controller/stop');
|
||||
}
|
||||
function ctrlKeyDown(e){const k=(e.key||'').toLowerCase(); if(['w','a','s','d','q','e',' '].includes(k)){ctrlKeys.add(k);e.preventDefault();}}
|
||||
function ctrlKeyUp(e){ctrlKeys.delete((e.key||'').toLowerCase());}
|
||||
function ctrlTick(){
|
||||
if(ctrlKeys.has(' ')){ctrlVel={vx:0,vy:0,vyaw:0};}
|
||||
else{
|
||||
ctrlVel.vx = ctrlKeys.has('w')? Math.min(2,ctrlVel.vx+CTRL_LIN) : ctrlKeys.has('s')? Math.max(-2,ctrlVel.vx-CTRL_LIN) : 0;
|
||||
ctrlVel.vy = ctrlKeys.has('q')? Math.min(2,ctrlVel.vy+CTRL_LIN) : ctrlKeys.has('e')? Math.max(-2,ctrlVel.vy-CTRL_LIN) : 0;
|
||||
ctrlVel.vyaw = ctrlKeys.has('a')? Math.min(3,ctrlVel.vyaw+CTRL_ANG) : ctrlKeys.has('d')? Math.max(-3,ctrlVel.vyaw-CTRL_ANG) : 0;
|
||||
}
|
||||
const run=(document.getElementById('ctrl-run-toggle')||{}).checked||false;
|
||||
const r=document.getElementById('ctrl-vel-readout'); if(r)r.textContent=`vx ${ctrlVel.vx.toFixed(2)} · vy ${ctrlVel.vy.toFixed(2)} · ω ${ctrlVel.vyaw.toFixed(2)}`;
|
||||
ctrlPost('/api/controller/move',{vx:ctrlVel.vx,vy:ctrlVel.vy,vyaw:ctrlVel.vyaw,run});
|
||||
}
|
||||
|
||||
async function refreshController(){
|
||||
try{
|
||||
const s=await api('GET','/api/controller/status');
|
||||
ctrlArmed=!!s.armed; ctrlRenderArmed();
|
||||
const fb=document.getElementById('ctrl-fsm-badge'); if(fb)fb.textContent='FSM '+(s.fsm_id??'—');
|
||||
const dot=document.getElementById('ctrl-ready-dot'), rt=document.getElementById('ctrl-ready-text');
|
||||
if(dot)dot.className='dot '+(s.walk_ready?'dot-ok':'dot-warn');
|
||||
if(rt)rt.textContent=s.walk_ready?'walk-ready':('mode '+(s.fsm_mode??'?'));
|
||||
const mb=document.getElementById('ctrl-msc-badge'); if(mb)mb.textContent='MSC '+(s.msc_mode||'—');
|
||||
const sb=document.getElementById('ctrl-sdk-badge'); if(sb)sb.textContent=s.sdk_available?(s.lc_ready?'SDK live':'SDK init…'):'SIM';
|
||||
}catch(e){}
|
||||
try{const j=await api('GET','/api/controller/joints');
|
||||
const el=document.getElementById('ctrl-joints');
|
||||
if(el)el.textContent=(j.joints||[]).map(x=>`${String(x.idx).padStart(2)} ${String(x.name).padEnd(16)} ${Number(x.q).toFixed(3)}`).join('\n');
|
||||
}catch(e){}
|
||||
}
|
||||
|
||||
// subsystem pills (global + controller mirror) + Motion-tab lockout — polled ~2.5s
|
||||
async function refreshStatusStrip(){
|
||||
try{
|
||||
const s=await api('GET','/api/controller/status/summary');
|
||||
const cam=!!(s.vision_enabled&&s.camera_running);
|
||||
setPill('pill-camera',cam); setPill('pill-face',s.face_rec_enabled); setPill('pill-place',s.zone_rec_enabled); setPill('pill-movement',s.movement_armed);
|
||||
setPill('ctrl-pill-camera',cam); setPill('ctrl-pill-face',s.face_rec_enabled); setPill('ctrl-pill-place',s.zone_rec_enabled); setPill('ctrl-pill-movement',s.movement_armed);
|
||||
setPill('ctrl-pill-gmove', s.gemini_movement_enabled);
|
||||
const gt=document.getElementById('ctrl-gmove-toggle'); if(gt && document.activeElement!==gt) gt.checked=!!s.gemini_movement_enabled;
|
||||
// keep the manual arm checkbox + button-enable state in sync even if the
|
||||
// robot was disarmed elsewhere (e.g. E-STOP) and the Controller tab is open.
|
||||
const at=document.getElementById('ctrl-arm-toggle');
|
||||
if(at && document.activeElement!==at && (!!s.movement_armed)!==ctrlArmed){ ctrlArmed=!!s.movement_armed; ctrlRenderArmed(); }
|
||||
applyMovementLock(!!s.movement_armed);
|
||||
}catch(e){}
|
||||
}
|
||||
function applyMovementLock(armed){
|
||||
const banner=document.getElementById('motion-lock-banner'); if(banner)banner.style.display=armed?'flex':'none';
|
||||
const grid=document.getElementById('motion-grid'); if(grid)grid.classList.toggle('motion-locked',armed);
|
||||
}
|
||||
|
||||
// Terminal tab — xterm.js attached to a WebSocket → PTY bridge on the robot.
|
||||
// Backend: dashboard/websockets/terminal.py.
|
||||
// Connection model: "SSH" button opens the socket + spawns the shell; the
|
||||
// Terminal tab itself doesn't auto-connect so leaving it open in the
|
||||
// background doesn't keep a bash running unnecessarily.
|
||||
let termInstance=null, termFit=null, termWS=null, termAutoSizeBound=false;
|
||||
function termLog(line){
|
||||
if(termInstance) termInstance.write('\r\n\x1b[2m[term] '+line+'\x1b[0m\r\n');
|
||||
}
|
||||
// Control messages MUST be prefixed with \x1f (Unit Separator). The
|
||||
// backend uses the prefix to distinguish a control frame from raw
|
||||
// keystrokes — without it, a user who pastes `{"type":"resize",...}`
|
||||
// into the shell would silently resize the PTY instead of pasting.
|
||||
const TERM_CTRL_PREFIX='\x1f';
|
||||
function termFitSafe(){
|
||||
if(!termFit||!termInstance) return;
|
||||
try{ termFit.fit(); }catch(e){ return; }
|
||||
if(termWS && termWS.readyState===1){
|
||||
try{
|
||||
termWS.send(TERM_CTRL_PREFIX+JSON.stringify({type:'resize', cols:termInstance.cols, rows:termInstance.rows}));
|
||||
}catch(e){}
|
||||
}
|
||||
}
|
||||
function termSetStatus(text,color){
|
||||
const el=document.getElementById('term-status');
|
||||
if(el){ el.textContent=text; el.style.color=color||'var(--dim)'; }
|
||||
}
|
||||
function termInit(){
|
||||
if(termInstance) return;
|
||||
if(typeof Terminal==='undefined'){ termSetStatus('xterm.js failed to load (check network/CDN)','var(--danger,#e57373)'); return; }
|
||||
termInstance=new Terminal({
|
||||
cursorBlink:true,
|
||||
fontFamily:'ui-monospace, "Cascadia Mono", Menlo, Consolas, monospace',
|
||||
fontSize:13,
|
||||
theme:{ background:'#000000', foreground:'#e0e0e0', cursor:'#00d4ff' },
|
||||
scrollback:5000,
|
||||
convertEol:true,
|
||||
});
|
||||
if(typeof FitAddon!=='undefined' && FitAddon.FitAddon){
|
||||
termFit=new FitAddon.FitAddon();
|
||||
termInstance.loadAddon(termFit);
|
||||
}
|
||||
termInstance.open(document.getElementById('term-host'));
|
||||
termFitSafe();
|
||||
// Send keystrokes upstream to the PTY.
|
||||
termInstance.onData(function(d){
|
||||
if(termWS && termWS.readyState===1){
|
||||
try{ termWS.send(d); }catch(e){}
|
||||
}
|
||||
});
|
||||
// Re-fit on window resize once xterm is attached.
|
||||
if(!termAutoSizeBound){
|
||||
window.addEventListener('resize', termFitSafe);
|
||||
termAutoSizeBound=true;
|
||||
}
|
||||
}
|
||||
async function termConnect(b){
|
||||
termInit();
|
||||
if(termWS && (termWS.readyState===0 || termWS.readyState===1)){
|
||||
toast('Terminal already connected','info'); return;
|
||||
}
|
||||
btnLoad(b);
|
||||
const scheme=(location.protocol==='https:'?'wss:':'ws:');
|
||||
const url=scheme+'//'+location.host+'/ws/terminal';
|
||||
termSetStatus('connecting…','var(--warn,#f5a623)');
|
||||
try{
|
||||
termWS=new WebSocket(url);
|
||||
}catch(e){
|
||||
termSetStatus('ws construct failed','var(--danger,#e57373)');
|
||||
btnDone(b); return;
|
||||
}
|
||||
termWS.onopen=function(){
|
||||
termSetStatus('connected','var(--success,#4caf50)');
|
||||
document.getElementById('term-stop-btn').disabled=false;
|
||||
document.getElementById('term-ssh-btn').disabled=true;
|
||||
btnDone(b);
|
||||
// Send initial sizing so the PTY knows the right window.
|
||||
try{
|
||||
if(termInstance) termWS.send(TERM_CTRL_PREFIX+JSON.stringify({type:'init', cols:termInstance.cols, rows:termInstance.rows}));
|
||||
}catch(e){}
|
||||
termFitSafe();
|
||||
if(termInstance) termInstance.focus();
|
||||
};
|
||||
termWS.onmessage=function(ev){
|
||||
if(termInstance) termInstance.write(typeof ev.data==='string'?ev.data:'');
|
||||
};
|
||||
termWS.onerror=function(){
|
||||
termSetStatus('ws error','var(--danger,#e57373)');
|
||||
};
|
||||
termWS.onclose=function(ev){
|
||||
termSetStatus('disconnected (code '+ev.code+')','var(--dim)');
|
||||
document.getElementById('term-stop-btn').disabled=true;
|
||||
document.getElementById('term-ssh-btn').disabled=false;
|
||||
btnDone(b);
|
||||
if(termInstance) termLog('session closed');
|
||||
};
|
||||
}
|
||||
function termDisconnect(b){
|
||||
btnLoad(b);
|
||||
try{ if(termWS) termWS.close(1000,'user disconnect'); }catch(e){}
|
||||
termWS=null;
|
||||
btnDone(b);
|
||||
}
|
||||
function termClear(){
|
||||
if(termInstance){ termInstance.clear(); termInstance.focus(); }
|
||||
}
|
||||
|
||||
// Temperature tab — lazy-load the 3D iframe on first open so its WebSocket
|
||||
// only connects when the user actually views it. Also wires the Controller tab:
|
||||
// refresh on enter, and stop teleop (release the window key listeners) on leave.
|
||||
// Terminal tab: lazy-init xterm on first open and re-fit on every entry so
|
||||
// the shell lays out correctly after a tab switch.
|
||||
(function(){
|
||||
const origSwitchTab=window.switchTab;
|
||||
window.switchTab=function(name){
|
||||
origSwitchTab(name);
|
||||
if(name!=='controller') ctrlStopTeleop(); // don't leave WASD bound to other tabs
|
||||
if(name==='controller') refreshController();
|
||||
if(name==='temp'){
|
||||
const f=document.getElementById('temp3d-frame');
|
||||
if(f && (!f.src || /about:blank$/.test(f.src))){
|
||||
f.src='/static/temp3d/index.html';
|
||||
}
|
||||
}
|
||||
if(name==='terminal'){
|
||||
// Defer to next frame so the panel's display:flex has applied —
|
||||
// FitAddon measures the host div and needs non-zero dimensions.
|
||||
requestAnimationFrame(function(){ termInit(); termFitSafe(); if(termInstance) termInstance.focus(); });
|
||||
}
|
||||
};
|
||||
})();
|
||||
|
||||
// Init — vision/camera/detector fetches removed; those endpoints were deleted.
|
||||
refreshStatus();refreshSystem();refreshAudio();refreshAudioDevices();refreshSkills();refreshReplayFiles();refreshScripts();refreshPrompt();refreshRecords();refreshLiveVoice();refreshLiveSub();refreshTR();refreshWakeActions();refreshApiKey();refreshCombo();refreshRecognition();connectLogs();
|
||||
refreshStatus();refreshSystem();refreshAudio();refreshAudioDevices();refreshSkills();refreshReplayFiles();refreshScripts();refreshPrompt();refreshRecords();refreshLiveVoice();refreshLiveSub();refreshTR();refreshWakeActions();refreshApiKey();refreshCombo();refreshRecognition();refreshZones();refreshPlaybackStatus();refreshStatusStrip();connectLogs();
|
||||
setTimeout(autoConnectGemini,2000);setTimeout(autoStartLiveSub,3000);
|
||||
setInterval(refreshStatus,5000);setInterval(refreshSystem,30000);setInterval(refreshLiveVoice,5000);setInterval(refreshLiveSub,5000);setInterval(refreshRecognition,5000);
|
||||
setInterval(refreshStatus,5000);setInterval(refreshSystem,30000);setInterval(refreshLiveVoice,5000);setInterval(refreshLiveSub,5000);setInterval(refreshRecognition,5000);setInterval(refreshPlaybackStatus,1000);
|
||||
setInterval(refreshStatusStrip,2500);
|
||||
setInterval(function(){const t=document.getElementById('tab-controller');if(t&&t.classList.contains('active'))refreshController();},2000);
|
||||
// Safety: if the tab loses focus / is hidden while teleoping, a keyup can be
|
||||
// missed and a key would "stick" (robot keeps moving). Stop teleop on blur/hide.
|
||||
window.addEventListener('blur',function(){ ctrlStopTeleop(); });
|
||||
document.addEventListener('visibilitychange',function(){ if(document.hidden) ctrlStopTeleop(); });
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
1058
dashboard/static/temp3d/g1/g1_29dof_rev_1_0.urdf
Normal file
1058
dashboard/static/temp3d/g1/g1_29dof_rev_1_0.urdf
Normal file
File diff suppressed because it is too large
Load Diff
BIN
dashboard/static/temp3d/g1/meshes/head_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/head_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_ankle_pitch_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_ankle_pitch_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_ankle_roll_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_ankle_roll_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_elbow_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_elbow_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hand_index_0_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hand_index_0_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hand_index_1_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hand_index_1_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hand_middle_0_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hand_middle_0_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hand_middle_1_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hand_middle_1_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hand_palm_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hand_palm_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hand_thumb_0_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hand_thumb_0_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hand_thumb_1_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hand_thumb_1_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hand_thumb_2_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hand_thumb_2_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hip_pitch_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hip_pitch_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hip_roll_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hip_roll_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_hip_yaw_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_hip_yaw_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_knee_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_knee_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_rubber_hand.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_rubber_hand.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_shoulder_pitch_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_shoulder_pitch_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_shoulder_roll_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_shoulder_roll_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_shoulder_yaw_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_shoulder_yaw_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_wrist_pitch_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_wrist_pitch_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_wrist_roll_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_wrist_roll_link.STL
Normal file
Binary file not shown.
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/left_wrist_yaw_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/left_wrist_yaw_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/logo_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/logo_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/pelvis.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/pelvis.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/pelvis_contour_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/pelvis_contour_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_ankle_pitch_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_ankle_pitch_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_ankle_roll_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_ankle_roll_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_elbow_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_elbow_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hand_index_0_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hand_index_0_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hand_index_1_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hand_index_1_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hand_middle_0_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hand_middle_0_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hand_middle_1_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hand_middle_1_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hand_palm_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hand_palm_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hand_thumb_0_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hand_thumb_0_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hand_thumb_1_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hand_thumb_1_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hand_thumb_2_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hand_thumb_2_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hip_pitch_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hip_pitch_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hip_roll_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hip_roll_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_hip_yaw_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_hip_yaw_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_knee_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_knee_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_rubber_hand.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_rubber_hand.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_shoulder_pitch_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_shoulder_pitch_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_shoulder_roll_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_shoulder_roll_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_shoulder_yaw_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_shoulder_yaw_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_wrist_pitch_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_wrist_pitch_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_wrist_roll_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_wrist_roll_link.STL
Normal file
Binary file not shown.
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/right_wrist_yaw_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/right_wrist_yaw_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/torso_constraint_L_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/torso_constraint_L_link.STL
Normal file
Binary file not shown.
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/torso_constraint_R_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/torso_constraint_R_link.STL
Normal file
Binary file not shown.
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/torso_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/torso_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/torso_link_23dof_rev_1_0.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/torso_link_23dof_rev_1_0.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/torso_link_rev_1_0.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/torso_link_rev_1_0.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/waist_constraint_L.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/waist_constraint_L.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/waist_constraint_R.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/waist_constraint_R.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/waist_roll_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/waist_roll_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/waist_roll_link_rev_1_0.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/waist_roll_link_rev_1_0.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/waist_support_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/waist_support_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/waist_yaw_link.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/waist_yaw_link.STL
Normal file
Binary file not shown.
BIN
dashboard/static/temp3d/g1/meshes/waist_yaw_link_rev_1_0.STL
Normal file
BIN
dashboard/static/temp3d/g1/meshes/waist_yaw_link_rev_1_0.STL
Normal file
Binary file not shown.
1196
dashboard/static/temp3d/index.html
Normal file
1196
dashboard/static/temp3d/index.html
Normal file
File diff suppressed because it is too large
Load Diff
1045
dashboard/static/temp3d/js/OrbitControls.js
Normal file
1045
dashboard/static/temp3d/js/OrbitControls.js
Normal file
File diff suppressed because it is too large
Load Diff
371
dashboard/static/temp3d/js/STLLoader.js
Normal file
371
dashboard/static/temp3d/js/STLLoader.js
Normal file
@ -0,0 +1,371 @@
|
||||
( function () {
|
||||
|
||||
/**
|
||||
* Description: A THREE loader for STL ASCII files, as created by Solidworks and other CAD programs.
|
||||
*
|
||||
* Supports both binary and ASCII encoded files, with automatic detection of type.
|
||||
*
|
||||
* The loader returns a non-indexed buffer geometry.
|
||||
*
|
||||
* Limitations:
|
||||
* Binary decoding supports "Magics" color format (http://en.wikipedia.org/wiki/STL_(file_format)#Color_in_binary_STL).
|
||||
* There is perhaps some question as to how valid it is to always assume little-endian-ness.
|
||||
* ASCII decoding assumes file is UTF-8.
|
||||
*
|
||||
* Usage:
|
||||
* const loader = new STLLoader();
|
||||
* loader.load( './models/stl/slotted_disk.stl', function ( geometry ) {
|
||||
* scene.add( new THREE.Mesh( geometry ) );
|
||||
* });
|
||||
*
|
||||
* For binary STLs geometry might contain colors for vertices. To use it:
|
||||
* // use the same code to load STL as above
|
||||
* if (geometry.hasColors) {
|
||||
* material = new THREE.MeshPhongMaterial({ opacity: geometry.alpha, vertexColors: true });
|
||||
* } else { .... }
|
||||
* const mesh = new THREE.Mesh( geometry, material );
|
||||
*
|
||||
* For ASCII STLs containing multiple solids, each solid is assigned to a different group.
|
||||
* Groups can be used to assign a different color by defining an array of materials with the same length of
|
||||
* geometry.groups and passing it to the Mesh constructor:
|
||||
*
|
||||
* const mesh = new THREE.Mesh( geometry, material );
|
||||
*
|
||||
* For example:
|
||||
*
|
||||
* const materials = [];
|
||||
* const nGeometryGroups = geometry.groups.length;
|
||||
*
|
||||
* const colorMap = ...; // Some logic to index colors.
|
||||
*
|
||||
* for (let i = 0; i < nGeometryGroups; i++) {
|
||||
*
|
||||
* const material = new THREE.MeshPhongMaterial({
|
||||
* color: colorMap[i],
|
||||
* wireframe: false
|
||||
* });
|
||||
*
|
||||
* }
|
||||
*
|
||||
* materials.push(material);
|
||||
* const mesh = new THREE.Mesh(geometry, materials);
|
||||
*/
|
||||
|
||||
class STLLoader extends THREE.Loader {
|
||||
|
||||
constructor( manager ) {
|
||||
|
||||
super( manager );
|
||||
|
||||
}
|
||||
|
||||
load( url, onLoad, onProgress, onError ) {
|
||||
|
||||
const scope = this;
|
||||
const loader = new THREE.FileLoader( this.manager );
|
||||
loader.setPath( this.path );
|
||||
loader.setResponseType( 'arraybuffer' );
|
||||
loader.setRequestHeader( this.requestHeader );
|
||||
loader.setWithCredentials( this.withCredentials );
|
||||
loader.load( url, function ( text ) {
|
||||
|
||||
try {
|
||||
|
||||
onLoad( scope.parse( text ) );
|
||||
|
||||
} catch ( e ) {
|
||||
|
||||
if ( onError ) {
|
||||
|
||||
onError( e );
|
||||
|
||||
} else {
|
||||
|
||||
console.error( e );
|
||||
|
||||
}
|
||||
|
||||
scope.manager.itemError( url );
|
||||
|
||||
}
|
||||
|
||||
}, onProgress, onError );
|
||||
|
||||
}
|
||||
|
||||
parse( data ) {
|
||||
|
||||
function isBinary( data ) {
|
||||
|
||||
const reader = new DataView( data );
|
||||
const face_size = 32 / 8 * 3 + 32 / 8 * 3 * 3 + 16 / 8;
|
||||
const n_faces = reader.getUint32( 80, true );
|
||||
const expect = 80 + 32 / 8 + n_faces * face_size;
|
||||
|
||||
if ( expect === reader.byteLength ) {
|
||||
|
||||
return true;
|
||||
|
||||
} // An ASCII STL data must begin with 'solid ' as the first six bytes.
|
||||
// However, ASCII STLs lacking the SPACE after the 'd' are known to be
|
||||
// plentiful. So, check the first 5 bytes for 'solid'.
|
||||
// Several encodings, such as UTF-8, precede the text with up to 5 bytes:
|
||||
// https://en.wikipedia.org/wiki/Byte_order_mark#Byte_order_marks_by_encoding
|
||||
// Search for "solid" to start anywhere after those prefixes.
|
||||
// US-ASCII ordinal values for 's', 'o', 'l', 'i', 'd'
|
||||
|
||||
|
||||
const solid = [ 115, 111, 108, 105, 100 ];
|
||||
|
||||
for ( let off = 0; off < 5; off ++ ) {
|
||||
|
||||
// If "solid" text is matched to the current offset, declare it to be an ASCII STL.
|
||||
if ( matchDataViewAt( solid, reader, off ) ) return false;
|
||||
|
||||
} // Couldn't find "solid" text at the beginning; it is binary STL.
|
||||
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function matchDataViewAt( query, reader, offset ) {
|
||||
|
||||
// Check if each byte in query matches the corresponding byte from the current offset
|
||||
for ( let i = 0, il = query.length; i < il; i ++ ) {
|
||||
|
||||
if ( query[ i ] !== reader.getUint8( offset + i, false ) ) return false;
|
||||
|
||||
}
|
||||
|
||||
return true;
|
||||
|
||||
}
|
||||
|
||||
function parseBinary( data ) {
|
||||
|
||||
const reader = new DataView( data );
|
||||
const faces = reader.getUint32( 80, true );
|
||||
let r,
|
||||
g,
|
||||
b,
|
||||
hasColors = false,
|
||||
colors;
|
||||
let defaultR, defaultG, defaultB, alpha; // process STL header
|
||||
// check for default color in header ("COLOR=rgba" sequence).
|
||||
|
||||
for ( let index = 0; index < 80 - 10; index ++ ) {
|
||||
|
||||
if ( reader.getUint32( index, false ) == 0x434F4C4F
|
||||
/*COLO*/
|
||||
&& reader.getUint8( index + 4 ) == 0x52
|
||||
/*'R'*/
|
||||
&& reader.getUint8( index + 5 ) == 0x3D
|
||||
/*'='*/
|
||||
) {
|
||||
|
||||
hasColors = true;
|
||||
colors = new Float32Array( faces * 3 * 3 );
|
||||
defaultR = reader.getUint8( index + 6 ) / 255;
|
||||
defaultG = reader.getUint8( index + 7 ) / 255;
|
||||
defaultB = reader.getUint8( index + 8 ) / 255;
|
||||
alpha = reader.getUint8( index + 9 ) / 255;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
const dataOffset = 84;
|
||||
const faceLength = 12 * 4 + 2;
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const vertices = new Float32Array( faces * 3 * 3 );
|
||||
const normals = new Float32Array( faces * 3 * 3 );
|
||||
|
||||
for ( let face = 0; face < faces; face ++ ) {
|
||||
|
||||
const start = dataOffset + face * faceLength;
|
||||
const normalX = reader.getFloat32( start, true );
|
||||
const normalY = reader.getFloat32( start + 4, true );
|
||||
const normalZ = reader.getFloat32( start + 8, true );
|
||||
|
||||
if ( hasColors ) {
|
||||
|
||||
const packedColor = reader.getUint16( start + 48, true );
|
||||
|
||||
if ( ( packedColor & 0x8000 ) === 0 ) {
|
||||
|
||||
// facet has its own unique color
|
||||
r = ( packedColor & 0x1F ) / 31;
|
||||
g = ( packedColor >> 5 & 0x1F ) / 31;
|
||||
b = ( packedColor >> 10 & 0x1F ) / 31;
|
||||
|
||||
} else {
|
||||
|
||||
r = defaultR;
|
||||
g = defaultG;
|
||||
b = defaultB;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
for ( let i = 1; i <= 3; i ++ ) {
|
||||
|
||||
const vertexstart = start + i * 12;
|
||||
const componentIdx = face * 3 * 3 + ( i - 1 ) * 3;
|
||||
vertices[ componentIdx ] = reader.getFloat32( vertexstart, true );
|
||||
vertices[ componentIdx + 1 ] = reader.getFloat32( vertexstart + 4, true );
|
||||
vertices[ componentIdx + 2 ] = reader.getFloat32( vertexstart + 8, true );
|
||||
normals[ componentIdx ] = normalX;
|
||||
normals[ componentIdx + 1 ] = normalY;
|
||||
normals[ componentIdx + 2 ] = normalZ;
|
||||
|
||||
if ( hasColors ) {
|
||||
|
||||
colors[ componentIdx ] = r;
|
||||
colors[ componentIdx + 1 ] = g;
|
||||
colors[ componentIdx + 2 ] = b;
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
geometry.setAttribute( 'position', new THREE.BufferAttribute( vertices, 3 ) );
|
||||
geometry.setAttribute( 'normal', new THREE.BufferAttribute( normals, 3 ) );
|
||||
|
||||
if ( hasColors ) {
|
||||
|
||||
geometry.setAttribute( 'color', new THREE.BufferAttribute( colors, 3 ) );
|
||||
geometry.hasColors = true;
|
||||
geometry.alpha = alpha;
|
||||
|
||||
}
|
||||
|
||||
return geometry;
|
||||
|
||||
}
|
||||
|
||||
function parseASCII( data ) {
|
||||
|
||||
const geometry = new THREE.BufferGeometry();
|
||||
const patternSolid = /solid([\s\S]*?)endsolid/g;
|
||||
const patternFace = /facet([\s\S]*?)endfacet/g;
|
||||
let faceCounter = 0;
|
||||
const patternFloat = /[\s]+([+-]?(?:\d*)(?:\.\d*)?(?:[eE][+-]?\d+)?)/.source;
|
||||
const patternVertex = new RegExp( 'vertex' + patternFloat + patternFloat + patternFloat, 'g' );
|
||||
const patternNormal = new RegExp( 'normal' + patternFloat + patternFloat + patternFloat, 'g' );
|
||||
const vertices = [];
|
||||
const normals = [];
|
||||
const normal = new THREE.Vector3();
|
||||
let result;
|
||||
let groupCount = 0;
|
||||
let startVertex = 0;
|
||||
let endVertex = 0;
|
||||
|
||||
while ( ( result = patternSolid.exec( data ) ) !== null ) {
|
||||
|
||||
startVertex = endVertex;
|
||||
const solid = result[ 0 ];
|
||||
|
||||
while ( ( result = patternFace.exec( solid ) ) !== null ) {
|
||||
|
||||
let vertexCountPerFace = 0;
|
||||
let normalCountPerFace = 0;
|
||||
const text = result[ 0 ];
|
||||
|
||||
while ( ( result = patternNormal.exec( text ) ) !== null ) {
|
||||
|
||||
normal.x = parseFloat( result[ 1 ] );
|
||||
normal.y = parseFloat( result[ 2 ] );
|
||||
normal.z = parseFloat( result[ 3 ] );
|
||||
normalCountPerFace ++;
|
||||
|
||||
}
|
||||
|
||||
while ( ( result = patternVertex.exec( text ) ) !== null ) {
|
||||
|
||||
vertices.push( parseFloat( result[ 1 ] ), parseFloat( result[ 2 ] ), parseFloat( result[ 3 ] ) );
|
||||
normals.push( normal.x, normal.y, normal.z );
|
||||
vertexCountPerFace ++;
|
||||
endVertex ++;
|
||||
|
||||
} // every face have to own ONE valid normal
|
||||
|
||||
|
||||
if ( normalCountPerFace !== 1 ) {
|
||||
|
||||
console.error( 'THREE.STLLoader: Something isn\'t right with the normal of face number ' + faceCounter );
|
||||
|
||||
} // each face have to own THREE valid vertices
|
||||
|
||||
|
||||
if ( vertexCountPerFace !== 3 ) {
|
||||
|
||||
console.error( 'THREE.STLLoader: Something isn\'t right with the vertices of face number ' + faceCounter );
|
||||
|
||||
}
|
||||
|
||||
faceCounter ++;
|
||||
|
||||
}
|
||||
|
||||
const start = startVertex;
|
||||
const count = endVertex - startVertex;
|
||||
geometry.addGroup( start, count, groupCount );
|
||||
groupCount ++;
|
||||
|
||||
}
|
||||
|
||||
geometry.setAttribute( 'position', new THREE.Float32BufferAttribute( vertices, 3 ) );
|
||||
geometry.setAttribute( 'normal', new THREE.Float32BufferAttribute( normals, 3 ) );
|
||||
return geometry;
|
||||
|
||||
}
|
||||
|
||||
function ensureString( buffer ) {
|
||||
|
||||
if ( typeof buffer !== 'string' ) {
|
||||
|
||||
return THREE.LoaderUtils.decodeText( new Uint8Array( buffer ) );
|
||||
|
||||
}
|
||||
|
||||
return buffer;
|
||||
|
||||
}
|
||||
|
||||
function ensureBinary( buffer ) {
|
||||
|
||||
if ( typeof buffer === 'string' ) {
|
||||
|
||||
const array_buffer = new Uint8Array( buffer.length );
|
||||
|
||||
for ( let i = 0; i < buffer.length; i ++ ) {
|
||||
|
||||
array_buffer[ i ] = buffer.charCodeAt( i ) & 0xff; // implicitly assumes little-endian
|
||||
|
||||
}
|
||||
|
||||
return array_buffer.buffer || array_buffer;
|
||||
|
||||
} else {
|
||||
|
||||
return buffer;
|
||||
|
||||
}
|
||||
|
||||
} // start
|
||||
|
||||
|
||||
const binData = ensureBinary( data );
|
||||
return isBinary( binData ) ? parseBinary( binData ) : parseASCII( ensureString( data ) );
|
||||
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
THREE.STLLoader = STLLoader;
|
||||
|
||||
} )();
|
||||
6
dashboard/static/temp3d/js/three.min.js
vendored
Normal file
6
dashboard/static/temp3d/js/three.min.js
vendored
Normal file
File diff suppressed because one or more lines are too long
90
dashboard/temp_motor_map.py
Normal file
90
dashboard/temp_motor_map.py
Normal file
@ -0,0 +1,90 @@
|
||||
"""G1 29-DoF motor → name / mesh mapping for the 3D temperature dashboard.
|
||||
|
||||
Ported verbatim from Marcus/Features/TempMonitor/config_g1.py so the copied
|
||||
three.js front-end (static/temp3d/index.html) binds temperature colours to the
|
||||
correct STL meshes. `build_payload()` turns the arm controller's raw lowstate
|
||||
snapshot into the exact 'motor_update' payload shape that front-end expects.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Optional
|
||||
|
||||
# Motor ID → human name (29 motors = 29 DOF)
|
||||
MOTOR_NAMES: dict[int, str] = {
|
||||
0: "Left Hip Pitch", 1: "Left Hip Roll", 2: "Left Hip Yaw", 3: "Left Knee",
|
||||
4: "Left Ankle Pitch", 5: "Left Ankle Roll",
|
||||
6: "Right Hip Pitch", 7: "Right Hip Roll", 8: "Right Hip Yaw", 9: "Right Knee",
|
||||
10: "Right Ankle Pitch", 11: "Right Ankle Roll",
|
||||
12: "Waist Yaw", 13: "Waist Roll", 14: "Waist Pitch",
|
||||
15: "Left Shoulder Pitch", 16: "Left Shoulder Roll", 17: "Left Shoulder Yaw",
|
||||
18: "Left Elbow", 19: "Left Wrist Roll", 20: "Left Wrist Pitch", 21: "Left Wrist Yaw",
|
||||
22: "Right Shoulder Pitch", 23: "Right Shoulder Roll", 24: "Right Shoulder Yaw",
|
||||
25: "Right Elbow", 26: "Right Wrist Roll", 27: "Right Wrist Pitch", 28: "Right Wrist Yaw",
|
||||
}
|
||||
|
||||
# Motor ID → URDF link / STL mesh name
|
||||
MOTOR_TO_MESH: dict[int, str] = {
|
||||
0: "left_hip_pitch_link", 1: "left_hip_roll_link", 2: "left_hip_yaw_link",
|
||||
3: "left_knee_link", 4: "left_ankle_pitch_link", 5: "left_ankle_roll_link",
|
||||
6: "right_hip_pitch_link", 7: "right_hip_roll_link", 8: "right_hip_yaw_link",
|
||||
9: "right_knee_link", 10: "right_ankle_pitch_link", 11: "right_ankle_roll_link",
|
||||
12: "waist_yaw_link", 13: "waist_roll_link", 14: "torso_link",
|
||||
15: "left_shoulder_pitch_link", 16: "left_shoulder_roll_link", 17: "left_shoulder_yaw_link",
|
||||
18: "left_elbow_link", 19: "left_wrist_roll_link", 20: "left_wrist_pitch_link",
|
||||
21: "left_wrist_yaw_link",
|
||||
22: "right_shoulder_pitch_link", 23: "right_shoulder_roll_link", 24: "right_shoulder_yaw_link",
|
||||
25: "right_elbow_link", 26: "right_wrist_roll_link", 27: "right_wrist_pitch_link",
|
||||
28: "right_wrist_yaw_link",
|
||||
}
|
||||
|
||||
# Temperature thresholds (°C) — the three.js gradient maps MIN→MAX (blue→red).
|
||||
TEMP_MIN = 30
|
||||
TEMP_MAX = 120
|
||||
TEMP_WARM_THRESHOLD = 45
|
||||
TEMP_HOT_THRESHOLD = 60
|
||||
|
||||
|
||||
def _coerce(v: Optional[int]) -> float:
|
||||
"""Temperatures default to 0 when the firmware didn't report one, so the
|
||||
front-end's Math.max / .toFixed never sees null/NaN."""
|
||||
return float(v) if v is not None else 0.0
|
||||
|
||||
|
||||
def build_payload(temps: list[dict[str, Any]],
|
||||
positions: list[float],
|
||||
timestamp: float) -> dict[str, Any]:
|
||||
"""Build the Marcus-compatible 'motor_update' payload.
|
||||
|
||||
`temps` — arm.get_motor_temps(): [{motor_id, surface, winding}]
|
||||
`positions` — arm.get_current_q(): joint angles indexed by motor id
|
||||
"""
|
||||
temperatures: list[dict[str, Any]] = []
|
||||
for t in temps or []:
|
||||
i = t.get("motor_id")
|
||||
surface = t.get("surface")
|
||||
winding = t.get("winding")
|
||||
if surface is not None and winding is not None:
|
||||
avg = (_coerce(surface) + _coerce(winding)) / 2.0
|
||||
else:
|
||||
avg = _coerce(surface if surface is not None else winding)
|
||||
entry: dict[str, Any] = {
|
||||
"motor_id": i,
|
||||
"motor_name": MOTOR_NAMES.get(i, f"Motor {i}"),
|
||||
"mesh_name": MOTOR_TO_MESH.get(i, ""),
|
||||
"surface": _coerce(surface),
|
||||
"winding": _coerce(winding),
|
||||
"temp1": _coerce(surface),
|
||||
"temp2": _coerce(winding),
|
||||
"avg": avg,
|
||||
}
|
||||
if positions and isinstance(i, int) and i < len(positions):
|
||||
entry["position"] = float(positions[i])
|
||||
temperatures.append(entry)
|
||||
|
||||
pos_list: list[dict[str, Any]] = [
|
||||
{"motor_id": i, "position": float(q), "link_name": MOTOR_TO_MESH.get(i)}
|
||||
for i, q in enumerate(positions or [])
|
||||
]
|
||||
return {"temperatures": temperatures, "positions": pos_list,
|
||||
"timestamp": timestamp}
|
||||
81
dashboard/websockets/motor_temps.py
Normal file
81
dashboard/websockets/motor_temps.py
Normal file
@ -0,0 +1,81 @@
|
||||
"""WebSocket endpoint streaming G1 motor temperatures to the 3D dashboard (N1).
|
||||
|
||||
Polls the arm controller's throttled rt/lowstate snapshot (arm.get_motor_temps
|
||||
/ arm.get_current_q — NO second DDS subscriber, no second ChannelFactoryInitialize)
|
||||
and pushes a Marcus-compatible 'motor_update' payload to each connected client.
|
||||
|
||||
Front-end: dashboard/static/temp3d/index.html (ported three.js view), which
|
||||
opens this socket via a tiny shim in place of socket.io.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import threading
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from Project.Sanad.core.logger import get_logger
|
||||
from Project.Sanad.dashboard.temp_motor_map import build_payload
|
||||
|
||||
log = get_logger("motor_temps_ws")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
MAX_WATCHERS = 20
|
||||
PUSH_HZ = 8.0 # ~8 fps is plenty for a temperature heatmap
|
||||
|
||||
_count = 0
|
||||
_count_lock = threading.Lock()
|
||||
|
||||
|
||||
def _get_arm():
|
||||
"""Lazy import — avoids a circular import on dashboard load."""
|
||||
try:
|
||||
from Project.Sanad.main import arm # type: ignore
|
||||
return arm
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@router.websocket("/ws/motor-temps")
|
||||
async def motor_temps_ws(ws: WebSocket):
|
||||
await ws.accept()
|
||||
|
||||
global _count
|
||||
with _count_lock:
|
||||
if _count >= MAX_WATCHERS:
|
||||
await ws.close(code=1013, reason="Too many temperature watchers")
|
||||
return
|
||||
_count += 1
|
||||
|
||||
period = 1.0 / PUSH_HZ
|
||||
try:
|
||||
while True:
|
||||
arm = _get_arm()
|
||||
temps: list = []
|
||||
positions: list = []
|
||||
if arm is not None:
|
||||
try:
|
||||
temps = arm.get_motor_temps()
|
||||
except Exception:
|
||||
temps = []
|
||||
try:
|
||||
positions = arm.get_current_q()
|
||||
except Exception:
|
||||
positions = []
|
||||
payload = build_payload(temps, positions, time.time())
|
||||
await ws.send_json(payload)
|
||||
await asyncio.sleep(period)
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
except Exception:
|
||||
# Any other error (client gone mid-send, serialise issue) closes cleanly.
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
finally:
|
||||
with _count_lock:
|
||||
_count -= 1
|
||||
323
dashboard/websockets/terminal.py
Normal file
323
dashboard/websockets/terminal.py
Normal file
@ -0,0 +1,323 @@
|
||||
"""WebSocket → PTY bridge for the dashboard's Terminal tab.
|
||||
|
||||
Spawns a shell (bash by default) inside a pseudo-terminal on the robot and
|
||||
relays stdin/stdout to a browser xterm.js instance over WebSocket. From the
|
||||
operator's seat this is functionally identical to an in-browser
|
||||
`ssh unitree@<robot>` — except no SSH handshake is needed because the
|
||||
dashboard process already runs as unitree on the robot. The Terminal tab
|
||||
connects to ws://<dashboard>/ws/terminal and you land in unitree's shell
|
||||
directly.
|
||||
|
||||
PROTOCOL — text frames only. Control vs. keystrokes are disambiguated by
|
||||
the leading byte:
|
||||
client → server:
|
||||
"\\x1f" + json-encoded control object (init / resize)
|
||||
e.g. "\\x1f{\\"type\\":\\"init\\",\\"cols\\":80,\\"rows\\":24}"
|
||||
<any other text> keystrokes — written to PTY
|
||||
server → client:
|
||||
<text> PTY stdout/stderr chunks
|
||||
|
||||
The \\x1f prefix (ASCII Unit Separator) is the disambiguator. If we just
|
||||
JSON-sniffed every message, a user pasting `{"type":"resize",...}` into
|
||||
their shell would silently resize the PTY instead of pasting the text.
|
||||
|
||||
SECURITY NOTE: anyone who can reach the dashboard URL gets shell access
|
||||
as the unitree user. The dashboard already exposes equally-powerful
|
||||
endpoints (E-STOP, motion replay, audio mute, etc.) so this isn't a new
|
||||
threat class — but it IS a single-bullet kill switch for the robot. Bind
|
||||
the dashboard to a trusted network only.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import fcntl
|
||||
import json
|
||||
import os
|
||||
import pty
|
||||
import select
|
||||
import shutil
|
||||
import signal
|
||||
import struct
|
||||
import termios
|
||||
import threading
|
||||
|
||||
from fastapi import APIRouter, WebSocket, WebSocketDisconnect
|
||||
|
||||
from Project.Sanad.core.logger import get_logger
|
||||
|
||||
log = get_logger("terminal_ws")
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
# Magic prefix that distinguishes control messages from raw keystrokes.
|
||||
# ASCII 0x1F (Unit Separator) — not produced by normal keyboard input,
|
||||
# so user-pasted JSON can never spoof a control frame.
|
||||
_CTRL_PREFIX = "\x1f"
|
||||
|
||||
# Concurrent-session cap so a runaway tab can't spawn 50 bashes on the robot.
|
||||
_MAX_SESSIONS = 4
|
||||
_active: set[int] = set()
|
||||
_active_lock = threading.Lock()
|
||||
|
||||
# Bounded queue depth between the PTY reader thread and the WS sender.
|
||||
# A chatty shell command (e.g. `yes`, `cat /dev/urandom`) at gigabytes/sec
|
||||
# would otherwise pile up unbounded asyncio tasks + string refs. Past the
|
||||
# cap we drop chunks and surface a single drop notice — ANSI may corrupt
|
||||
# briefly but the session stays alive.
|
||||
_SEND_QUEUE_MAX = 64
|
||||
|
||||
|
||||
def _resolve_shell() -> list[str]:
|
||||
"""Pick a sensible shell. SHELL env first, then /bin/bash, then sh."""
|
||||
sh = os.environ.get("SHELL", "")
|
||||
if sh and shutil.which(sh):
|
||||
return [sh, "-i"]
|
||||
if shutil.which("/bin/bash"):
|
||||
return ["/bin/bash", "-i"]
|
||||
return ["/bin/sh", "-i"]
|
||||
|
||||
|
||||
def _set_pty_size(fd: int, cols: int, rows: int) -> None:
|
||||
"""Inform the PTY of its new window size so curses-style apps (htop,
|
||||
less, vim) lay out correctly."""
|
||||
try:
|
||||
# TIOCSWINSZ payload: rows, cols, xpixel, ypixel (xpixel/ypixel
|
||||
# unused, kept 0).
|
||||
fcntl.ioctl(fd, termios.TIOCSWINSZ,
|
||||
struct.pack("HHHH", rows, cols, 0, 0))
|
||||
except Exception as exc:
|
||||
log.debug("TIOCSWINSZ failed (cols=%s rows=%s): %s", cols, rows, exc)
|
||||
|
||||
|
||||
async def _reap_child(pid: int) -> None:
|
||||
"""SIGHUP → wait briefly → SIGKILL → wait briefly → giveup.
|
||||
|
||||
Earlier version SIGKILLed unconditionally because the WNOHANG check
|
||||
happened immediately after SIGHUP (which never returns true that fast).
|
||||
Now we poll for up to ~1.5s after SIGHUP before escalating.
|
||||
"""
|
||||
async def _wait_exit(timeout_s: float, interval_s: float = 0.1) -> bool:
|
||||
end = asyncio.get_running_loop().time() + timeout_s
|
||||
while asyncio.get_running_loop().time() < end:
|
||||
try:
|
||||
done_pid, _ = os.waitpid(pid, os.WNOHANG)
|
||||
except ChildProcessError:
|
||||
return True # already reaped
|
||||
except OSError:
|
||||
return False
|
||||
if done_pid:
|
||||
return True
|
||||
await asyncio.sleep(interval_s)
|
||||
return False
|
||||
|
||||
# 1. Polite request
|
||||
try:
|
||||
os.kill(pid, signal.SIGHUP)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except OSError as exc:
|
||||
log.debug("SIGHUP pid=%d: %s", pid, exc)
|
||||
return
|
||||
|
||||
if await _wait_exit(1.5):
|
||||
return
|
||||
|
||||
# 2. Force
|
||||
try:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
except ProcessLookupError:
|
||||
return
|
||||
except OSError as exc:
|
||||
log.debug("SIGKILL pid=%d: %s", pid, exc)
|
||||
return
|
||||
|
||||
if not await _wait_exit(1.0):
|
||||
log.warning("terminal child pid=%d failed to exit after SIGKILL", pid)
|
||||
|
||||
|
||||
@router.websocket("/ws/terminal")
|
||||
async def terminal_ws(ws: WebSocket) -> None:
|
||||
"""Bridge a browser xterm.js to a shell PTY on the robot."""
|
||||
await ws.accept()
|
||||
|
||||
# Concurrent-session guard.
|
||||
with _active_lock:
|
||||
if len(_active) >= _MAX_SESSIONS:
|
||||
await ws.send_text(
|
||||
f"\r\n[terminal] Refused — already have {_MAX_SESSIONS} "
|
||||
f"open sessions. Close another tab and reconnect.\r\n"
|
||||
)
|
||||
await ws.close(code=1008)
|
||||
return
|
||||
|
||||
# Fork + exec the shell. Parent gets the master fd; child becomes the
|
||||
# shell with stdin/stdout/stderr wired to the slave end.
|
||||
cmd = _resolve_shell()
|
||||
try:
|
||||
pid, fd = pty.fork()
|
||||
except OSError as exc:
|
||||
log.error("pty.fork failed: %s", exc)
|
||||
await ws.send_text(f"\r\n[terminal] pty.fork failed: {exc}\r\n")
|
||||
await ws.close(code=1011)
|
||||
return
|
||||
|
||||
if pid == 0:
|
||||
# CHILD — set env so the shell is interactive and looks right.
|
||||
os.environ.setdefault("TERM", "xterm-256color")
|
||||
os.environ.setdefault("LANG", os.environ.get("LANG", "en_US.UTF-8"))
|
||||
try:
|
||||
os.execvp(cmd[0], cmd)
|
||||
except OSError as exc:
|
||||
# exec failed — printing to fd 2 reaches the parent via the
|
||||
# PTY so the browser sees the error before we _exit.
|
||||
os.write(2, f"[terminal] exec failed: {exc}\n".encode())
|
||||
os._exit(127)
|
||||
return # unreachable in child
|
||||
|
||||
# PARENT
|
||||
with _active_lock:
|
||||
_active.add(pid)
|
||||
log.info("terminal session started pid=%d cmd=%s", pid, cmd[0])
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
closed = asyncio.Event()
|
||||
# Bounded queue + dedicated sender task = backpressure. If the queue
|
||||
# fills up we drop the chunk and bump _dropped so we can surface a
|
||||
# short notice in the stream.
|
||||
send_q: asyncio.Queue[str] = asyncio.Queue(maxsize=_SEND_QUEUE_MAX)
|
||||
dropped = 0
|
||||
|
||||
def _reader_thread() -> None:
|
||||
"""Drain PTY master fd → queue. Runs in a daemon thread because
|
||||
select.select on a pipe blocks; asyncio has no portable
|
||||
equivalent for arbitrary fds on Windows (and we want one code
|
||||
path)."""
|
||||
nonlocal dropped
|
||||
try:
|
||||
while not closed.is_set():
|
||||
try:
|
||||
r, _, _ = select.select([fd], [], [], 0.1)
|
||||
except (OSError, ValueError):
|
||||
break
|
||||
if not r:
|
||||
continue
|
||||
try:
|
||||
data = os.read(fd, 4096)
|
||||
except OSError:
|
||||
break
|
||||
if not data: # EOF — child exited / PTY closed
|
||||
break
|
||||
try:
|
||||
text = data.decode("utf-8", errors="replace")
|
||||
except Exception:
|
||||
continue
|
||||
# put_nowait raises on full — we drop and count.
|
||||
try:
|
||||
loop.call_soon_threadsafe(_enqueue, text)
|
||||
except RuntimeError:
|
||||
# loop closed — bail
|
||||
break
|
||||
finally:
|
||||
loop.call_soon_threadsafe(closed.set)
|
||||
|
||||
def _enqueue(text: str) -> None:
|
||||
nonlocal dropped
|
||||
try:
|
||||
send_q.put_nowait(text)
|
||||
except asyncio.QueueFull:
|
||||
dropped += 1
|
||||
|
||||
async def _sender_task() -> None:
|
||||
"""Drains send_q → WebSocket. Single producer, single consumer
|
||||
means no extra locking needed. Backoff on send failure and let
|
||||
the closed flag end the session."""
|
||||
nonlocal dropped
|
||||
while not closed.is_set():
|
||||
try:
|
||||
text = await asyncio.wait_for(send_q.get(), timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
try:
|
||||
await ws.send_text(text)
|
||||
except Exception as exc:
|
||||
log.info("terminal ws.send failed (likely client gone): %s", exc)
|
||||
closed.set()
|
||||
return
|
||||
# If we dropped chunks since the last successful send, tell
|
||||
# the user once so the ANSI corruption isn't mysterious.
|
||||
if dropped:
|
||||
d = dropped
|
||||
dropped = 0
|
||||
try:
|
||||
await ws.send_text(
|
||||
f"\r\n\x1b[2m[term: dropped {d} chunk(s) — slow client]"
|
||||
f"\x1b[0m\r\n",
|
||||
)
|
||||
except Exception:
|
||||
closed.set()
|
||||
return
|
||||
|
||||
reader = threading.Thread(target=_reader_thread, daemon=True,
|
||||
name=f"terminal-rx-{pid}")
|
||||
reader.start()
|
||||
sender = asyncio.create_task(_sender_task())
|
||||
|
||||
# Initial sizing — xterm.js will send a {type:"init",...} control
|
||||
# frame right after onopen with the actual window size.
|
||||
_set_pty_size(fd, 80, 24)
|
||||
|
||||
try:
|
||||
while not closed.is_set():
|
||||
try:
|
||||
msg = await asyncio.wait_for(ws.receive_text(), timeout=0.5)
|
||||
except asyncio.TimeoutError:
|
||||
continue
|
||||
except WebSocketDisconnect:
|
||||
break
|
||||
|
||||
if not msg:
|
||||
continue
|
||||
|
||||
# Control frame? Must start with the magic prefix. User-typed
|
||||
# / pasted text can never spoof this — \x1f isn't producible
|
||||
# by normal keyboard input.
|
||||
if msg[:1] == _CTRL_PREFIX:
|
||||
try:
|
||||
ctrl = json.loads(msg[1:])
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
ctrl = None
|
||||
if isinstance(ctrl, dict) and ctrl.get("type") in ("init", "resize"):
|
||||
cols = int(ctrl.get("cols") or 80)
|
||||
rows = int(ctrl.get("rows") or 24)
|
||||
_set_pty_size(fd, cols, rows)
|
||||
# Either way, control frames are NEVER forwarded to PTY.
|
||||
continue
|
||||
|
||||
# Plain keystrokes — write to PTY master.
|
||||
try:
|
||||
os.write(fd, msg.encode("utf-8", errors="replace"))
|
||||
except OSError as exc:
|
||||
log.info("terminal pty write failed (child likely exited): %s", exc)
|
||||
break
|
||||
finally:
|
||||
closed.set()
|
||||
try:
|
||||
sender.cancel()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
await _reap_child(pid)
|
||||
except Exception as exc:
|
||||
log.debug("reap_child pid=%d: %s", pid, exc)
|
||||
try:
|
||||
os.close(fd)
|
||||
except OSError:
|
||||
pass
|
||||
with _active_lock:
|
||||
_active.discard(pid)
|
||||
log.info("terminal session ended pid=%d", pid)
|
||||
try:
|
||||
await ws.close()
|
||||
except Exception:
|
||||
pass
|
||||
10
data/.recognition_state.json
Normal file
10
data/.recognition_state.json
Normal file
@ -0,0 +1,10 @@
|
||||
{
|
||||
"vision_enabled": false,
|
||||
"face_rec_enabled": false,
|
||||
"gallery_version": 0,
|
||||
"zone_rec_enabled": false,
|
||||
"zones_version": 0,
|
||||
"nav_target_zone_id": 0,
|
||||
"nav_target_place_id": 0,
|
||||
"movement_enabled": false
|
||||
}
|
||||
BIN
data/audio/Etisalat.wav
Normal file
BIN
data/audio/Etisalat.wav
Normal file
Binary file not shown.
BIN
data/audio/Etisalat_1.wav
Normal file
BIN
data/audio/Etisalat_1.wav
Normal file
Binary file not shown.
BIN
data/audio/Etisalat_1_raw.wav
Normal file
BIN
data/audio/Etisalat_1_raw.wav
Normal file
Binary file not shown.
BIN
data/audio/Etisalat_raw.wav
Normal file
BIN
data/audio/Etisalat_raw.wav
Normal file
Binary file not shown.
BIN
data/audio/Gccc.wav
Normal file
BIN
data/audio/Gccc.wav
Normal file
Binary file not shown.
BIN
data/audio/Gccc_1.wav
Normal file
BIN
data/audio/Gccc_1.wav
Normal file
Binary file not shown.
BIN
data/audio/Gccc_1_raw.wav
Normal file
BIN
data/audio/Gccc_1_raw.wav
Normal file
Binary file not shown.
BIN
data/audio/Gccc_raw.wav
Normal file
BIN
data/audio/Gccc_raw.wav
Normal file
Binary file not shown.
BIN
data/audio/Gccmm.wav
Normal file
BIN
data/audio/Gccmm.wav
Normal file
Binary file not shown.
BIN
data/audio/Gccmm_1.wav
Normal file
BIN
data/audio/Gccmm_1.wav
Normal file
Binary file not shown.
BIN
data/audio/Gccmm_1_raw.wav
Normal file
BIN
data/audio/Gccmm_1_raw.wav
Normal file
Binary file not shown.
BIN
data/audio/Gccmm_raw.wav
Normal file
BIN
data/audio/Gccmm_raw.wav
Normal file
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Loading…
x
Reference in New Issue
Block a user