支持通过 UART 手动逐台添加相机再启动输出
保留已连接相机,添加失败不占用编号,并要求输出停止后才能新增。通过 23 项回归测试、真实 SDK 的 1–4 路逐台添加验证、Ruff 和文档检查。
This commit is contained in:
+40
-37
@@ -1,8 +1,8 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Headless UART finger service for 1-4 sensors; see ../docs/uart.md.
|
||||
|
||||
The login console and data UART must be separate ports. Every configured camera
|
||||
is prepared before UART output. All force records include a 1-based sensor ID.
|
||||
The login console and data UART must be separate ports. Cameras are added
|
||||
manually via UART before starting output. All force records include a 1-based sensor ID.
|
||||
Importing this module opens no devices; acquisition is owned by finger_api.
|
||||
"""
|
||||
|
||||
@@ -12,6 +12,7 @@ import argparse
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from collections import deque
|
||||
|
||||
@@ -26,15 +27,14 @@ class UartFingerController:
|
||||
"""Single-threaded command service; the caller owns serial and final cleanup."""
|
||||
|
||||
def __init__(
|
||||
self, ser, *, sources, configs=("finger",), loop_video=True,
|
||||
show=False, verbose=False, read_only=False,
|
||||
self, ser, *, config="finger", loop_video=True,
|
||||
show=False, verbose=False,
|
||||
):
|
||||
self.ser = ser
|
||||
self.bank = FingerSensorBank(sources, configs=configs, verbose=verbose)
|
||||
self.bank = FingerSensorBank(config=config, verbose=verbose)
|
||||
self.loop_video = loop_video
|
||||
self.show = show
|
||||
self.verbose = verbose
|
||||
self.read_only = read_only
|
||||
self._streaming = False
|
||||
self._should_quit = False
|
||||
self._partial = bytearray()
|
||||
@@ -68,8 +68,6 @@ class UartFingerController:
|
||||
return None
|
||||
# Never readline(): a timeout may return only half of a command.
|
||||
raw = self.ser.read(min(waiting, 4096) if waiting else 1)
|
||||
if self.read_only:
|
||||
return None
|
||||
for byte in raw:
|
||||
if byte in (10, 13):
|
||||
if self._discard_line:
|
||||
@@ -130,19 +128,39 @@ class UartFingerController:
|
||||
logger.exception("Sensor %s display failed", reading.sensor_id)
|
||||
|
||||
def _dispatch(self, line):
|
||||
if self.read_only:
|
||||
try:
|
||||
parts = shlex.split(line)
|
||||
except ValueError as exc:
|
||||
self.reply(f"ERR command {exc}")
|
||||
return True
|
||||
parts = line.split()
|
||||
if not parts:
|
||||
return True
|
||||
command = parts[0].lower()
|
||||
if command not in {"start", "stop", "read", "quit"}:
|
||||
if command == "add":
|
||||
if len(parts) not in (2, 3):
|
||||
self.reply("ERR command expected add SOURCE [CONFIG]")
|
||||
elif self._streaming:
|
||||
self.reply("ERR add stop streaming first")
|
||||
else:
|
||||
try:
|
||||
sensor_id = self.bank.add(
|
||||
parse_source(parts[1]), config=parts[2] if len(parts) == 3 else None,
|
||||
)
|
||||
except Exception as exc:
|
||||
logger.exception("Sensor addition failed")
|
||||
self.reply(f"ERR add {exc}")
|
||||
else:
|
||||
self.reply(f"OK added {sensor_id}")
|
||||
elif command not in {"start", "stop", "read", "quit"}:
|
||||
self.reply(f"ERR unknown {command}")
|
||||
elif len(parts) != 1:
|
||||
self.reply("ERR command start|stop|read|quit take no arguments")
|
||||
elif command == "start":
|
||||
self._streaming = True
|
||||
self.reply("OK start")
|
||||
if not self.bank.runtimes:
|
||||
self.reply("ERR start no sensors added")
|
||||
else:
|
||||
self._streaming = True
|
||||
self.reply("OK start")
|
||||
elif command == "stop":
|
||||
self._streaming = False
|
||||
self.reply("OK stop")
|
||||
@@ -155,10 +173,8 @@ class UartFingerController:
|
||||
return not self._should_quit
|
||||
|
||||
def serve(self):
|
||||
if set(self.bank.runtimes) != set(self.bank.sources):
|
||||
raise RuntimeError("connect all configured cameras before serving UART")
|
||||
self.reply(f"OK ready {len(self.bank.sources)}")
|
||||
self._streaming = True
|
||||
self._streaming = False
|
||||
while not self._should_quit:
|
||||
for _ in range(16):
|
||||
line = self._poll_command_line(blocking=not self._streaming)
|
||||
@@ -168,6 +184,7 @@ class UartFingerController:
|
||||
return
|
||||
if self._streaming:
|
||||
self._read_round()
|
||||
|
||||
def open_serial(port, baud, timeout, write_timeout=1.0):
|
||||
try:
|
||||
import serial
|
||||
@@ -181,44 +198,30 @@ def open_serial(port, baud, timeout, write_timeout=1.0):
|
||||
|
||||
|
||||
def main(argv=None):
|
||||
parser = argparse.ArgumentParser(description="UART finger control and read-only streaming")
|
||||
parser = argparse.ArgumentParser(description="Manual UART finger service: add, start, stop, read, quit")
|
||||
parser.add_argument("--port", default="/dev/ttyHS1")
|
||||
parser.add_argument("--baud", type=int, default=115200)
|
||||
parser.add_argument("--timeout", type=float, default=0.1, help="idle read timeout in seconds")
|
||||
parser.add_argument("--write-timeout", type=float, default=1.0)
|
||||
parser.add_argument("--config", "-c", action="append", help="one shared config or repeat per sensor")
|
||||
parser.add_argument("--sources", required=True, nargs="+", help="1-4 camera indices, device paths or video files")
|
||||
parser.add_argument("--config", "-c", default="finger", help="default config for add commands")
|
||||
parser.add_argument("--no-loop-video", dest="loop_video", action="store_false")
|
||||
parser.add_argument("--show", action="store_true", help="local desktop debug only")
|
||||
parser.add_argument("--verbose", action="store_true")
|
||||
parser.add_argument("--read-only", action="store_true", help="ignore all UART commands; exit from the login console")
|
||||
args = parser.parse_args(argv)
|
||||
if args.baud <= 0:
|
||||
parser.error("baud must be positive")
|
||||
if any(not math.isfinite(t) or t <= 0 for t in (args.timeout, args.write_timeout)):
|
||||
parser.error("timeouts must be finite and positive")
|
||||
configs = args.config or ["finger"]
|
||||
try:
|
||||
sources = [parse_source(s) for s in args.sources]
|
||||
controller = UartFingerController(
|
||||
None, sources=sources, configs=configs,
|
||||
loop_video=args.loop_video, show=args.show, verbose=args.verbose,
|
||||
read_only=args.read_only,
|
||||
)
|
||||
except ValueError as exc:
|
||||
parser.error(str(exc))
|
||||
controller = UartFingerController(
|
||||
None, config=args.config, loop_video=args.loop_video,
|
||||
show=args.show, verbose=args.verbose,
|
||||
)
|
||||
ser = None
|
||||
result = 0
|
||||
try:
|
||||
for sensor_id, source in controller.bank.sources.items():
|
||||
print(
|
||||
f"Preparing sensor {sensor_id}: source={source} config={controller.bank.configs[sensor_id]}",
|
||||
file=sys.stderr, flush=True,
|
||||
)
|
||||
controller.bank.connect_all()
|
||||
ser = open_serial(args.port, args.baud, args.timeout, args.write_timeout)
|
||||
controller.ser = ser
|
||||
print(f"UART service on {args.port} @ {args.baud}; sources={sources}", file=sys.stderr, flush=True)
|
||||
print(f"UART service on {args.port} @ {args.baud}; waiting for add SOURCE, then start", file=sys.stderr, flush=True)
|
||||
controller.serve()
|
||||
except KeyboardInterrupt:
|
||||
print("Interrupted.", file=sys.stderr)
|
||||
|
||||
+47
-51
@@ -1,7 +1,7 @@
|
||||
"""Single-threaded finger acquisition API, independent of UART and the CLI.
|
||||
|
||||
Sensor IDs are 1-based, fixed by source order. Calls and runtime ownership belong
|
||||
to one thread; the caller must close the bank in a finally block. Values use the
|
||||
Sensor IDs are 1-based, assigned after each successful add(). Calls and runtime
|
||||
ownership belong to one thread; close the bank in a finally block. Values use the
|
||||
SDK's FNORMAL/FSHEARX/FSHEARY axes and calibration units, not assumed Newtons.
|
||||
"""
|
||||
|
||||
@@ -40,74 +40,68 @@ class ForceReading:
|
||||
|
||||
|
||||
class FingerSensorBank:
|
||||
"""Own 1-4 independent runtimes; connect_all() precedes read_all().
|
||||
"""Own up to four independently added runtimes; add() precedes read_all().
|
||||
|
||||
Processing/connection failures raise exceptions; they never become zero force.
|
||||
A shared config can be reused, or each source can have its own config.
|
||||
"""
|
||||
|
||||
def __init__(self, sources, *, configs=("finger",), verbose=False):
|
||||
sources = tuple(parse_source(str(source)) for source in sources)
|
||||
configs = tuple(configs)
|
||||
if not 1 <= len(sources) <= 4:
|
||||
raise ValueError("provide 1 to 4 sources")
|
||||
if len(configs) not in (1, len(sources)):
|
||||
raise ValueError("provide one config or one config per source")
|
||||
if any(isinstance(source, int) and source < 0 for source in sources):
|
||||
raise ValueError("camera index must be non-negative")
|
||||
if len(configs) == 1:
|
||||
configs = configs * len(sources)
|
||||
identities = []
|
||||
for source in sources:
|
||||
if isinstance(source, int):
|
||||
identity = f"camera:{source}"
|
||||
elif source.startswith("/dev/video") and source[len("/dev/video"):].isdigit():
|
||||
identity = f"camera:{int(source[len('/dev/video'):])}"
|
||||
else:
|
||||
identity = str(Path(source).resolve())
|
||||
identities.append(identity)
|
||||
if len(set(identities)) != len(identities):
|
||||
raise ValueError("each sensor must use a different source")
|
||||
self.sources = dict(enumerate(sources, 1))
|
||||
self.configs = dict(enumerate(configs, 1))
|
||||
def __init__(self, *, config="finger", verbose=False):
|
||||
self.default_config = config
|
||||
self.sources = {}
|
||||
self.configs = {}
|
||||
self.runtimes = {}
|
||||
self.verbose = verbose
|
||||
|
||||
def connect_all(self) -> None:
|
||||
"""Prepare every source, rolling back all runtimes on any failure."""
|
||||
@staticmethod
|
||||
def _source_identity(source):
|
||||
if isinstance(source, int):
|
||||
return f"camera:{source}"
|
||||
if source.startswith("/dev/video") and source[len("/dev/video"):].isdigit():
|
||||
return f"camera:{int(source[len('/dev/video'):])}"
|
||||
return str(Path(source).resolve())
|
||||
|
||||
def add(self, source: Source, *, config: str | None = None) -> int:
|
||||
"""Open/prepare one source. Failure preserves existing sensors and IDs."""
|
||||
source = parse_source(str(source))
|
||||
if len(self.runtimes) >= 4:
|
||||
raise ValueError("maximum 4 sensors")
|
||||
if isinstance(source, int) and source < 0:
|
||||
raise ValueError("camera index must be non-negative")
|
||||
identity = self._source_identity(source)
|
||||
if any(self._source_identity(existing) == identity for existing in self.sources.values()):
|
||||
raise ValueError("source already added")
|
||||
if is_video_file(source) and not Path(source).is_file():
|
||||
raise ValueError(f"file not found: {source}")
|
||||
from orisys.finger import FingerRuntime
|
||||
|
||||
if self.runtimes:
|
||||
raise RuntimeError("already connected; close before reconnecting")
|
||||
sensor_id = None
|
||||
config = self.default_config if config is None else config
|
||||
runtime = None
|
||||
try:
|
||||
for sensor_id, source in self.sources.items():
|
||||
if is_video_file(source) and not Path(source).is_file():
|
||||
raise ValueError(f"file not found: {source}")
|
||||
runtime = FingerRuntime.open(
|
||||
config=self.configs[sensor_id], video=source, verbose=self.verbose,
|
||||
)
|
||||
self.runtimes[sensor_id] = runtime
|
||||
ok, _ = runtime.warmup(color=False)
|
||||
if not ok:
|
||||
raise RuntimeError("warmup could not read a frame")
|
||||
except Exception as exc:
|
||||
try:
|
||||
self.close()
|
||||
except Exception:
|
||||
logger.exception("Connection rollback failed")
|
||||
raise RuntimeError(f"sensor {sensor_id}: {exc}") from exc
|
||||
runtime = FingerRuntime.open(config=config, video=source, verbose=self.verbose)
|
||||
ok, _ = runtime.warmup(color=False)
|
||||
if not ok:
|
||||
raise RuntimeError("warmup could not read a frame")
|
||||
except BaseException:
|
||||
self.close()
|
||||
if runtime is not None:
|
||||
try:
|
||||
runtime.close()
|
||||
except Exception:
|
||||
logger.exception("New sensor cleanup failed")
|
||||
raise
|
||||
sensor_id = len(self.runtimes) + 1
|
||||
self.sources[sensor_id] = source
|
||||
self.configs[sensor_id] = config
|
||||
self.runtimes[sensor_id] = runtime
|
||||
return sensor_id
|
||||
|
||||
def read_all(self, *, loop_video=False) -> tuple[ForceReading, ...]:
|
||||
"""Read a complete round. EOF/failure raises; no partial round is returned.
|
||||
|
||||
The frames are read sequentially, not synchronized across sources.
|
||||
"""
|
||||
if set(self.runtimes) != set(self.sources):
|
||||
raise RuntimeError("all configured sensors must be connected first")
|
||||
if not self.runtimes:
|
||||
raise RuntimeError("no sensors added")
|
||||
readings = []
|
||||
for sensor_id in self.sources:
|
||||
try:
|
||||
@@ -152,5 +146,7 @@ class FingerSensorBank:
|
||||
except Exception as exc:
|
||||
logger.exception("Sensor %s cleanup failed", sensor_id)
|
||||
first_error = first_error or exc
|
||||
self.sources.clear()
|
||||
self.configs.clear()
|
||||
if first_error is not None:
|
||||
raise first_error
|
||||
|
||||
Reference in New Issue
Block a user