Initial commit: scan_web integrated recording UI

FastAPI backend + vanilla JS frontend for LiDAR/camera startup, camera
settings, and rosbag recording, unifying the previous scan_gui/scan_gui_dual/
scan_gui_triple desktop tools into one web app.
This commit is contained in:
gardentech
2026-08-07 14:26:00 +09:00
commit f34817d5b6
38 changed files with 5430 additions and 0 deletions
+85
View File
@@ -0,0 +1,85 @@
"""Synthesized topic health (no diagnostics topics exist upstream — see plan).
Classification per topic:
DOWN - backing process not running, OR no message ever received since
process start, OR staleness beyond down_threshold
STALE - running, >=1 message received, staleness in (stale, down]
OK - staleness <= stale_threshold
Also tracks a rolling message rate (Hz) per topic — a measured number is a
more actionable status signal than a traffic light alone (e.g. "cam2 4.1Hz"
tells the operator it's alive but degraded, not just "not OK").
"""
from __future__ import annotations
import threading
import time
from collections import deque
from dataclasses import dataclass
RATE_WINDOW = 150 # samples kept per topic — larger window trades responsiveness for a
# stable number: our own callback dispatch has scheduling jitter (see
# ros_bridge.py's MultiThreadedExecutor/GIL-contention notes), so a short
# window made the displayed Hz visibly jump around between updates even
# when the underlying topic rate was steady. 150 samples spans ~25s for a
# ~6Hz camera down to ~2s for ~80Hz IMU — smooths out that jitter without
# the number going stale-looking on genuinely slow topics.
RATE_STALE_S = 2.0 # if last message older than this, report rate as 0 regardless of window
@dataclass
class TopicThreshold:
stale_s: float
down_s: float
class TopicHealthTracker:
def __init__(self):
self._last_msg_time: dict[str, float] = {}
self._recent_times: dict[str, deque] = {}
self._lock = threading.Lock()
def mark_received(self, topic: str) -> None:
now = time.monotonic()
with self._lock:
self._last_msg_time[topic] = now
dq = self._recent_times.setdefault(topic, deque(maxlen=RATE_WINDOW))
dq.append(now)
def reset(self, topic: str) -> None:
with self._lock:
self._last_msg_time.pop(topic, None)
self._recent_times.pop(topic, None)
def classify(self, topic: str, process_running: bool, thresholds: TopicThreshold) -> str:
if not process_running:
return "DOWN"
with self._lock:
last = self._last_msg_time.get(topic)
if last is None:
return "DOWN"
staleness = time.monotonic() - last
if staleness <= thresholds.stale_s:
return "OK"
if staleness <= thresholds.down_s:
return "STALE"
return "DOWN"
def staleness(self, topic: str) -> float | None:
with self._lock:
last = self._last_msg_time.get(topic)
return None if last is None else time.monotonic() - last
def rate_hz(self, topic: str) -> float:
"""Measured message rate over the last few samples. 0.0 if stale or unseen."""
with self._lock:
last = self._last_msg_time.get(topic)
dq = self._recent_times.get(topic)
if last is None or dq is None or len(dq) < 2:
return 0.0
if time.monotonic() - last > RATE_STALE_S:
return 0.0
span = dq[-1] - dq[0]
if span <= 0:
return 0.0
return round((len(dq) - 1) / span, 1)