支持通过 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
+109 -100
View File
@@ -1,145 +1,154 @@
# UART finger service # UART finger service
Start the service with an explicit list of 1-4 camera sources. It opens and Start the service first, then manually add 1-4 camera sources through UART.
warms up **every source first**, then opens the data UART and starts publishing. Each successful `add` opens and warms up one source. Send `start` when the set
If any source fails to prepare, all opened sources are closed and the process is ready. There is no camera discovery, hot-plug monitoring or automatic connection.
exits with status 1. No partial camera set is published.
## Connect the cameras and start once ## Start the service and add cameras
Plug the USB cameras into the Q6A. In its TTL login shell, identify their actual In the Q6A TTL login shell, activate the board environment prepared by
capture device paths before launching; one USB camera can expose more than one `setup/sdk_setup.sh`. It needs Python 3.10+, the `orisys` SDK and `pyserial`.
`/dev/video*` node, so do not assume every node represents a different camera. From the board's `dragon-q6a` directory:
For example, `v4l2-ctl --list-devices` shows device groups. Select one usable
capture node per camera. Use the board environment prepared by `setup/sdk_setup.sh`;
it needs Python 3.10+, the `orisys` SDK and `pyserial`.
Run from the board's `dragon-q6a` directory. These paths are examples, not detected
hardware identities:
```bash ```bash
# One camera. python serial/example_uart.py --port /dev/ttyHS1 --config finger
python serial/example_uart.py --port /dev/ttyHS1 --sources /dev/video0
# Two cameras.
python serial/example_uart.py --port /dev/ttyHS1 --sources /dev/video0 /dev/video2
# Three cameras.
python serial/example_uart.py --port /dev/ttyHS1 --sources /dev/video0 /dev/video2 /dev/video4
# Four cameras.
python serial/example_uart.py --port /dev/ttyHS1 --sources /dev/video0 /dev/video2 /dev/video4 /dev/video6
``` ```
The first source is sensor 1, the second sensor 2, and so on. The number of listed The service opens only the data UART and replies `OK ready 0`. No camera is
sources determines the sensor count. Camera indices such as `--sources 0 2` are opened and no measurement is sent until the host adds a source and starts output.
also accepted. Sources must be distinct. A shared `--config finger` is the Send these lines through the data UART, waiting for each reply:
default; repeat `--config` once per source to use different configurations.
The login console prints each sensor's source/config mapping during preparation.
There is no UART connection command: physical connection and source selection
happen before startup, and software connection happens automatically.
The TTL login console and data UART must use **different ports**. The data
UART defaults to `/dev/ttyHS1`, 115200 baud, 8N1 without flow control. It must
not be occupied by getty or another serial program. This script does not enable
UART pins or configure the board's hardware. Logs stay in the login console;
protocol records go to the data UART. `--show` is optional desktop debugging.
For a passive receiver, append `--read-only` to the same startup command. Startup
and output are identical, but incoming UART commands are discarded. Exit using
Ctrl-C in the login shell. The receiver should filter `force,` records because
startup and failures also produce status records.
## One protocol for every sensor count
After all sources are ready, the data UART reports `OK ready N`, then immediately
streams each complete processing round in source order. Every measurement has
five CSV fields, including a sensor ID even when only one camera is configured:
```text ```text
OK ready 2 add /dev/video0
OK added 1
add /dev/video2
OK added 2
start
OK start
force,1,1.250000,-2.500000,3.750000 force,1,1.250000,-2.500000,3.750000
force,2,0.500000,0.100000,-0.200000 force,2,0.500000,0.100000,-0.200000
``` ```
The fields are `force,sensor_id,FNORMAL,FSHEARX,FSHEARY`. Axes follow the SDK; Use actual capture nodes; the paths above are examples. One USB camera can expose
units depend on calibration and must not be assumed to be Newtons. CSV carries multiple `/dev/video*` nodes, so do not assume every node represents a different
no timestamp or units. Frames are processed sequentially, not synchronized camera. `v4l2-ctl --list-devices` shows device groups. Select one usable capture
across cameras. All inputs for a round must succeed before that round is node per camera. Camera indices such as `add 0` are also accepted. Sources must
published. A transport failure can still interrupt transmission between lines. be distinct; `0` and `/dev/video0` are treated as the same camera.
Control mode accepts only four commands, each acting on the entire camera set: The first successful addition is sensor 1, the next sensor 2, up to sensor 4.
No sensor count is declared in advance. A failed addition replies `ERR add ...`,
closes its newly opened runtime if one exists, and leaves existing cameras and
IDs unchanged. It does not consume an ID. The service remains available for retry.
| Command | Result | The TTL login console and data UART must use **different ports**. Data defaults
to `/dev/ttyHS1`, 115200 baud, 8N1 without flow control. It must not be occupied
by getty or another serial program. The script does not configure UART pins.
Logs stay in the login console; protocol records go to the data UART.
`--show` is optional local desktop debugging.
## Commands and output
| Command | Behavior |
|---|---| |---|---|
| `stop` | Pause continuous output and reply `OK stop`; keep cameras open. | | `add SOURCE [CONFIG]` | Open and prepare one source while output is stopped; reply `OK added ID`. Optional config overrides the startup `--config` for this sensor only. |
| `start` | Resume continuous output and reply `OK start`. | | `start` | Begin continuous output for all added sensors; requires at least one sensor. Reply `OK start`. |
| `read` | Process and publish one complete round; preserve streaming state on success. | | `stop` | Pause continuous output for the whole set; keep all cameras open. Reply `OK stop`. |
| `read` | Publish one complete processing round; requires a sensor and preserves streaming state on success. |
| `quit` | Reply `OK quit`, close all cameras and UART, then exit. | | `quit` | Reply `OK quit`, close all cameras and UART, then exit. |
Commands take no arguments. They are case-insensitive UTF-8 lines terminated by To extend the camera set after streaming has started, pause before adding:
LF, CR or CRLF. Partial lines survive read timeouts; commands exceeding 1024
bytes are discarded through their terminator. Unknown commands or arguments
produce `ERR`. The former protocol and source-selection options have been
removed; receivers must use this single CSV format and explicit `--sources`.
A capture or processing failure produces `ERR read sensor ID: ...` and pauses ```text
output for the whole set. Measurements are never replaced with zeros. Sources stop
remain open; `start` retries processing, or restart the service to reopen them. OK stop
For recorded files, `--no-loop-video` reports `OK eof sensor ID: video ended` add /dev/video4
and pauses the whole set at EOF. Otherwise, one rewind and tracker reset is OK added 3
attempted per read. Ctrl-C always ends the process from the login console. start
OK start
```
`add` while streaming replies `ERR add stop streaming first` and opens no source.
A fifth source or duplicate source is rejected. Removing/replacing cameras requires
quitting and starting a fresh service. `start`, `stop`, `read` and `quit` take no
arguments and always act on the full set. The older commands and automatic
startup options are removed.
Commands are case-insensitive UTF-8 lines terminated by LF, CR or CRLF. Partial
lines survive read timeouts. Lines exceeding 1024 bytes are discarded through
their terminator. Quote source/config paths containing spaces:
```text
add "/path/test clip.mp4" "/path/my config.json"
```
Every measurement has five CSV fields, even with only one sensor:
`force,sensor_id,FNORMAL,FSHEARX,FSHEARY`. Axes follow the SDK; units depend on
calibration and must not be assumed to be Newtons. CSV carries no units or
timestamp. The receiver can remain passive after sending setup commands and
`start`; it needs no further requests to receive continuous data. Filter `force,`
lines to distinguish measurements from replies and errors.
Frames are processed sequentially, not synchronized across cameras. All sources
must succeed before a processing round is published. A capture/processing failure
produces `ERR read sensor ID: ...` and stops continuous output for the full set,
without fabricating zero values or transmitting a partial processing round.
A transport failure can still interrupt transmission between lines. `start`
retries processing; restart the service to reopen an unavailable camera.
For recorded files, `--no-loop-video` replies `OK eof sensor ID: video ended` and
pauses the whole set at EOF. Otherwise one rewind and tracker reset is attempted
per read. Ctrl-C in the login shell ends the service and releases resources.
## Callable acquisition API ## Callable acquisition API
`serial/finger_api.py` has no UART dependency and opens no resources on import. `serial/finger_api.py` has no UART dependency and opens no resources on import.
Put its directory on the caller's module search path. Use the same connection-first Place its directory on the caller's module search path. Adding sources is explicit:
lifecycle as the service:
```python ```python
from finger_api import FingerSensorBank from finger_api import FingerSensorBank
bank = FingerSensorBank([0, 2], configs=["finger"]) bank = FingerSensorBank(config="finger")
try: try:
bank.connect_all() first_id = bank.add(0)
second_id = bank.add(2, config="finger")
for reading in bank.read_all(): for reading in bank.read_all():
print(reading.sensor_id, reading.normal, reading.shear_x, reading.shear_y, reading.units) print(reading.sensor_id, reading.normal, reading.shear_x, reading.shear_y, reading.units)
finally: finally:
bank.close() bank.close()
``` ```
One thread owns the bank. `connect_all()` rolls back every opened runtime on One thread owns the bank. `add()` returns a 1-based ID only after successful
failure. `read_all()` returns a tuple only when every source succeeds; it raises preparation; it raises on failure and preserves previously added runtimes.
`EOFError` for file EOF and `RuntimeError` for capture/processing failure. Each `read_all()` requires at least one source and returns a tuple only when all
reading contains the unrolled image, three scalar forces and SDK units from its sources succeed. File EOF raises `EOFError`; capture/processing failure raises
own runtime. Older SDKs without a units property report `unknown`. Reconnection `RuntimeError`. Each reading contains its own unrolled image, three scalar forces
requires closing the bank before calling `connect_all()` again. and SDK units; older SDKs without a units property report `unknown`. `close()`
attempts every runtime and clears the camera set. The API caller must pause its
own processing loop before adding; the UART service enforces this for commands.
## Backend evidence and remaining limits ## Backend evidence and remaining limits
The SDK's `FingerRuntime.open()` constructs a separate capture object, The SDK's `FingerRuntime.open()` creates separate capture, unwarping and sensor
`FrameUnwarper`, `TactileProcessor` and `Sensor` for each source. Live sources state for each source. SDK code is unchanged. Verification on 2026-10-14 used
have separate SDK capture workers. The service holds all runtimes open together local SDK revision `3953747f1726b197a3f327a38c65d9878e333e12`: 1, 2, 3 and 4
and reads them sequentially. SDK implementation is unchanged. recorded sources were added incrementally, with complete readings after each
addition and three final rounds for each count. Existing runtime identities were
preserved; capture handles and sensor objects were distinct, and optical-flow
arrays did not share memory. Resources were released after each case.
Verification on 2026-10-14 used local SDK revision To rerun with a recording containing at least 12 frames, put generated clips and
`3953747f1726b197a3f327a38c65d9878e333e12`: real SDK instances processed 1, 2, 3 caches in an isolated task scratch directory:
and 4 distinct recorded inputs, three complete rounds for each count. Capture
handles and sensor objects were distinct; optical-flow arrays did not share
memory. All capture resources were released after each case. Run the same check
with a recording containing at least 12 frames; put generated clips and caches
in an isolated task scratch directory:
```bash ```bash
python tests/check_backend.py --recording /path/to/recording.avi --scratch .scratch/uart-connect-first/backend-run python tests/check_backend.py --recording /path/to/recording.avi --scratch .scratch/uart-manual-add/backend-run
python -m unittest discover -s tests -v python -m unittest discover -s tests -v
``` ```
Unit tests separately verify startup ordering, full rollback, a uniform protocol, Unit tests separately verify empty startup, explicit additions, failure isolation,
removed commands/options, command framing and failure behavior. These tests do IDs/limits, stop-before-add, command framing, complete-round output and cleanup.
not prove Q6A USB bandwidth or the shipped compiled ARM `orisys 0.5.0` wheel. The shipped compiled ARM `orisys 0.5.0` wheel and Q6A physical camera/UART setup
Run the backend check in the board's installed environment to verify that SDK. remain unverified. Run the backend check inside the board's installed environment
Four live cameras, serial wiring, sustained frame rates and command latency to verify that SDK. USB bandwidth, sustained frame rates and command latency
still require hardware validation. A slow capture/processing call delays command require hardware validation. Camera opening/warmup and synchronous reads can delay
handling. Serial writes time out after a configurable interval (default 1 second). command handling. Serial writes have a configurable timeout, default 1 second.
At 115200 baud/8N1, all data and replies share 11,520 bytes/s. At 115200 baud/8N1, all data and replies share 11,520 bytes/s.
+40 -37
View File
@@ -1,8 +1,8 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""Headless UART finger service for 1-4 sensors; see ../docs/uart.md. """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 The login console and data UART must be separate ports. Cameras are added
is prepared before UART output. All force records include a 1-based sensor ID. 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. Importing this module opens no devices; acquisition is owned by finger_api.
""" """
@@ -12,6 +12,7 @@ import argparse
import logging import logging
import math import math
import os import os
import shlex
import sys import sys
from collections import deque from collections import deque
@@ -26,15 +27,14 @@ class UartFingerController:
"""Single-threaded command service; the caller owns serial and final cleanup.""" """Single-threaded command service; the caller owns serial and final cleanup."""
def __init__( def __init__(
self, ser, *, sources, configs=("finger",), loop_video=True, self, ser, *, config="finger", loop_video=True,
show=False, verbose=False, read_only=False, show=False, verbose=False,
): ):
self.ser = ser self.ser = ser
self.bank = FingerSensorBank(sources, configs=configs, verbose=verbose) self.bank = FingerSensorBank(config=config, verbose=verbose)
self.loop_video = loop_video self.loop_video = loop_video
self.show = show self.show = show
self.verbose = verbose self.verbose = verbose
self.read_only = read_only
self._streaming = False self._streaming = False
self._should_quit = False self._should_quit = False
self._partial = bytearray() self._partial = bytearray()
@@ -68,8 +68,6 @@ class UartFingerController:
return None return None
# Never readline(): a timeout may return only half of a command. # Never readline(): a timeout may return only half of a command.
raw = self.ser.read(min(waiting, 4096) if waiting else 1) raw = self.ser.read(min(waiting, 4096) if waiting else 1)
if self.read_only:
return None
for byte in raw: for byte in raw:
if byte in (10, 13): if byte in (10, 13):
if self._discard_line: if self._discard_line:
@@ -130,19 +128,39 @@ class UartFingerController:
logger.exception("Sensor %s display failed", reading.sensor_id) logger.exception("Sensor %s display failed", reading.sensor_id)
def _dispatch(self, line): 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 return True
parts = line.split()
if not parts: if not parts:
return True return True
command = parts[0].lower() 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}") self.reply(f"ERR unknown {command}")
elif len(parts) != 1: elif len(parts) != 1:
self.reply("ERR command start|stop|read|quit take no arguments") self.reply("ERR command start|stop|read|quit take no arguments")
elif command == "start": elif command == "start":
self._streaming = True if not self.bank.runtimes:
self.reply("OK start") self.reply("ERR start no sensors added")
else:
self._streaming = True
self.reply("OK start")
elif command == "stop": elif command == "stop":
self._streaming = False self._streaming = False
self.reply("OK stop") self.reply("OK stop")
@@ -155,10 +173,8 @@ class UartFingerController:
return not self._should_quit return not self._should_quit
def serve(self): 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.reply(f"OK ready {len(self.bank.sources)}")
self._streaming = True self._streaming = False
while not self._should_quit: while not self._should_quit:
for _ in range(16): for _ in range(16):
line = self._poll_command_line(blocking=not self._streaming) line = self._poll_command_line(blocking=not self._streaming)
@@ -168,6 +184,7 @@ class UartFingerController:
return return
if self._streaming: if self._streaming:
self._read_round() self._read_round()
def open_serial(port, baud, timeout, write_timeout=1.0): def open_serial(port, baud, timeout, write_timeout=1.0):
try: try:
import serial import serial
@@ -181,44 +198,30 @@ def open_serial(port, baud, timeout, write_timeout=1.0):
def main(argv=None): 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("--port", default="/dev/ttyHS1")
parser.add_argument("--baud", type=int, default=115200) 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("--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("--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("--config", "-c", default="finger", help="default config for add commands")
parser.add_argument("--sources", required=True, nargs="+", help="1-4 camera indices, device paths or video files")
parser.add_argument("--no-loop-video", dest="loop_video", action="store_false") 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("--show", action="store_true", help="local desktop debug only")
parser.add_argument("--verbose", action="store_true") 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) args = parser.parse_args(argv)
if args.baud <= 0: if args.baud <= 0:
parser.error("baud must be positive") parser.error("baud must be positive")
if any(not math.isfinite(t) or t <= 0 for t in (args.timeout, args.write_timeout)): 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") parser.error("timeouts must be finite and positive")
configs = args.config or ["finger"] controller = UartFingerController(
try: None, config=args.config, loop_video=args.loop_video,
sources = [parse_source(s) for s in args.sources] show=args.show, verbose=args.verbose,
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))
ser = None ser = None
result = 0 result = 0
try: 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) ser = open_serial(args.port, args.baud, args.timeout, args.write_timeout)
controller.ser = ser 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() controller.serve()
except KeyboardInterrupt: except KeyboardInterrupt:
print("Interrupted.", file=sys.stderr) print("Interrupted.", file=sys.stderr)
+47 -51
View File
@@ -1,7 +1,7 @@
"""Single-threaded finger acquisition API, independent of UART and the CLI. """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 Sensor IDs are 1-based, assigned after each successful add(). Calls and runtime
to one thread; the caller must close the bank in a finally block. Values use the 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. SDK's FNORMAL/FSHEARX/FSHEARY axes and calibration units, not assumed Newtons.
""" """
@@ -40,74 +40,68 @@ class ForceReading:
class FingerSensorBank: 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. Processing/connection failures raise exceptions; they never become zero force.
A shared config can be reused, or each source can have its own config. A shared config can be reused, or each source can have its own config.
""" """
def __init__(self, sources, *, configs=("finger",), verbose=False): def __init__(self, *, config="finger", verbose=False):
sources = tuple(parse_source(str(source)) for source in sources) self.default_config = config
configs = tuple(configs) self.sources = {}
if not 1 <= len(sources) <= 4: self.configs = {}
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))
self.runtimes = {} self.runtimes = {}
self.verbose = verbose self.verbose = verbose
def connect_all(self) -> None: @staticmethod
"""Prepare every source, rolling back all runtimes on any failure.""" 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 from orisys.finger import FingerRuntime
if self.runtimes: config = self.default_config if config is None else config
raise RuntimeError("already connected; close before reconnecting") runtime = None
sensor_id = None
try: try:
for sensor_id, source in self.sources.items(): runtime = FingerRuntime.open(config=config, video=source, verbose=self.verbose)
if is_video_file(source) and not Path(source).is_file(): ok, _ = runtime.warmup(color=False)
raise ValueError(f"file not found: {source}") if not ok:
runtime = FingerRuntime.open( raise RuntimeError("warmup could not read a frame")
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
except BaseException: except BaseException:
self.close() if runtime is not None:
try:
runtime.close()
except Exception:
logger.exception("New sensor cleanup failed")
raise 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, ...]: def read_all(self, *, loop_video=False) -> tuple[ForceReading, ...]:
"""Read a complete round. EOF/failure raises; no partial round is returned. """Read a complete round. EOF/failure raises; no partial round is returned.
The frames are read sequentially, not synchronized across sources. The frames are read sequentially, not synchronized across sources.
""" """
if set(self.runtimes) != set(self.sources): if not self.runtimes:
raise RuntimeError("all configured sensors must be connected first") raise RuntimeError("no sensors added")
readings = [] readings = []
for sensor_id in self.sources: for sensor_id in self.sources:
try: try:
@@ -152,5 +146,7 @@ class FingerSensorBank:
except Exception as exc: except Exception as exc:
logger.exception("Sensor %s cleanup failed", sensor_id) logger.exception("Sensor %s cleanup failed", sensor_id)
first_error = first_error or exc first_error = first_error or exc
self.sources.clear()
self.configs.clear()
if first_error is not None: if first_error is not None:
raise first_error raise first_error
+7 -3
View File
@@ -55,9 +55,13 @@ def main():
open_runtime = partial(FingerRuntime.open, map_cache=str(scratch / "uv-map.npz")) open_runtime = partial(FingerRuntime.open, map_cache=str(scratch / "uv-map.npz"))
with patch.object(FingerRuntime, "open", side_effect=open_runtime): with patch.object(FingerRuntime, "open", side_effect=open_runtime):
for count in range(1, 5): for count in range(1, 5):
bank = FingerSensorBank(sources[:count]) bank = FingerSensorBank()
try: try:
bank.connect_all() for source in sources[:count]:
existing = dict(bank.runtimes)
bank.add(source)
assert all(bank.runtimes[i] is runtime for i, runtime in existing.items())
assert len(bank.read_all()) == len(bank.runtimes)
runtimes = list(bank.runtimes.values()) runtimes = list(bank.runtimes.values())
for left, right in itertools.combinations(runtimes, 2): for left, right in itertools.combinations(runtimes, 2):
assert left.capture is not right.capture assert left.capture is not right.capture
@@ -67,7 +71,7 @@ def main():
readings = bank.read_all() readings = bank.read_all()
assert [r.sensor_id for r in readings] == list(range(1, count + 1)) assert [r.sensor_id for r in readings] == list(range(1, count + 1))
assert all(np.isfinite([r.normal, r.shear_x, r.shear_y]).all() for r in readings) assert all(np.isfinite([r.normal, r.shear_x, r.shear_y]).all() for r in readings)
print(f"PASS {count} sources: independent captures/processors, 3 complete rounds", flush=True) print(f"PASS {count} sources: incremental add/read, independent states, 3 final rounds", flush=True)
finally: finally:
bank.close() bank.close()
assert all(runtime.capture is None for runtime in runtimes) assert all(runtime.capture is None for runtime in runtimes)
+219 -139
View File
@@ -90,98 +90,194 @@ class HardwareFreeTest(unittest.TestCase):
stderr.__enter__() stderr.__enter__()
self.addCleanup(stderr.__exit__, None, None, None) self.addCleanup(stderr.__exit__, None, None, None)
finger = types.ModuleType("orisys.finger") finger = types.ModuleType("orisys.finger")
finger.FingerRuntime = types.SimpleNamespace(open=lambda **kwargs: FakeRuntime()) finger.FingerRuntime = types.SimpleNamespace(
open=lambda **kwargs: FakeRuntime()
)
capture = types.ModuleType("orisys.capture") capture = types.ModuleType("orisys.capture")
capture.is_live_camera = lambda source: isinstance(source, int) or str(source).startswith("/dev/video") capture.is_live_camera = lambda source: (
isinstance(source, int) or str(source).startswith("/dev/video")
)
capture.list_cameras = lambda **kwargs: [] capture.list_cameras = lambda **kwargs: []
package = types.ModuleType("orisys") package = types.ModuleType("orisys")
package.finger, package.capture = finger, capture package.finger, package.capture = finger, capture
modules = patch.dict(sys.modules, { modules = patch.dict(
"orisys": package, "orisys.finger": finger, "orisys.capture": capture, sys.modules,
}) {
"orisys": package,
"orisys.finger": finger,
"orisys.capture": capture,
},
)
modules.start() modules.start()
self.addCleanup(modules.stop) self.addCleanup(modules.stop)
class ConnectionFirstTests(HardwareFreeTest): class ManualAddTests(HardwareFreeTest):
def test_connects_every_source_before_ready_or_force(self): def test_service_starts_empty_without_opening_a_camera(self):
ser = ByteSerial(b"quit\n")
controller = uart.UartFingerController(ser)
with patch("orisys.finger.FingerRuntime.open") as open_runtime:
controller.serve()
open_runtime.assert_not_called()
self.assertEqual(ser.output, ["OK ready 0", "OK quit"])
def test_add_one_through_four_then_start_produces_uniform_output(self):
for count in range(1, 5): for count in range(1, 5):
with self.subTest(count=count): with self.subTest(count=count):
ser = ByteSerial() commands = "".join(f"add {i * 2}\n" for i in range(count)) + "start\n"
controller = uart.UartFingerController(ser, sources=list(range(count))) ser = ByteSerial(commands.encode())
events = [] controller = uart.UartFingerController(ser)
def open_runtime(events=events, **kwargs):
events.append(kwargs["video"])
return FakeRuntime()
original_write = ser.write original_write = ser.write
def write(data, events=events, count=count, ser=ser, original_write=original_write):
self.assertEqual(events, list(range(count))) def write(data, ser=ser, original_write=original_write, count=count):
size = original_write(data) size = original_write(data)
if data.startswith(b"force,") and sum(x.startswith("force,") for x in ser.output) == count: if (
data.startswith(b"force,")
and sum(x.startswith("force,") for x in ser.output) == count
):
ser.input.extend(b"quit\n") ser.input.extend(b"quit\n")
return size return size
ser.write = write ser.write = write
with patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime): controller.serve()
controller.bank.connect_all() self.assertEqual(
controller.serve() ser.output[: count + 2],
self.assertEqual(ser.output[0], f"OK ready {count}") ["OK ready 0"]
self.assertEqual([x for x in ser.output if x.startswith("force,")], [ + [f"OK added {i}" for i in range(1, count + 1)]
f"force,{i},1.250000,-2.500000,3.750000" for i in range(1, count + 1) + ["OK start"],
]) )
self.assertEqual(
[x for x in ser.output if x.startswith("force,")],
[
f"force,{i},1.250000,-2.500000,3.750000"
for i in range(1, count + 1)
],
)
controller.close_runtime() controller.close_runtime()
def test_one_failed_connection_rolls_back_every_opened_camera(self): def test_add_alone_does_not_start_output(self):
bank = FingerSensorBank([0, 2, 4]) ser = ByteSerial(b"add 0\nquit\n")
opened = [] uart.UartFingerController(ser).serve()
def open_runtime(**kwargs): self.assertEqual(ser.output, ["OK ready 0", "OK added 1", "OK quit"])
runtime = FakeRuntime(warmup_error=kwargs["video"] == 2)
opened.append(runtime) def test_start_and_read_require_at_least_one_sensor(self):
return runtime ser = ByteSerial()
with (patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime), controller = uart.UartFingerController(ser)
self.assertRaisesRegex(RuntimeError, "sensor 2")): for command in ("start", "read"):
bank.connect_all() controller._dispatch(command)
self.assertTrue(ser.output[-1].startswith(f"ERR {command}"))
self.assertFalse(controller._streaming)
def test_add_requires_stop_and_never_interrupts_existing_stream(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser)
controller._dispatch("add 0")
controller._dispatch("start")
first = controller.bank.runtimes[1]
with patch("orisys.finger.FingerRuntime.open") as open_runtime:
controller._dispatch("add 2")
open_runtime.assert_not_called()
self.assertEqual(ser.output[-1], "ERR add stop streaming first")
self.assertTrue(controller._streaming)
controller._dispatch("stop")
controller._dispatch("add 2")
self.assertEqual(ser.output[-1], "OK added 2")
self.assertIs(controller.bank.runtimes[1], first)
self.assertFalse(first.closed)
self.assertFalse(controller._streaming)
def test_failed_add_preserves_other_sensor_and_reuses_available_id(self):
bank = FingerSensorBank()
self.assertEqual(bank.add(0), 1)
first = bank.runtimes[1]
failed = FakeRuntime(warmup_error=True)
with (
patch("orisys.finger.FingerRuntime.open", return_value=failed),
self.assertRaises(RuntimeError),
):
bank.add(2)
self.assertTrue(failed.closed)
self.assertFalse(first.closed)
self.assertEqual(bank.sources, {1: 0})
self.assertEqual(bank.add(4), 2)
self.assertEqual(bank.sources, {1: 0, 2: 4})
def test_failed_warmup_without_exception_closes_new_runtime(self):
bank = FingerSensorBank()
failed = FakeRuntime(warmup_ok=False)
with (
patch("orisys.finger.FingerRuntime.open", return_value=failed),
self.assertRaisesRegex(RuntimeError, "warmup"),
):
bank.add(0)
self.assertTrue(failed.closed)
self.assertEqual(bank.runtimes, {}) self.assertEqual(bank.runtimes, {})
self.assertTrue(all(r.closed for r in opened)) self.assertEqual(bank.sources, {})
def test_no_uart_is_opened_if_camera_preparation_fails(self): def test_duplicate_source_and_fifth_camera_are_rejected_before_open(self):
with (patch("orisys.finger.FingerRuntime.open", return_value=FakeRuntime(warmup_ok=False)), bank = FingerSensorBank()
patch.object(uart, "open_serial") as serial): bank.add(0)
self.assertEqual(uart.main(["--sources", "0", "2"]), 1) for source in ("0", "/dev/video0", -1):
serial.assert_not_called() with (
self.subTest(source=source),
patch("orisys.finger.FingerRuntime.open") as open_runtime,
):
with self.assertRaises(ValueError):
bank.add(source)
open_runtime.assert_not_called()
for source in (2, 4, 6):
bank.add(source)
with patch("orisys.finger.FingerRuntime.open") as open_runtime:
with self.assertRaisesRegex(ValueError, "4"):
bank.add(8)
open_runtime.assert_not_called()
def test_legacy_commands_are_rejected(self): def test_each_sensor_can_use_its_own_config(self):
bank = FingerSensorBank(config="shared.json")
seen = []
def open_runtime(**kwargs):
seen.append(kwargs["config"])
return FakeRuntime()
with patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime):
bank.add(0)
bank.add(2, config="right.json")
self.assertEqual(seen, ["shared.json", "right.json"])
self.assertIsNot(bank.runtimes[1], bank.runtimes[2])
self.assertEqual([r.sensor_id for r in bank.read_all()], [1, 2])
def test_quoted_source_and_config_are_one_argument_each(self):
ser = ByteSerial() ser = ByteSerial()
controller = uart.UartFingerController(ser, sources=[0, 2]) controller = uart.UartFingerController(ser)
for command in ["connect", "connect 2", "connect all", "run", "run all", "search", "exit"]: with patch.object(controller.bank, "add", return_value=1) as add:
self.assertTrue(controller._dispatch(command)) controller._dispatch('add "/tmp/test clip.mp4" "my config.json"')
add.assert_called_once_with("/tmp/test clip.mp4", config="my config.json")
self.assertEqual(ser.output[-1], "OK added 1")
def test_removed_commands_and_arguments_are_rejected(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser)
for command in ("connect", "run", "search", "exit"):
controller._dispatch(command)
self.assertTrue(ser.output[-1].startswith("ERR unknown")) self.assertTrue(ser.output[-1].startswith("ERR unknown"))
for command in (
def test_no_per_sensor_or_all_command_arguments(self): "add",
ser = ByteSerial() "add 0 a extra",
controller = uart.UartFingerController(ser, sources=[0, 2]) "start 1",
for command in ["start 1", "stop all", "read 2", "quit extra"]: "stop all",
self.assertTrue(controller._dispatch(command)) "read 2",
"quit extra",
'add "broken',
):
controller._dispatch(command)
self.assertTrue(ser.output[-1].startswith("ERR command")) self.assertTrue(ser.output[-1].startswith("ERR command"))
def test_start_stop_and_read_control_entire_connected_bank(self): def test_processing_failure_stops_output_without_partial_round(self):
ser = ByteSerial() ser = ByteSerial()
controller = uart.UartFingerController(ser, sources=[0, 2]) controller = uart.UartFingerController(ser)
controller.bank.connect_all() controller._dispatch("add 0")
controller._dispatch("stop") controller._dispatch("add 2")
controller._dispatch("read")
self.assertEqual(ser.output[-2:], [
"force,1,1.250000,-2.500000,3.750000",
"force,2,1.250000,-2.500000,3.750000",
])
self.assertFalse(controller._streaming)
controller._dispatch("start")
self.assertTrue(controller._streaming)
self.assertEqual(ser.output[-1], "OK start")
def test_read_all_never_publishes_partial_round_on_processing_failure(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser, sources=[0, 2])
controller.bank.connect_all()
controller.bank.runtimes[2].grab_error = True controller.bank.runtimes[2].grab_error = True
controller._streaming = True controller._streaming = True
controller._dispatch("read") controller._dispatch("read")
@@ -189,141 +285,125 @@ class ConnectionFirstTests(HardwareFreeTest):
self.assertFalse(any(x.startswith("force,") for x in ser.output)) self.assertFalse(any(x.startswith("force,") for x in ser.output))
self.assertIn("sensor 2", ser.output[-1]) self.assertIn("sensor 2", ser.output[-1])
def test_serve_rejects_incomplete_bank_before_ready(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser, sources=[0, 2])
with self.assertRaises(RuntimeError):
controller.serve()
self.assertEqual(ser.output, [])
class FramingTests(HardwareFreeTest): class FramingTests(HardwareFreeTest):
def test_partial_line_waits_for_terminator(self): def test_partial_line_waits_for_terminator(self):
ser = ByteSerial(b"st") ser = ByteSerial(b"st")
controller = uart.UartFingerController(ser, sources=[0]) controller = uart.UartFingerController(ser)
self.assertIsNone(controller._poll_command_line(blocking=False)) self.assertIsNone(controller._poll_command_line(blocking=False))
ser.input.extend(b"op\n") ser.input.extend(b"op\n")
self.assertEqual(controller._poll_command_line(blocking=False), "stop") self.assertEqual(controller._poll_command_line(blocking=False), "stop")
def test_cr_lf_crlf_commands_are_separate(self): def test_cr_lf_crlf_commands_are_separate(self):
controller = uart.UartFingerController(ByteSerial(b"stop\rstart\nread\r\n"), sources=[0]) controller = uart.UartFingerController(ByteSerial(b"stop\rstart\nread\r\n"))
self.assertEqual(controller._poll_command_line(blocking=False), "stop") self.assertEqual(controller._poll_command_line(blocking=False), "stop")
self.assertEqual(controller._poll_command_line(blocking=False), "start") self.assertEqual(controller._poll_command_line(blocking=False), "start")
self.assertEqual(controller._poll_command_line(blocking=False), "read") self.assertEqual(controller._poll_command_line(blocking=False), "read")
def test_overlong_command_is_discarded_until_terminator(self): def test_overlong_command_is_discarded_until_terminator(self):
ser = ByteSerial(b"a" * 1025) ser = ByteSerial(b"a" * 1025)
controller = uart.UartFingerController(ser, sources=[0]) controller = uart.UartFingerController(ser)
self.assertIsNone(controller._poll_command_line(blocking=False)) self.assertIsNone(controller._poll_command_line(blocking=False))
ser.input.extend(b"quit\nstop\r") ser.input.extend(b"quit\nstop\r")
self.assertEqual(controller._poll_command_line(blocking=False), "stop") self.assertEqual(controller._poll_command_line(blocking=False), "stop")
self.assertTrue(ser.output[-1].startswith("ERR command")) self.assertTrue(ser.output[-1].startswith("ERR command"))
def test_read_only_ignores_every_command_and_still_streams(self):
ser = ByteSerial(b"stop\nquit\n" + b"a" * 1100 + b"\n")
controller = uart.UartFingerController(ser, sources=[0], read_only=True)
controller.bank.connect_all()
original_write = ser.write
def write(data):
size = original_write(data)
if data.startswith(b"force,"):
raise KeyboardInterrupt()
return size
ser.write = write
with self.assertRaises(KeyboardInterrupt):
controller.serve()
self.assertEqual(ser.output, ["OK ready 1", "force,1,1.250000,-2.500000,3.750000"])
def test_short_write_is_transport_failure(self): def test_short_write_is_transport_failure(self):
ser = ByteSerial() ser = ByteSerial()
ser.write = lambda data: len(data) - 1 ser.write = lambda data: len(data) - 1
with self.assertRaises(OSError): with self.assertRaises(OSError):
uart.UartFingerController(ser, sources=[0]).reply("OK ready 1") uart.UartFingerController(ser).reply("OK ready 0")
def test_error_newlines_do_not_inject_records(self): def test_error_newlines_do_not_inject_records(self):
ser = ByteSerial() ser = ByteSerial()
uart.UartFingerController(ser, sources=[0]).reply("ERR read bad\nOK quit\r\n") uart.UartFingerController(ser).reply("ERR read bad\nOK quit\r\n")
self.assertEqual(ser.output, ["ERR read bad OK quit"]) self.assertEqual(ser.output, ["ERR read bad OK quit"])
class BankTests(HardwareFreeTest): class BankTests(HardwareFreeTest):
def test_invalid_count_config_count_and_duplicate_sources(self):
for sources, configs in [([], ["finger"]), (range(5), ["finger"]),
([0, 2], ["a", "b", "c"]), ([0, "0"], ["finger"]),
([0, "/dev/video0"], ["finger"]), ([-1], ["finger"])]:
with self.subTest(sources=sources), self.assertRaises(ValueError):
FingerSensorBank(sources, configs=configs)
def test_configs_and_runtime_states_are_independent(self):
bank = FingerSensorBank([0, 2], configs=["left.json", "right.json"])
configs = []
def open_runtime(**kwargs):
configs.append(kwargs["config"])
return FakeRuntime()
with patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime):
bank.connect_all()
self.assertEqual(configs, ["left.json", "right.json"])
self.assertIsNot(bank.runtimes[1], bank.runtimes[2])
readings = bank.read_all()
self.assertEqual([r.sensor_id for r in readings], [1, 2])
self.assertEqual(readings[0].units, ("raw", "raw", "raw"))
def test_no_frame_during_warmup_closes_runtime(self):
runtime = FakeRuntime(warmup_ok=False)
bank = FingerSensorBank([0])
with (patch("orisys.finger.FingerRuntime.open", return_value=runtime),
self.assertRaisesRegex(RuntimeError, "warmup")):
bank.connect_all()
self.assertTrue(runtime.closed)
self.assertEqual(bank.runtimes, {})
def test_nonfinite_force_is_not_a_valid_round(self): def test_nonfinite_force_is_not_a_valid_round(self):
bank = FingerSensorBank([0]) bank = FingerSensorBank()
bank.connect_all() bank.add(0)
bank.runtimes[1].tactile.read_info = lambda *args: (float("nan"), 0, 0) bank.runtimes[1].tactile.read_info = lambda *args: (float("nan"), 0, 0)
with self.assertRaisesRegex(RuntimeError, "non-finite"): with self.assertRaisesRegex(RuntimeError, "non-finite"):
bank.read_all() bank.read_all()
def test_eof_returns_no_partial_round(self): def test_eof_returns_no_partial_round(self):
bank = FingerSensorBank([0, 2]) bank = FingerSensorBank()
bank.connect_all() bank.add(0)
bank.add(2)
bank.runtimes[2].frames = 0 bank.runtimes[2].frames = 0
bank.sources[2] = "ended.mp4" bank.sources[2] = "ended.mp4"
with self.assertRaisesRegex(EOFError, "sensor 2"): with self.assertRaisesRegex(EOFError, "sensor 2"):
bank.read_all() bank.read_all()
def test_all_resources_are_attempted_even_when_close_fails(self): def test_all_resources_are_attempted_even_when_close_fails(self):
bank = FingerSensorBank([0, 2]) bank = FingerSensorBank()
bank.connect_all() bank.add(0)
bank.add(2)
second = bank.runtimes[2] second = bank.runtimes[2]
bank.runtimes[1].close = lambda: (_ for _ in ()).throw(RuntimeError("close failed")) bank.runtimes[1].close = lambda: (_ for _ in ()).throw(
RuntimeError("close failed")
)
with self.assertRaisesRegex(RuntimeError, "close failed"): with self.assertRaisesRegex(RuntimeError, "close failed"):
bank.close() bank.close()
self.assertTrue(second.closed) self.assertTrue(second.closed)
self.assertEqual(bank.runtimes, {}) self.assertEqual(bank.runtimes, {})
self.assertEqual(bank.sources, {})
class CliTests(HardwareFreeTest): class CliTests(HardwareFreeTest):
def test_sources_are_required_and_legacy_options_are_removed(self): def test_startup_opens_only_uart_and_waits_for_add(self):
for args in [[], ["--camera-id", "0"], ["--video", "a.mp4"], ser = ByteSerial(b"quit\n")
["--sources", "0", "--autostart"], ["--sources", "0", "--max-camera-index", "10"], ser.close = lambda: None
["--sources", "0", "1", "2", "3", "4"], ["--sources", "0", "--timeout", "nan"]]: with (
patch.object(uart, "open_serial", return_value=ser),
patch("orisys.finger.FingerRuntime.open") as runtime,
):
self.assertEqual(uart.main([]), 0)
runtime.assert_not_called()
self.assertEqual(ser.output, ["OK ready 0", "OK quit"])
def test_automatic_setup_options_are_removed(self):
for args in (
["--sources", "0"],
["--read-only"],
["--autostart"],
["--camera-id", "0"],
["--timeout", "nan"],
):
with self.subTest(args=args), patch.object(uart, "open_serial") as serial: with self.subTest(args=args), patch.object(uart, "open_serial") as serial:
with self.assertRaises(SystemExit) as status: with self.assertRaises(SystemExit) as status:
uart.main(args) uart.main(args)
self.assertEqual(status.exception.code, 2) self.assertEqual(status.exception.code, 2)
serial.assert_not_called() serial.assert_not_called()
def test_serial_failure_closes_every_connected_runtime(self): def test_transport_failure_closes_cameras_added_by_commands(self):
ser = ByteSerial(b"add 0\nadd 2\nstart\n")
ser.closed = False
ser.close = lambda: setattr(ser, "closed", True)
opened = [] opened = []
def open_runtime(**kwargs): def open_runtime(**kwargs):
runtime = FakeRuntime() runtime = FakeRuntime()
opened.append(runtime) opened.append(runtime)
return runtime return runtime
with (patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime),
patch.object(uart, "open_serial", side_effect=OSError("UART unavailable"))): original_write = ser.write
self.assertEqual(uart.main(["--sources", "0", "2"]), 1)
def write(data):
if data.startswith(b"force,"):
raise OSError("UART disconnected")
return original_write(data)
ser.write = write
with (
patch.object(uart, "open_serial", return_value=ser),
patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime),
):
self.assertEqual(uart.main([]), 1)
self.assertEqual(len(opened), 2) self.assertEqual(len(opened), 2)
self.assertTrue(ser.closed)
self.assertTrue(all(r.closed for r in opened)) self.assertTrue(all(r.closed for r in opened))