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
+141
View File
@@ -0,0 +1,141 @@
"""Subprocess orchestration for lidar/camera/recording processes.
ManagedProcess.stop() is a near-literal port of the `_kill_proc` pattern used
by scan_gui.py / scan_gui_dual.py / scan_gui_triple.py: SIGINT the whole
process group (so `ros2 launch` subtrees and `ros2 bag record` get a chance
to shut down / flush cleanly), wait, then SIGKILL stragglers. This must not
regress — it's the one piece of logic explicitly called out as load-bearing
in the design plan.
"""
from __future__ import annotations
import logging
import os
import signal
import subprocess
import threading
import time
from pathlib import Path
from typing import Optional
import psutil
logger = logging.getLogger("scan_web.process_manager")
class ManagedProcess:
def __init__(self, name: str, cmd: str, log_path: Optional[Path] = None, env: Optional[dict] = None):
self.name = name
self.cmd = cmd
self.log_path = log_path
self.env = env
self._proc: Optional[subprocess.Popen] = None
self._log_file = None
self._lock = threading.Lock()
self.started_at: Optional[float] = None
def start(self) -> None:
with self._lock:
if self.is_running():
return
env = os.environ.copy()
env["PYTHONUNBUFFERED"] = "1"
if self.env:
env.update(self.env)
stdout = subprocess.DEVNULL
stderr = subprocess.DEVNULL
if self.log_path is not None:
self.log_path.parent.mkdir(parents=True, exist_ok=True)
self._log_file = open(self.log_path, "w")
stdout = self._log_file
stderr = subprocess.STDOUT
self._proc = subprocess.Popen(
["bash", "-c", self.cmd],
stdout=stdout, stderr=stderr,
preexec_fn=os.setsid, env=env,
)
self.started_at = time.monotonic()
logger.info("started %s pid=%s", self.name, self._proc.pid)
def is_running(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def pid(self) -> Optional[int]:
return self._proc.pid if self._proc else None
def stop(self, timeout: float = 3.0) -> None:
with self._lock:
proc = self._proc
if proc is None or proc.poll() is not None:
self._cleanup()
return
try:
parent = psutil.Process(proc.pid)
children = parent.children(recursive=True)
except psutil.NoSuchProcess:
self._cleanup()
return
for p in children + [parent]:
try:
p.send_signal(signal.SIGINT)
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
_, alive = psutil.wait_procs(children + [parent], timeout=timeout)
for p in alive:
try:
p.kill()
except (psutil.NoSuchProcess, psutil.AccessDenied):
pass
logger.info("stopped %s", self.name)
self._cleanup()
def _cleanup(self) -> None:
self._proc = None
self.started_at = None
if self._log_file:
self._log_file.close()
self._log_file = None
class ProcessManager:
"""App-lifetime singleton (stored on app.state) tracking all managed processes."""
def __init__(self):
self._procs: dict[str, ManagedProcess] = {}
self._lock = threading.Lock()
def start(self, name: str, cmd: str, log_path: Optional[Path] = None, env: Optional[dict] = None) -> ManagedProcess:
with self._lock:
mp = self._procs.get(name)
if mp is None or not mp.is_running():
mp = ManagedProcess(name, cmd, log_path=log_path, env=env)
self._procs[name] = mp
mp.start()
return mp
def stop(self, name: str, timeout: float = 3.0) -> None:
with self._lock:
mp = self._procs.get(name)
if mp is not None:
mp.stop(timeout=timeout)
def is_running(self, name: str) -> bool:
with self._lock:
mp = self._procs.get(name)
return mp is not None and mp.is_running()
def status(self) -> dict[str, dict]:
with self._lock:
items = list(self._procs.items())
return {
name: {"running": mp.is_running(), "pid": mp.pid()}
for name, mp in items
}
def stop_all(self, timeout: float = 3.0) -> None:
with self._lock:
names = list(self._procs.keys())
for name in names:
self.stop(name, timeout=timeout)