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",
|
"model_ws_uri": "wss://generativelanguage.googleapis.com/ws/google.ai.generativelanguage.v1alpha.GenerativeService.BidiGenerateContent",
|
||||||
"voice_name": "Charon",
|
"voice_name": "Charon",
|
||||||
"ws_timeout_sec": 30,
|
"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": {
|
"g1_hardware": {
|
||||||
@ -53,7 +53,7 @@
|
|||||||
|
|
||||||
"script_files": {
|
"script_files": {
|
||||||
"_comment": "Filenames (under scripts/) used across voice + dashboard",
|
"_comment": "Filenames (under scripts/) used across voice + dashboard",
|
||||||
"persona": "sanad_v2",
|
"persona": "sanad_script.txt",
|
||||||
"rules": "sanad_rule.txt",
|
"rules": "sanad_rule.txt",
|
||||||
"arm_phrases": "sanad_arm.txt"
|
"arm_phrases": "sanad_arm.txt"
|
||||||
},
|
},
|
||||||
|
|||||||
@ -13,6 +13,20 @@
|
|||||||
"jsonl_id_start": 100
|
"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": {
|
"macro_player": {
|
||||||
"_comment": "motion/macro_player.py — JSONL playback",
|
"_comment": "motion/macro_player.py — JSONL playback",
|
||||||
"ramp_in_steps": 60,
|
"ramp_in_steps": 60,
|
||||||
|
|||||||
@ -51,9 +51,12 @@ _REST_ROUTES: list[tuple[str, str, str]] = [
|
|||||||
("live_subprocess", "/api/live-subprocess", "live-subprocess"),
|
("live_subprocess", "/api/live-subprocess", "live-subprocess"),
|
||||||
("typed_replay", "/api/typed-replay", "typed-replay"),
|
("typed_replay", "/api/typed-replay", "typed-replay"),
|
||||||
("recognition", "/api/recognition", "recognition"),
|
("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] = []
|
_loaded_routes: list[str] = []
|
||||||
_failed_routes: dict[str, str] = {}
|
_failed_routes: dict[str, str] = {}
|
||||||
|
|||||||
@ -473,3 +473,450 @@ async def apply_audio():
|
|||||||
pass
|
pass
|
||||||
return result
|
return result
|
||||||
return await asyncio.to_thread(_do)
|
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()
|
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):
|
class MacroName(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
@ -63,6 +79,7 @@ async def stop_recording():
|
|||||||
@router.post("/play")
|
@router.post("/play")
|
||||||
async def play_macro(payload: MacroName):
|
async def play_macro(payload: MacroName):
|
||||||
from Project.Sanad.main import brain
|
from Project.Sanad.main import brain
|
||||||
|
_block_if_movement_armed()
|
||||||
return await brain.play_macro(payload.name)
|
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)
|
has_motion = payload.action_id is not None or bool(payload.motion_file)
|
||||||
if not has_audio and not has_motion:
|
if not has_audio and not has_motion:
|
||||||
raise HTTPException(400, "pick at least one of audio_file / action_id / motion_file")
|
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 = []
|
tasks = []
|
||||||
result: dict = {
|
result: dict = {
|
||||||
|
|||||||
@ -10,6 +10,23 @@ from pydantic import BaseModel
|
|||||||
router = APIRouter()
|
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")
|
@router.get("/status")
|
||||||
async def motion_status():
|
async def motion_status():
|
||||||
from Project.Sanad.main import arm
|
from Project.Sanad.main import arm
|
||||||
@ -33,6 +50,7 @@ async def trigger_action(payload: TriggerPayload):
|
|||||||
from Project.Sanad.main import arm
|
from Project.Sanad.main import arm
|
||||||
if arm is None:
|
if arm is None:
|
||||||
raise HTTPException(503, "Arm controller not attached.")
|
raise HTTPException(503, "Arm controller not attached.")
|
||||||
|
_block_if_movement_armed()
|
||||||
|
|
||||||
speed = max(0.1, min(payload.speed, 5.0))
|
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
|
from Project.Sanad.main import audio_mgr
|
||||||
import asyncio
|
import asyncio
|
||||||
await asyncio.to_thread(audio_mgr.play_wav, raw_path)
|
# Fire-and-forget — play_wav blocks for the clip duration on the G1
|
||||||
return {"ok": True, "record_name": payload.record_name, "file_kind": payload.file_kind, "path": str(raw_path)}
|
# 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):
|
class RecordRename(BaseModel):
|
||||||
|
|||||||
@ -21,6 +21,22 @@ log = get_logger("replay_route")
|
|||||||
router = APIRouter()
|
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 --
|
# -- models --
|
||||||
|
|
||||||
class ReplayRequest(BaseModel):
|
class ReplayRequest(BaseModel):
|
||||||
@ -94,6 +110,7 @@ _BG_TASKS: set[asyncio.Task] = set()
|
|||||||
async def test_replay(payload: ReplayRequest):
|
async def test_replay(payload: ReplayRequest):
|
||||||
"""Test-play a motion file at the given speed."""
|
"""Test-play a motion file at the given speed."""
|
||||||
from Project.Sanad.main import arm
|
from Project.Sanad.main import arm
|
||||||
|
_block_if_movement_armed()
|
||||||
if arm.is_busy:
|
if arm.is_busy:
|
||||||
raise HTTPException(409, "Arm is busy.")
|
raise HTTPException(409, "Arm is busy.")
|
||||||
path = safe_path_under(MOTIONS_DIR, payload.name)
|
path = safe_path_under(MOTIONS_DIR, payload.name)
|
||||||
@ -142,6 +159,7 @@ async def start_teaching(payload: TeachRequest):
|
|||||||
from Project.Sanad.main import teacher
|
from Project.Sanad.main import teacher
|
||||||
if teacher is None:
|
if teacher is None:
|
||||||
raise HTTPException(503, "Teaching module not available.")
|
raise HTTPException(503, "Teaching module not available.")
|
||||||
|
_block_if_movement_armed()
|
||||||
if teacher.is_recording:
|
if teacher.is_recording:
|
||||||
raise HTTPException(409, "Teaching session already active.")
|
raise HTTPException(409, "Teaching session already active.")
|
||||||
existing = MOTIONS_DIR / f"{payload.name}.jsonl"
|
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">
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
<title>Sanad Dashboard</title>
|
<title>Sanad Dashboard</title>
|
||||||
<style>
|
<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}
|
*{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}
|
body{font-family:'Inter','Segoe UI',system-ui,sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
||||||
/* Header */
|
/* 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{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-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)}
|
.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{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)}
|
#estop:hover{box-shadow:0 0 20px rgba(239,68,68,.5);transform:scale(1.04)}
|
||||||
/* Tabs */
|
/* Tabs */
|
||||||
@ -118,13 +128,24 @@
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</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 -->
|
<!-- Tabs -->
|
||||||
<div class="tabs">
|
<div class="tabs">
|
||||||
<div class="tab active" onclick="switchTab('operations')">Operations</div>
|
<div class="tab active" onclick="switchTab('operations')">Operations</div>
|
||||||
<div class="tab" onclick="switchTab('voice')">Voice & Audio</div>
|
<div class="tab" onclick="switchTab('voice')">Voice & Audio</div>
|
||||||
<div class="tab" onclick="switchTab('motion')">Motion & Replay</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('recognition')">Recognition</div>
|
||||||
<div class="tab" onclick="switchTab('recordings')">Recordings</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 class="tab" onclick="switchTab('settings')">Settings & Logs</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@ -193,6 +214,8 @@
|
|||||||
</select>
|
</select>
|
||||||
<button class="btn btn-primary btn-sm" onclick="applyAudioProfile(this)" title="Apply selected profile to PulseAudio">Apply</button>
|
<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="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>
|
||||||
<div id="audio-detected" style="margin-top:.3rem;font-size:.65rem;color:var(--dim)"></div>
|
<div id="audio-detected" style="margin-top:.3rem;font-size:.65rem;color:var(--dim)"></div>
|
||||||
</div>
|
</div>
|
||||||
@ -350,7 +373,8 @@
|
|||||||
|
|
||||||
<!-- ==================== TAB: Motion & Replay ==================== -->
|
<!-- ==================== TAB: Motion & Replay ==================== -->
|
||||||
<div class="tab-content" id="tab-motion">
|
<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 -->
|
<!-- Full Motion Control -->
|
||||||
<div class="card card-full">
|
<div class="card card-full">
|
||||||
@ -552,9 +576,170 @@
|
|||||||
<div id="rec-faces-list" style="margin-top:.6rem"><div class="empty">Loading…</div></div>
|
<div id="rec-faces-list" style="margin-top:.6rem"><div class="empty">Loading…</div></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>
|
||||||
</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>
|
||||||
|
|
||||||
<!-- ==================== TAB: Recordings ==================== -->
|
<!-- ==================== TAB: Recordings ==================== -->
|
||||||
<div class="tab-content" id="tab-recordings">
|
<div class="tab-content" id="tab-recordings">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@ -569,6 +754,16 @@
|
|||||||
<!-- Saved Records -->
|
<!-- Saved Records -->
|
||||||
<div class="card card-full">
|
<div class="card card-full">
|
||||||
<h3>Saved Records</h3>
|
<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>
|
<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>
|
<button class="btn btn-ghost btn-sm" onclick="refreshRecords()" style="margin-top:.3rem">Refresh</button>
|
||||||
</div>
|
</div>
|
||||||
@ -576,6 +771,30 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 ==================== -->
|
<!-- ==================== TAB: Settings & Logs ==================== -->
|
||||||
<div class="tab-content" id="tab-settings">
|
<div class="tab-content" id="tab-settings">
|
||||||
<div class="grid">
|
<div class="grid">
|
||||||
@ -838,6 +1057,61 @@ async function scanAudioDevices(b){
|
|||||||
refreshAudioDevices();
|
refreshAudioDevices();
|
||||||
refreshAudio();
|
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){
|
async function refreshAudioDevices(b){
|
||||||
if(b)btnLoad(b);
|
if(b)btnLoad(b);
|
||||||
try{
|
try{
|
||||||
@ -1098,8 +1372,54 @@ async function reloadPrompt(){try{const r=await api('POST','/api/prompt/reload')
|
|||||||
|
|
||||||
// Records
|
// 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 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){}}
|
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
|
// Live Voice
|
||||||
async function startLiveVoice(b){
|
async function startLiveVoice(b){
|
||||||
@ -1448,7 +1768,7 @@ function stopRecPreview(){if(_recPreviewTimer){clearInterval(_recPreviewTimer);_
|
|||||||
const origSwitchTab=window.switchTab;
|
const origSwitchTab=window.switchTab;
|
||||||
window.switchTab=function(name){
|
window.switchTab=function(name){
|
||||||
origSwitchTab(name);
|
origSwitchTab(name);
|
||||||
if(name==='recognition'){refreshRecognition();refreshFaces();startRecPreview();}
|
if(name==='recognition'){refreshRecognition();refreshFaces();refreshZones();startRecPreview();}
|
||||||
else{stopRecPreview();}
|
else{stopRecPreview();}
|
||||||
};
|
};
|
||||||
})();
|
})();
|
||||||
@ -1591,10 +1911,437 @@ async function deleteFace(id){
|
|||||||
}catch(e){toast('Delete failed','err');}
|
}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.
|
// 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);
|
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>
|
</script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</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