统一多相机连接与 UART 数据协议

启动时完整连接所有配置相机,移除旧命令与单路数据格式。通过 22 项回归测试、真实 SDK 的 1–4 路录像验证、Ruff 和文档检查。
This commit is contained in:
ykcheungab
2026-10-14 15:39:21 +08:00
parent c970272361
commit 07339c3374
5 changed files with 476 additions and 541 deletions
+102 -98
View File
@@ -1,141 +1,145 @@
# UART finger service
`serial/example_uart.py` processes 1–4 finger cameras or recorded videos and
publishes the SDK's normal and shear values over a separate data UART. Python
3.10+, the board's installed `orisys` SDK and `pyserial` are required. Run inside
the board environment prepared by `setup/sdk_setup.sh`; install `pyserial` there
with `python -m pip install pyserial` if needed.
Start the service with an explicit list of 1-4 camera sources. It opens and
warms up **every source first**, then opens the data UART and starts publishing.
If any source fails to prepare, all opened sources are closed and the process
exits with status 1. No partial camera set is published.
## Launch from the login console
## Connect the cameras and start once
The TTL login console and data UART must use **different device ports**. Start
the script in the login shell; connect the data receiver to the port selected
by `--port`. A shell/getty or another serial program must not own that data port.
The default is `/dev/ttyHS1`, 115200 baud, 8N1, with no flow control. This service
does not configure pin multiplexing or enable the board's UART hardware.
Plug the USB cameras into the Q6A. In its TTL login shell, identify their actual
capture device paths before launching; one USB camera can expose more than one
`/dev/video*` node, so do not assume every node represents a different camera.
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`.
From the board's `dragon-q6a` checkout:
Run from the board's `dragon-q6a` directory. These paths are examples, not detected
hardware identities:
```bash
# Host commands control connection and streaming (default: one camera).
python serial/example_uart.py --port /dev/ttyHS1 --config finger
# One camera.
python serial/example_uart.py --port /dev/ttyHS1 --sources /dev/video0
# Four configured sources; use 1, 2, 3 or 4 entries as required.
python serial/example_uart.py --port /dev/ttyHS1 --sources 0 2 4 6
# Two cameras.
python serial/example_uart.py --port /dev/ttyHS1 --sources /dev/video0 /dev/video2
# A passive receiver needs only to read: connection and streaming start automatically.
python serial/example_uart.py --port /dev/ttyHS1 --sources 0 2 --read-only
# Three cameras.
python serial/example_uart.py --port /dev/ttyHS1 --sources /dev/video0 /dev/video2 /dev/video4
# Start streaming immediately, while still accepting UART commands.
python serial/example_uart.py --port /dev/ttyHS1 --sources 0 2 --autostart
# Four cameras.
python serial/example_uart.py --port /dev/ttyHS1 --sources /dev/video0 /dev/video2 /dev/video4 /dev/video6
```
Use actual camera indices/device paths discovered on the board; `0 2 4 6` is
only an example. Slot IDs are fixed by `--sources` order, not camera index.
Repeat `--config` once per source for different sensor configurations, or supply
one shared configuration. Existing `--camera-id` and `--video` remain available
for a single source. `--sources` replaces those options. `--show` requires a local
GUI and is off by default. Logs go to the login console, replies to the data UART.
The first source is sensor 1, the second sensor 2, and so on. The number of listed
sources determines the sensor count. Camera indices such as `--sources 0 2` are
also accepted. Sources must be distinct. A shared `--config finger` is the
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.
In `--read-only` mode, all incoming UART commands are ignored. End the process
with Ctrl-C in the login shell. Startup still emits `OK`/`ERR` records; the receiver
should select `force,` lines. Without `--autostart` or `--read-only`, a passive
reader receives only `OK ready` until a host sends `connect` and `run`.
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.
## Command protocol
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.
Commands are UTF-8, case-insensitive, terminated by LF, CR or CRLF. Incomplete
lines remain buffered across serial timeouts. Lines exceeding 1024 bytes are
discarded through their terminator and produce `ERR command ...` in control mode.
Quote file paths containing spaces. Linux device paths are recommended on Q6A.
## One protocol for every sensor count
| Command | Behavior |
|---|---|
| `search` | List cameras, then `OK search N`; use before connecting. Rejected while streaming. |
| `connect` | Connect slot 1 using its configured source; in single-source mode, prefer the latest search result for a camera. |
| `connect SOURCE` | Legacy form: connect slot 1 to this camera index, device path or file. |
| `connect ID SOURCE` | Connect/replace one slot; stop streaming that slot. Other slots keep their connections. |
| `connect all` | Connect all configured slots; report each success/failure independently. |
| `run [ID\|all]` | Stream the selected slot, or all connected slots when omitted. Requires a connection. |
| `read [ID\|all]` | Publish one newly processed measurement per selected slot; defaults to all configured slots. Does not start streaming. |
| `stop [ID\|all]` | Stop selected streaming, or all when omitted; keep cameras connected. |
| `quit` / `exit` | Exit and release all camera and UART resources. |
`connect 2` means **camera index 2 on slot 1**, preserving the old protocol.
To connect slot 2, send `connect 2 /dev/video2` or use `connect all`.
There are no motor-control commands; these commands control acquisition only.
Single-source measurements retain the original format:
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
force,1.250000,-2.500000,3.750000
```
When configured with multiple sources, measurements include the slot ID even if
only one source connected successfully:
```text
OK connect 2 /dev/video2 (camera)
OK run 1 2
OK ready 2
force,1,1.250000,-2.500000,3.750000
force,2,0.500000,0.100000,-0.200000
ERR run 2 processing failed
OK eof 1
```
Force fields are `FNORMAL`, `FSHEARX`, `FSHEARY` in SDK axes. They may be raw
or calibrated; **do not assume Newtons**. The Python API exposes SDK units per
reading, using `unknown` if an older SDK does not expose them. CSV preserves
the original fields and carries no units, timestamps or cross-sensor synchronization.
The fields are `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 timestamp or units. Frames are processed sequentially, not synchronized
across cameras. All inputs for a round must succeed before that round is
published. A transport failure can still interrupt transmission between lines.
Each processing failure or EOF stops only the affected slot. Processing exceptions
produce `ERR`, never fabricated zero force. `--no-loop-video` stops files at EOF;
the default tries one rewind, resets the tracker and retries one frame. Failed
preparation releases the newly opened runtime; replacing a connection releases
the old one before opening the replacement. A failed replacement requires retrying
`connect`. Startup continues with any successfully connected slots and fails if
none connect. Reconnect and run explicitly after a camera failure.
Control mode accepts only four commands, each acting on the entire camera set:
| Command | Result |
|---|---|
| `stop` | Pause continuous output and reply `OK stop`; keep cameras open. |
| `start` | Resume continuous output and reply `OK start`. |
| `read` | Process and publish one complete round; preserve streaming state on success. |
| `quit` | Reply `OK quit`, close all cameras and UART, then exit. |
Commands take no arguments. They are case-insensitive UTF-8 lines terminated by
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
output for the whole set. Measurements are never replaced with zeros. Sources
remain open; `start` retries processing, or restart the service to reopen them.
For recorded files, `--no-loop-video` reports `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 always ends the process from the login console.
## Callable acquisition API
`serial/finger_api.py` owns acquisition and runtime cleanup; importing it opens
no UART or camera. Place that directory on the caller's module search path:
`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
lifecycle as the service:
```python
from finger_api import FingerSensorBank
bank = FingerSensorBank([0, 2], configs=["finger"])
try:
bank.connect(1)
bank.connect(2)
reading = bank.read(1)
if reading is not None:
print(reading.normal, reading.shear_x, reading.shear_y, reading.units)
bank.connect_all()
for reading in bank.read_all():
print(reading.sensor_id, reading.normal, reading.shear_x, reading.shear_y, reading.units)
finally:
bank.close()
```
The caller owns one thread and must close the bank. `read()` returns `None` on
capture failure/EOF and raises on processing failure. It returns an image and
three scalar forces from the same processed frame. One file or camera is read
at a time; the API introduces no workers beyond those owned by the SDK.
One thread owns the bank. `connect_all()` rolls back every opened runtime on
failure. `read_all()` returns a tuple only when every source succeeds; it raises
`EOFError` for file EOF and `RuntimeError` for capture/processing failure. Each
reading contains the unrolled image, three scalar forces and SDK units from its
own runtime. Older SDKs without a units property report `unknown`. Reconnection
requires closing the bank before calling `connect_all()` again.
## Verification and limits
## Backend evidence and remaining limits
Hardware-free regression tests cover command fragmentation, CR/LF, legacy output,
1–4 slot identities, selective control, failure isolation, read-only startup and
cleanup. They use in-memory serial and runtime fakes, not physical devices:
The SDK's `FingerRuntime.open()` constructs a separate capture object,
`FrameUnwarper`, `TactileProcessor` and `Sensor` for each source. Live sources
have separate SDK capture workers. The service holds all runtimes open together
and reads them sequentially. SDK implementation is unchanged.
Verification on 2026-10-14 used local SDK revision
`3953747f1726b197a3f327a38c65d9878e333e12`: real SDK instances processed 1, 2, 3
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
python tests/check_backend.py --recording /path/to/recording.avi --scratch .scratch/uart-connect-first/backend-run
python -m unittest discover -s tests -v
```
Q6A UART wiring, installed ARM SDK compatibility, camera bandwidth, sustained
frame rates and latency remain unverified. The shipped ARM wheel is compiled
`orisys 0.5.0`; local tests do not execute it. SDK capture and processing remain
synchronous from this service's perspective. Commands are checked before each
round; a slow grab, processing call, connection or camera scan can delay them.
Serial writes have a finite timeout (default 1 second), and transport failure
terminates the service with cleanup. At 115200 baud/8N1, the data budget is
11,520 bytes/s, shared by all measurements and replies. Measure actual frame
rates and stop latency on the board before choosing a higher baud rate.
Unit tests separately verify startup ordering, full rollback, a uniform protocol,
removed commands/options, command framing and failure behavior. These tests do
not prove Q6A USB bandwidth or the shipped compiled ARM `orisys 0.5.0` wheel.
Run the backend check in the board's installed environment to verify that SDK.
Four live cameras, serial wiring, sustained frame rates and command latency
still require hardware validation. A slow capture/processing call delays command
handling. Serial writes time out after a configurable interval (default 1 second).
At 115200 baud/8N1, all data and replies share 11,520 bytes/s.
+62 -175
View File
@@ -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. Single-sensor force
records retain the original format; multi-sensor records add a 1-based ID.
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.
Importing this module opens no devices; acquisition is owned by finger_api.
"""
@@ -12,14 +12,12 @@ import argparse
import logging
import math
import os
import shlex
import sys
from collections import deque
from pathlib import Path
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
from finger_api import FingerSensorBank, is_video_file, parse_source
from finger_api import FingerSensorBank, parse_source
logger = logging.getLogger(__name__)
@@ -28,32 +26,21 @@ class UartFingerController:
"""Single-threaded command service; the caller owns serial and final cleanup."""
def __init__(
self, ser, *, config="finger", default_source=0, sources=None,
configs=None, max_camera_index=10, loop_video=True, show=False,
verbose=False, read_only=False, autostart=False,
self, ser, *, sources, configs=("finger",), loop_video=True,
show=False, verbose=False, read_only=False,
):
self.ser = ser
self.bank = FingerSensorBank(
sources if sources is not None else (default_source,),
configs=configs if configs is not None else (config,), verbose=verbose,
)
self.max_camera_index = max_camera_index
self.bank = FingerSensorBank(sources, configs=configs, verbose=verbose)
self.loop_video = loop_video
self.show = show
self.verbose = verbose
self.read_only = read_only
self.autostart = autostart or read_only
self._streaming = set()
self._streaming = False
self._should_quit = False
self._last_search_source = None
self._partial = bytearray()
self._lines = deque()
self._discard_line = False
@property
def multi_sensor(self):
return len(self.bank.sources) > 1
def reply(self, line):
# SDK exceptions can contain newlines; every reply must be one record.
text = " ".join(str(line).splitlines()) + "\n"
@@ -64,7 +51,7 @@ class UartFingerController:
print(f"<< {text.rstrip()}", file=sys.stderr, flush=True)
def close_runtime(self):
self._streaming.clear()
self._streaming = False
try:
self.bank.close()
finally:
@@ -99,112 +86,27 @@ class UartFingerController:
self._discard_line = True
return self._lines.popleft() if self._lines else None
def _targets(self, args, *, connected=False):
if len(args) > 1:
raise ValueError("expected [sensor-id|all]")
if args and args[0].lower() != "all":
sensor_id = int(args[0])
self.bank.validate_id(sensor_id)
return [sensor_id]
return sorted(self.bank.runtimes if connected else self.bank.sources)
def _sensor_suffix(self, sensor_id):
return f" {sensor_id}" if self.multi_sensor else ""
def cmd_search(self, args):
if args:
raise ValueError("search takes no arguments")
if self._streaming:
raise ValueError("stop streaming before camera search")
def _read_round(self):
try:
from orisys.capture import list_cameras
cameras = list_cameras(max_index=self.max_camera_index)
readings = self.bank.read_all(loop_video=self.loop_video)
except EOFError as exc:
self._streaming = False
self.reply(f"OK eof {exc}")
return
except Exception as exc:
logger.exception("Camera search failed")
self.reply(f"ERR search {exc}")
logger.exception("Acquisition round failed")
self._streaming = False
self.reply(f"ERR read {exc}")
return
self._last_search_source = None
for cam in cameras:
path = (cam.path or f"/dev/video{cam.index}").strip() or f"/dev/video{cam.index}"
self.reply(f"camera,{cam.index},{path},{cam.width}x{cam.height}")
if self._last_search_source is None:
self._last_search_source = cam.index
self.reply(f"OK search {len(cameras)}")
def cmd_connect(self, args):
# Legacy `connect <source>` targets sensor 1. `connect <id> <source>`
# selects a slot explicitly; quote source paths containing spaces.
if args and args[0].lower() == "all":
if len(args) != 1:
raise ValueError("expected connect all")
for sensor_id in self.bank.sources:
self._connect_one(sensor_id)
return
sensor_id = 1
if len(args) >= 2 and args[0].isdigit():
sensor_id = int(args[0])
args = args[1:]
source = parse_source(" ".join(args)) if args else None
if (source is None and not self.multi_sensor and self._last_search_source is not None
and not is_video_file(self.bank.sources[1])):
source = self._last_search_source
self._connect_one(sensor_id, source)
def _connect_one(self, sensor_id, source=None):
self.bank.validate_id(sensor_id)
self._streaming.discard(sensor_id)
try:
source = self.bank.connect(sensor_id, source)
except Exception as exc:
logger.exception("Sensor %s connection failed", sensor_id)
self.reply(f"ERR connect{self._sensor_suffix(sensor_id)} {exc}")
return
kind = "file" if is_video_file(source) else "camera"
self.reply(f"OK connect{self._sensor_suffix(sensor_id)} {source} ({kind})")
def cmd_run(self, args):
targets = self._targets(args, connected=True)
if not targets or any(i not in self.bank.runtimes for i in targets):
raise ValueError("not connected")
if set(targets).issubset(self._streaming):
raise ValueError("already running")
self._streaming.update(targets)
self.reply("OK run" + (f" {' '.join(map(str, targets))}" if self.multi_sensor else ""))
def cmd_stop(self, args):
targets = self._targets(args)
self._streaming.difference_update(targets)
self.reply("OK stop" + (f" {' '.join(map(str, targets))}" if self.multi_sensor else ""))
def cmd_quit(self, args):
if args:
raise ValueError("quit takes no arguments")
self._streaming.clear()
self._should_quit = True
self.reply("OK quit")
def _read_one(self, sensor_id, *, context):
try:
reading = self.bank.read(sensor_id, loop_video=self.loop_video)
except Exception as exc:
logger.exception("Sensor %s acquisition failed", sensor_id)
self._streaming.discard(sensor_id)
self.reply(f"ERR {context}{self._sensor_suffix(sensor_id)} {exc}")
return
if reading is None:
self._streaming.discard(sensor_id)
if is_video_file(self.bank.sources[sensor_id]):
self.reply(f"OK eof{self._sensor_suffix(sensor_id)}")
else:
self.reply(f"ERR {context}{self._sensor_suffix(sensor_id)} grab failed")
return
sensor_prefix = f"{sensor_id}," if self.multi_sensor else ""
for reading in readings:
self.reply(
f"force,{sensor_prefix}{reading.normal:.6f},{reading.shear_x:.6f},{reading.shear_y:.6f}"
f"force,{reading.sensor_id},{reading.normal:.6f},"
f"{reading.shear_x:.6f},{reading.shear_y:.6f}"
)
if self.show:
self._show(reading)
if self._should_quit:
return
def _show(self, reading):
import cv2
@@ -219,7 +121,9 @@ class UartFingerController:
cv2.imshow(f"UV Unrolled {reading.sensor_id}", arrows)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
self.cmd_quit([])
self._streaming = False
self._should_quit = True
self.reply("OK quit")
elif key == ord("r"):
tactile.reset()
except Exception:
@@ -228,56 +132,42 @@ class UartFingerController:
def _dispatch(self, line):
if self.read_only:
return True
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
cmd, args = parts[0].lower(), parts[1:]
try:
if cmd == "search":
self.cmd_search(args)
elif cmd == "connect":
self.cmd_connect(args)
elif cmd == "run":
self.cmd_run(args)
elif cmd == "read":
for sensor_id in self._targets(args):
self._read_one(sensor_id, context="read")
elif cmd == "stop":
self.cmd_stop(args)
elif cmd in ("quit", "exit"):
self.cmd_quit(args)
else:
self.reply(f"ERR unknown {cmd}")
except (ValueError, RuntimeError) as exc:
self.reply(f"ERR {cmd} {exc}")
command = parts[0].lower()
if 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")
elif command == "stop":
self._streaming = False
self.reply("OK stop")
elif command == "read":
self._read_round()
elif command == "quit":
self._streaming = False
self._should_quit = True
self.reply("OK quit")
return not self._should_quit
def serve(self):
self.reply("OK ready")
if self.autostart:
self.cmd_connect(["all"])
if self.bank.runtimes:
self.cmd_run([])
else:
raise RuntimeError("autostart could not connect any sensor")
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
while not self._should_quit:
# Bounded command work keeps a noisy host from starving acquisition.
for _ in range(16):
line = self._poll_command_line(blocking=not self._streaming)
if line is None:
break
if not self._dispatch(line):
return
for sensor_id in sorted(self._streaming):
self._read_one(sensor_id, context="run")
if self._should_quit:
return
if self._streaming:
self._read_round()
def open_serial(port, baud, timeout, write_timeout=1.0):
try:
import serial
@@ -297,38 +187,35 @@ def main(argv=None):
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("--camera-id", type=int, default=0)
parser.add_argument("--video", help="single-sensor default video file")
parser.add_argument("--sources", nargs="+", help="1-4 camera indices, device paths or video files")
parser.add_argument("--max-camera-index", type=int, default=10)
parser.add_argument("--loop-video", dest="loop_video", action="store_true", default=True)
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("--show", action="store_true", help="local desktop debug only")
parser.add_argument("--verbose", action="store_true")
parser.add_argument("--autostart", action="store_true", help="connect and run configured sources on startup")
parser.add_argument("--read-only", action="store_true", help="autostart and ignore all UART commands")
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 or args.max_camera_index < 0:
parser.error("baud must be positive; max camera index must be non-negative")
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")
if args.sources and (args.video is not None or args.camera_id != 0):
parser.error("--sources cannot be combined with --video or --camera-id")
configs = args.config or ["finger"]
try:
sources = [parse_source(s) for s in args.sources] if args.sources else [
str(Path(args.video).resolve()) if args.video else args.camera_id,
]
sources = [parse_source(s) for s in args.sources]
controller = UartFingerController(
None, sources=sources, configs=configs, max_camera_index=args.max_camera_index,
None, sources=sources, configs=configs,
loop_video=args.loop_video, show=args.show, verbose=args.verbose,
autostart=args.autostart, read_only=args.read_only,
read_only=args.read_only,
)
except ValueError as exc:
parser.error(str(exc))
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)
+50 -30
View File
@@ -40,13 +40,13 @@ class ForceReading:
class FingerSensorBank:
"""Own 1–4 independent runtimes. read() returns None at EOF/capture failure.
"""Own 1-4 independent runtimes; connect_all() 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=(0,), *, configs=("finger",), verbose=False):
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:
@@ -57,45 +57,71 @@ class FingerSensorBank:
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.verbose = verbose
def validate_id(self, sensor_id: int) -> None:
if sensor_id not in self.sources:
raise ValueError(f"sensor ID must be 1..{len(self.sources)}")
def connect(self, sensor_id: int = 1, source: Source | None = None) -> Source:
def connect_all(self) -> None:
"""Prepare every source, rolling back all runtimes on any failure."""
from orisys.finger import FingerRuntime
self.validate_id(sensor_id)
source = self.sources[sensor_id] if source is None else parse_source(str(source))
if isinstance(source, int) and source < 0:
raise ValueError("camera index must be non-negative")
if self.runtimes:
raise RuntimeError("already connected; close before reconnecting")
sensor_id = 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}")
if source in (self.sources[i] for i in self.runtimes if i != sensor_id):
raise ValueError(f"source already connected: {source}")
self.disconnect(sensor_id)
runtime = FingerRuntime.open(
config=self.configs[sensor_id], video=source, verbose=self.verbose,
)
try:
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:
runtime.close()
self.close()
raise
self.runtimes[sensor_id] = runtime
self.sources[sensor_id] = source
return source
def read(self, sensor_id: int = 1, *, loop_video=False) -> ForceReading | None:
self.validate_id(sensor_id)
if sensor_id not in self.runtimes:
raise ValueError("not connected")
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")
readings = []
for sensor_id in self.sources:
try:
reading = self._read(sensor_id, loop_video=loop_video)
except Exception as exc:
raise RuntimeError(f"sensor {sensor_id}: {exc}") from exc
if reading is None:
if is_video_file(self.sources[sensor_id]):
raise EOFError(f"sensor {sensor_id}: video ended")
raise RuntimeError(f"sensor {sensor_id}: capture failed")
readings.append(reading)
return tuple(readings)
def _read(self, sensor_id: int, *, loop_video=False) -> ForceReading | None:
runtime = self.runtimes[sensor_id]
ok, image = runtime.grab_and_process(color=False)
if not ok and loop_video and is_video_file(self.sources[sensor_id]):
@@ -118,17 +144,11 @@ class FingerSensorBank:
units = getattr(tactile, "force_units", ("unknown", "unknown", "unknown"))
return ForceReading(sensor_id, *forces, tuple(units), image)
def disconnect(self, sensor_id: int = 1) -> None:
self.validate_id(sensor_id)
runtime = self.runtimes.pop(sensor_id, None)
if runtime is not None:
runtime.close()
def close(self) -> None:
first_error = None
for sensor_id in list(self.runtimes):
try:
self.disconnect(sensor_id)
self.runtimes.pop(sensor_id).close()
except Exception as exc:
logger.exception("Sensor %s cleanup failed", sensor_id)
first_error = first_error or exc
+78
View File
@@ -0,0 +1,78 @@
"""Exercise real SDK instances for 1-4 recorded inputs, without UART/hardware."""
import argparse
import itertools
import os
import sys
from functools import partial
from pathlib import Path
from unittest.mock import patch
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "serial"))
from finger_api import FingerSensorBank
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--recording", required=True, type=Path)
parser.add_argument("--scratch", required=True, type=Path)
args = parser.parse_args()
scratch = args.scratch.resolve()
scratch.mkdir(parents=True, exist_ok=True)
os.environ["NUMBA_CACHE_DIR"] = str(scratch / "numba")
import cv2
import numpy as np
import orisys
from orisys.finger import FingerRuntime
print(f"SDK: {orisys.__file__}", flush=True)
capture = cv2.VideoCapture(str(args.recording.resolve()))
frames = []
try:
for _ in range(12):
ok, frame = capture.read()
if not ok:
raise RuntimeError("recording must contain at least 12 frames")
frames.append(frame)
finally:
capture.release()
height, width = frames[0].shape[:2]
sources = []
for sensor_id in range(1, 5):
path = scratch / f"sensor-{sensor_id}.avi"
writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"MJPG"), 30, (width, height))
try:
if not writer.isOpened():
raise RuntimeError("cannot create test recording")
for frame in frames:
writer.write(np.roll(frame, sensor_id, axis=1))
finally:
writer.release()
sources.append(str(path))
# Keep the SDK implementation intact; redirect only its generated UV cache.
open_runtime = partial(FingerRuntime.open, map_cache=str(scratch / "uv-map.npz"))
with patch.object(FingerRuntime, "open", side_effect=open_runtime):
for count in range(1, 5):
bank = FingerSensorBank(sources[:count])
try:
bank.connect_all()
runtimes = list(bank.runtimes.values())
for left, right in itertools.combinations(runtimes, 2):
assert left.capture is not right.capture
assert left.tactile.raw_sensor is not right.tactile.raw_sensor
assert not np.shares_memory(left.tactile.flow, right.tactile.flow)
for _ in range(3):
readings = bank.read_all()
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)
print(f"PASS {count} sources: independent captures/processors, 3 complete rounds", flush=True)
finally:
bank.close()
assert all(runtime.capture is None for runtime in runtimes)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+172 -226
View File
@@ -103,176 +103,126 @@ class HardwareFreeTest(unittest.TestCase):
self.addCleanup(modules.stop)
class ExistingRegressionTests(HardwareFreeTest):
def test_partial_command_waits_for_terminator(self):
ser = ByteSerial(b"st")
controller = uart.UartFingerController(ser)
self.assertIsNone(controller._poll_command_line(blocking=False))
ser.input.extend(b"op\n")
self.assertEqual(controller._poll_command_line(blocking=False), "stop")
def test_cr_only_terminal_commands(self):
controller = uart.UartFingerController(ByteSerial(b"stop\rrun\r"))
self.assertEqual(controller._poll_command_line(blocking=False), "stop")
self.assertEqual(controller._poll_command_line(blocking=False), "run")
def test_failed_warmup_releases_runtime(self):
runtime = FakeRuntime(warmup_error=True)
ser = ByteSerial()
controller = uart.UartFingerController(ser)
with patch("orisys.finger.FingerRuntime.open", return_value=runtime):
controller.cmd_connect([])
self.assertTrue(runtime.closed)
self.assertTrue(ser.output[-1].startswith("ERR connect"))
def test_unsuccessful_warmup_is_not_connected(self):
runtime = FakeRuntime(warmup_ok=False)
ser = ByteSerial()
controller = uart.UartFingerController(ser)
with patch("orisys.finger.FingerRuntime.open", return_value=runtime):
controller.cmd_connect([])
self.assertTrue(ser.output[-1].startswith("ERR connect"))
self.assertTrue(runtime.closed)
class MultiSensorTests(HardwareFreeTest):
def test_autostart_service_streams_one_through_four_sensors_then_stops(self):
for count in range(1, 5):
with self.subTest(count=count):
ser = ByteSerial()
controller = uart.UartFingerController(ser, sources=list(range(count)), autostart=True)
original_write = ser.write
def write(data, original_write=original_write, ser=ser, count=count):
size = original_write(data)
if data.startswith(b"force,") and sum(line.startswith("force,") for line in ser.output) == count:
ser.input.extend(b"stop all\nquit\n")
return size
ser.write = write
controller.serve()
self.assertEqual(sum(line.startswith("force,") for line in ser.output), count)
self.assertEqual(ser.output[-1], "OK quit")
self.assertFalse(controller._streaming)
controller.close_runtime()
def test_one_through_four_sensors_have_identifiable_readings(self):
class ConnectionFirstTests(HardwareFreeTest):
def test_connects_every_source_before_ready_or_force(self):
for count in range(1, 5):
with self.subTest(count=count):
ser = ByteSerial()
controller = uart.UartFingerController(ser, sources=list(range(count)))
controller.cmd_connect(["all"])
controller._dispatch("read all")
forces = [line for line in ser.output if line.startswith("force,")]
expected = ["force,1.250000,-2.500000,3.750000"] if count == 1 else [
events = []
def open_runtime(events=events, **kwargs):
events.append(kwargs["video"])
return FakeRuntime()
original_write = ser.write
def write(data, events=events, count=count, ser=ser, original_write=original_write):
self.assertEqual(events, list(range(count)))
size = original_write(data)
if data.startswith(b"force,") and sum(x.startswith("force,") for x in ser.output) == count:
ser.input.extend(b"quit\n")
return size
ser.write = write
with patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime):
controller.bank.connect_all()
controller.serve()
self.assertEqual(ser.output[0], f"OK ready {count}")
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)
]
self.assertEqual(forces, expected)
self.assertFalse(controller._streaming)
])
controller.close_runtime()
def test_selective_stop_and_reconnect_leave_other_sensor_running(self):
controller = uart.UartFingerController(ByteSerial(), sources=[0, 2, 4, 6])
controller.cmd_connect(["all"])
controller._dispatch("run all")
controller._dispatch("stop 2")
self.assertEqual(controller._streaming, {1, 3, 4})
old = controller.bank.runtimes[3]
controller._dispatch("connect 3 8")
self.assertTrue(old.closed)
self.assertEqual(controller._streaming, {1, 4})
self.assertEqual(controller.bank.sources[3], 8)
def test_one_failed_connection_rolls_back_every_opened_camera(self):
bank = FingerSensorBank([0, 2, 4])
opened = []
def open_runtime(**kwargs):
runtime = FakeRuntime(warmup_error=kwargs["video"] == 2)
opened.append(runtime)
return runtime
with (patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime),
self.assertRaisesRegex(RuntimeError, "sensor 2")):
bank.connect_all()
self.assertEqual(bank.runtimes, {})
self.assertTrue(all(r.closed for r in opened))
def test_failed_sensor_does_not_stop_healthy_sensor(self):
def test_no_uart_is_opened_if_camera_preparation_fails(self):
with (patch("orisys.finger.FingerRuntime.open", return_value=FakeRuntime(warmup_ok=False)),
patch.object(uart, "open_serial") as serial):
self.assertEqual(uart.main(["--sources", "0", "2"]), 1)
serial.assert_not_called()
def test_legacy_commands_are_rejected(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser, sources=[0, 2])
controller.cmd_connect(["all"])
controller.bank.runtimes[1].grab_error = True
controller.cmd_run([])
for sensor_id in sorted(controller._streaming):
controller._read_one(sensor_id, context="run")
self.assertIn("ERR run 1 processing failed", ser.output)
self.assertIn("force,2,1.250000,-2.500000,3.750000", ser.output)
self.assertEqual(controller._streaming, {2})
def test_connect_all_retains_successes_when_one_source_fails(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser, sources=[0, 2, 4])
def open_runtime(**kwargs):
if kwargs["video"] == 2:
raise RuntimeError("camera unavailable")
return FakeRuntime()
with patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime):
controller.cmd_connect(["all"])
self.assertEqual(set(controller.bank.runtimes), {1, 3})
self.assertIn("ERR connect 2 camera unavailable", ser.output)
def test_rejects_duplicate_live_source_without_closing_other_camera(self):
controller = uart.UartFingerController(ByteSerial(), sources=[0, 2])
controller.cmd_connect(["all"])
original = controller.bank.runtimes[2]
controller._dispatch("connect 2 0")
self.assertIs(controller.bank.runtimes[2], original)
self.assertFalse(original.closed)
def test_config_is_selected_per_sensor(self):
bank = FingerSensorBank([0, 2], configs=["left.json", "right.json"])
seen = []
def open_runtime(**kwargs):
seen.append(kwargs["config"])
return FakeRuntime()
with patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime):
bank.connect(1)
bank.connect(2)
self.assertEqual(seen, ["left.json", "right.json"])
self.assertEqual(bank.read(1).units, ("raw", "raw", "raw"))
def test_invalid_sensor_count_and_config_count(self):
for sources, configs in [([], ["finger"]), (range(5), ["finger"]), ([0, 2], ["a", "b", "c"])]:
with self.assertRaises(ValueError):
FingerSensorBank(sources, configs=configs)
def test_invalid_command_id_and_unconnected_run_are_errors(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser, sources=[0, 2])
for command in ["run 0", "read 3", "stop -1", "connect 3 4", "run all", "run 2 extra"]:
for command in ["connect", "connect 2", "connect all", "run", "run all", "search", "exit"]:
self.assertTrue(controller._dispatch(command))
self.assertTrue(ser.output[-1].startswith("ERR "))
self.assertTrue(ser.output[-1].startswith("ERR unknown"))
class ProtocolTests(HardwareFreeTest):
def test_repeated_run_is_rejected_without_recursion(self):
def test_no_per_sensor_or_all_command_arguments(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser)
controller.cmd_connect([])
controller._dispatch("run")
controller._dispatch("run")
self.assertEqual(ser.output[-1], "ERR run already running")
self.assertEqual(controller._streaming, {1})
controller = uart.UartFingerController(ser, sources=[0, 2])
for command in ["start 1", "stop all", "read 2", "quit extra"]:
self.assertTrue(controller._dispatch(command))
self.assertTrue(ser.output[-1].startswith("ERR command"))
def test_camera_search_failure_is_reported_without_exiting(self):
def test_start_stop_and_read_control_entire_connected_bank(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser)
with patch("orisys.capture.list_cameras", side_effect=OSError("camera scan failed")):
self.assertTrue(controller._dispatch("search"))
self.assertEqual(ser.output[-1], "ERR search camera scan failed")
def test_legacy_connect_and_batched_stop_quit(self):
ser = ByteSerial(b"connect 2\r\nrun\nstop\nquit\n")
controller = uart.UartFingerController(ser)
controller.serve()
self.assertEqual(ser.output, ["OK ready", "OK connect 2 (camera)", "OK run", "OK stop", "OK quit"])
controller = uart.UartFingerController(ser, sources=[0, 2])
controller.bank.connect_all()
controller._dispatch("stop")
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_stop_is_processed_before_next_frame(self):
ser = ByteSerial(b"stop\nquit\n")
controller = uart.UartFingerController(ser)
controller.cmd_connect([])
controller.cmd_run([])
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._streaming = True
controller._dispatch("read")
self.assertFalse(controller._streaming)
self.assertFalse(any(x.startswith("force,") for x in ser.output))
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.assertFalse(any(line.startswith("force,") for line in ser.output))
self.assertEqual(ser.output, [])
def test_read_only_autostarts_and_ignores_host_commands(self):
ser = ByteSerial(b"quit\nstop\nconnect 9\n" + b"a" * 1100 + b"\n")
controller = uart.UartFingerController(ser, read_only=True)
class FramingTests(HardwareFreeTest):
def test_partial_line_waits_for_terminator(self):
ser = ByteSerial(b"st")
controller = uart.UartFingerController(ser, sources=[0])
self.assertIsNone(controller._poll_command_line(blocking=False))
ser.input.extend(b"op\n")
self.assertEqual(controller._poll_command_line(blocking=False), "stop")
def test_cr_lf_crlf_commands_are_separate(self):
controller = uart.UartFingerController(ByteSerial(b"stop\rstart\nread\r\n"), sources=[0])
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), "read")
def test_overlong_command_is_discarded_until_terminator(self):
ser = ByteSerial(b"a" * 1025)
controller = uart.UartFingerController(ser, sources=[0])
self.assertIsNone(controller._poll_command_line(blocking=False))
ser.input.extend(b"quit\nstop\r")
self.assertEqual(controller._poll_command_line(blocking=False), "stop")
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)
@@ -282,62 +232,69 @@ class ProtocolTests(HardwareFreeTest):
ser.write = write
with self.assertRaises(KeyboardInterrupt):
controller.serve()
self.assertIn("OK run", ser.output)
self.assertIn("force,1.250000,-2.500000,3.750000", ser.output)
self.assertNotIn("OK quit", ser.output)
self.assertFalse(any(line.startswith("ERR ") for line in ser.output))
self.assertEqual(ser.output, ["OK ready 1", "force,1,1.250000,-2.500000,3.750000"])
def test_long_line_is_discarded_until_delimiter(self):
ser = ByteSerial(b"a" * 1025)
controller = uart.UartFingerController(ser)
self.assertIsNone(controller._poll_command_line(blocking=False))
ser.input.extend(b"quit\nstop\r")
self.assertEqual(controller._poll_command_line(blocking=False), "stop")
self.assertEqual(ser.output, ["ERR command line too long (max 1024 bytes)"])
self.assertFalse(controller._should_quit)
def test_quoted_paths_and_malformed_quotes(self):
controller = uart.UartFingerController(ByteSerial(), sources=[0, 2])
with patch.object(controller.bank, "connect", return_value="/tmp/a b.mp4") as connect:
controller._dispatch('connect 2 "/tmp/a b.mp4"')
connect.assert_called_once_with(2, "/tmp/a b.mp4")
controller._dispatch('connect "broken')
self.assertTrue(controller.ser.output[-1].startswith("ERR command"))
def test_non_finite_measurements_are_not_published(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser)
controller.cmd_connect([])
controller.bank.runtimes[1].tactile.read_info = lambda *args: (float("nan"), 0, 0)
controller._dispatch("read")
self.assertEqual(ser.output[-1], "ERR read non-finite force measurement")
self.assertFalse(any(line.startswith("force,") for line in ser.output))
def test_serial_short_write_is_a_transport_failure(self):
def test_short_write_is_transport_failure(self):
ser = ByteSerial()
ser.write = lambda data: len(data) - 1
with self.assertRaises(OSError):
uart.UartFingerController(ser).reply("OK ready")
uart.UartFingerController(ser, sources=[0]).reply("OK ready 1")
def test_error_newlines_cannot_inject_protocol_records(self):
def test_error_newlines_do_not_inject_records(self):
ser = ByteSerial()
uart.UartFingerController(ser).reply("ERR read bad\nOK quit\r\n")
uart.UartFingerController(ser, sources=[0]).reply("ERR read bad\nOK quit\r\n")
self.assertEqual(ser.output, ["ERR read bad OK quit"])
def test_empty_looping_video_retries_once_then_reports_eof(self):
ser = ByteSerial()
controller = uart.UartFingerController(ser)
runtime = FakeRuntime()
runtime.frames = 0
controller.bank.sources[1] = "empty.mp4"
controller.bank.runtimes[1] = runtime
controller._dispatch("read")
self.assertEqual(ser.output, ["OK eof"])
def test_close_attempts_every_sensor_even_when_one_close_fails(self):
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):
bank = FingerSensorBank([0])
bank.connect_all()
bank.runtimes[1].tactile.read_info = lambda *args: (float("nan"), 0, 0)
with self.assertRaisesRegex(RuntimeError, "non-finite"):
bank.read_all()
def test_eof_returns_no_partial_round(self):
bank = FingerSensorBank([0, 2])
bank.connect(1)
bank.connect(2)
bank.connect_all()
bank.runtimes[2].frames = 0
bank.sources[2] = "ended.mp4"
with self.assertRaisesRegex(EOFError, "sensor 2"):
bank.read_all()
def test_all_resources_are_attempted_even_when_close_fails(self):
bank = FingerSensorBank([0, 2])
bank.connect_all()
second = bank.runtimes[2]
bank.runtimes[1].close = lambda: (_ for _ in ()).throw(RuntimeError("close failed"))
with self.assertRaisesRegex(RuntimeError, "close failed"):
@@ -347,38 +304,27 @@ class ProtocolTests(HardwareFreeTest):
class CliTests(HardwareFreeTest):
def test_open_serial_has_finite_write_timeout_and_no_flow_control(self):
serial = types.ModuleType("serial")
serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE = 8, "N", 1
serial.Serial = types.SimpleNamespace()
with patch.dict(sys.modules, {"serial": serial}), patch.object(serial, "Serial") as factory:
uart.open_serial("/dev/ttyHS1", 115200, 0.1, 0.5)
factory.assert_called_once_with(
port="/dev/ttyHS1", baudrate=115200, bytesize=8, parity="N",
stopbits=1, timeout=0.1, write_timeout=0.5, exclusive=True,
)
def test_invalid_options_do_not_open_uart(self):
for args in [["--timeout", "0"], ["--write-timeout", "nan"], ["--sources", "0", "1", "2", "3", "4"],
["--sources", "0", "2", "--config", "a", "--config", "b", "--config", "c"]]:
with self.subTest(args=args), patch.object(uart, "open_serial") as open_serial:
with self.assertRaises(SystemExit) as exit_status:
def test_sources_are_required_and_legacy_options_are_removed(self):
for args in [[], ["--camera-id", "0"], ["--video", "a.mp4"],
["--sources", "0", "--autostart"], ["--sources", "0", "--max-camera-index", "10"],
["--sources", "0", "1", "2", "3", "4"], ["--sources", "0", "--timeout", "nan"]]:
with self.subTest(args=args), patch.object(uart, "open_serial") as serial:
with self.assertRaises(SystemExit) as status:
uart.main(args)
self.assertEqual(exit_status.exception.code, 2)
open_serial.assert_not_called()
self.assertEqual(status.exception.code, 2)
serial.assert_not_called()
def test_transport_failure_returns_nonzero_and_closes_all_resources(self):
ser = ByteSerial()
ser.closed = False
ser.close = lambda: setattr(ser, "closed", True)
def serve(controller):
controller.cmd_connect(["all"])
self.runtimes = list(controller.bank.runtimes.values())
raise OSError("UART disconnected")
with patch.object(uart, "open_serial", return_value=ser), patch.object(uart.UartFingerController, "serve", serve):
def test_serial_failure_closes_every_connected_runtime(self):
opened = []
def open_runtime(**kwargs):
runtime = FakeRuntime()
opened.append(runtime)
return runtime
with (patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime),
patch.object(uart, "open_serial", side_effect=OSError("UART unavailable"))):
self.assertEqual(uart.main(["--sources", "0", "2"]), 1)
self.assertTrue(ser.closed)
self.assertTrue(all(runtime.closed for runtime in self.runtimes))
self.assertEqual(len(opened), 2)
self.assertTrue(all(r.closed for r in opened))
if __name__ == "__main__":