支持通过 UART 手动逐台添加相机再启动输出

保留已连接相机,添加失败不占用编号,并要求输出停止后才能新增。通过 23 项回归测试、真实 SDK 的 1–4 路逐台添加验证、Ruff 和文档检查。
This commit is contained in:
ykcheungab
2026-10-14 15:49:58 +08:00
parent 07339c3374
commit 133a98e418
5 changed files with 422 additions and 330 deletions
+47 -51
View File
@@ -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