统一多相机连接与 UART 数据协议
启动时完整连接所有配置相机,移除旧命令与单路数据格式。通过 22 项回归测试、真实 SDK 的 1–4 路录像验证、Ruff 和文档检查。
This commit is contained in:
+102
-98
@@ -1,141 +1,145 @@
|
|||||||
# UART finger service
|
# UART finger service
|
||||||
|
|
||||||
`serial/example_uart.py` processes 1–4 finger cameras or recorded videos and
|
Start the service with an explicit list of 1-4 camera sources. It opens and
|
||||||
publishes the SDK's normal and shear values over a separate data UART. Python
|
warms up **every source first**, then opens the data UART and starts publishing.
|
||||||
3.10+, the board's installed `orisys` SDK and `pyserial` are required. Run inside
|
If any source fails to prepare, all opened sources are closed and the process
|
||||||
the board environment prepared by `setup/sdk_setup.sh`; install `pyserial` there
|
exits with status 1. No partial camera set is published.
|
||||||
with `python -m pip install pyserial` if needed.
|
|
||||||
|
|
||||||
## Launch from the login console
|
## Connect the cameras and start once
|
||||||
|
|
||||||
The TTL login console and data UART must use **different device ports**. Start
|
Plug the USB cameras into the Q6A. In its TTL login shell, identify their actual
|
||||||
the script in the login shell; connect the data receiver to the port selected
|
capture device paths before launching; one USB camera can expose more than one
|
||||||
by `--port`. A shell/getty or another serial program must not own that data port.
|
`/dev/video*` node, so do not assume every node represents a different camera.
|
||||||
The default is `/dev/ttyHS1`, 115200 baud, 8N1, with no flow control. This service
|
For example, `v4l2-ctl --list-devices` shows device groups. Select one usable
|
||||||
does not configure pin multiplexing or enable the board's UART hardware.
|
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
|
```bash
|
||||||
# Host commands control connection and streaming (default: one camera).
|
# One camera.
|
||||||
python serial/example_uart.py --port /dev/ttyHS1 --config finger
|
python serial/example_uart.py --port /dev/ttyHS1 --sources /dev/video0
|
||||||
|
|
||||||
# Four configured sources; use 1, 2, 3 or 4 entries as required.
|
# Two cameras.
|
||||||
python serial/example_uart.py --port /dev/ttyHS1 --sources 0 2 4 6
|
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.
|
# Three cameras.
|
||||||
python serial/example_uart.py --port /dev/ttyHS1 --sources 0 2 --read-only
|
python serial/example_uart.py --port /dev/ttyHS1 --sources /dev/video0 /dev/video2 /dev/video4
|
||||||
|
|
||||||
# Start streaming immediately, while still accepting UART commands.
|
# Four cameras.
|
||||||
python serial/example_uart.py --port /dev/ttyHS1 --sources 0 2 --autostart
|
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
|
The first source is sensor 1, the second sensor 2, and so on. The number of listed
|
||||||
only an example. Slot IDs are fixed by `--sources` order, not camera index.
|
sources determines the sensor count. Camera indices such as `--sources 0 2` are
|
||||||
Repeat `--config` once per source for different sensor configurations, or supply
|
also accepted. Sources must be distinct. A shared `--config finger` is the
|
||||||
one shared configuration. Existing `--camera-id` and `--video` remain available
|
default; repeat `--config` once per source to use different configurations.
|
||||||
for a single source. `--sources` replaces those options. `--show` requires a local
|
The login console prints each sensor's source/config mapping during preparation.
|
||||||
GUI and is off by default. Logs go to the login console, replies to the data UART.
|
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
|
The TTL login console and data UART must use **different ports**. The data
|
||||||
with Ctrl-C in the login shell. Startup still emits `OK`/`ERR` records; the receiver
|
UART defaults to `/dev/ttyHS1`, 115200 baud, 8N1 without flow control. It must
|
||||||
should select `force,` lines. Without `--autostart` or `--read-only`, a passive
|
not be occupied by getty or another serial program. This script does not enable
|
||||||
reader receives only `OK ready` until a host sends `connect` and `run`.
|
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
|
## One protocol for every sensor count
|
||||||
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.
|
|
||||||
|
|
||||||
| Command | Behavior |
|
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
|
||||||
| `search` | List cameras, then `OK search N`; use before connecting. Rejected while streaming. |
|
five CSV fields, including a sensor ID even when only one camera is configured:
|
||||||
| `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:
|
|
||||||
|
|
||||||
```text
|
```text
|
||||||
force,1.250000,-2.500000,3.750000
|
OK ready 2
|
||||||
```
|
|
||||||
|
|
||||||
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
|
|
||||||
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
|
||||||
ERR run 2 processing failed
|
|
||||||
OK eof 1
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Force fields are `FNORMAL`, `FSHEARX`, `FSHEARY` in SDK axes. They may be raw
|
The fields are `force,sensor_id,FNORMAL,FSHEARX,FSHEARY`. Axes follow the SDK;
|
||||||
or calibrated; **do not assume Newtons**. The Python API exposes SDK units per
|
units depend on calibration and must not be assumed to be Newtons. CSV carries
|
||||||
reading, using `unknown` if an older SDK does not expose them. CSV preserves
|
no timestamp or units. Frames are processed sequentially, not synchronized
|
||||||
the original fields and carries no units, timestamps or cross-sensor synchronization.
|
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
|
Control mode accepts only four commands, each acting on the entire camera set:
|
||||||
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
|
| Command | Result |
|
||||||
preparation releases the newly opened runtime; replacing a connection releases
|
|---|---|
|
||||||
the old one before opening the replacement. A failed replacement requires retrying
|
| `stop` | Pause continuous output and reply `OK stop`; keep cameras open. |
|
||||||
`connect`. Startup continues with any successfully connected slots and fails if
|
| `start` | Resume continuous output and reply `OK start`. |
|
||||||
none connect. Reconnect and run explicitly after a camera failure.
|
| `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
|
## Callable acquisition API
|
||||||
|
|
||||||
`serial/finger_api.py` owns acquisition and runtime cleanup; importing it opens
|
`serial/finger_api.py` has no UART dependency and opens no resources on import.
|
||||||
no UART or camera. Place that directory on the caller's module search path:
|
Put its directory on the caller's module search path. Use the same connection-first
|
||||||
|
lifecycle as the service:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from finger_api import FingerSensorBank
|
from finger_api import FingerSensorBank
|
||||||
|
|
||||||
bank = FingerSensorBank([0, 2], configs=["finger"])
|
bank = FingerSensorBank([0, 2], configs=["finger"])
|
||||||
try:
|
try:
|
||||||
bank.connect(1)
|
bank.connect_all()
|
||||||
bank.connect(2)
|
for reading in bank.read_all():
|
||||||
reading = bank.read(1)
|
print(reading.sensor_id, reading.normal, reading.shear_x, reading.shear_y, reading.units)
|
||||||
if reading is not None:
|
|
||||||
print(reading.normal, reading.shear_x, reading.shear_y, reading.units)
|
|
||||||
finally:
|
finally:
|
||||||
bank.close()
|
bank.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
The caller owns one thread and must close the bank. `read()` returns `None` on
|
One thread owns the bank. `connect_all()` rolls back every opened runtime on
|
||||||
capture failure/EOF and raises on processing failure. It returns an image and
|
failure. `read_all()` returns a tuple only when every source succeeds; it raises
|
||||||
three scalar forces from the same processed frame. One file or camera is read
|
`EOFError` for file EOF and `RuntimeError` for capture/processing failure. Each
|
||||||
at a time; the API introduces no workers beyond those owned by the SDK.
|
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,
|
The SDK's `FingerRuntime.open()` constructs a separate capture object,
|
||||||
1–4 slot identities, selective control, failure isolation, read-only startup and
|
`FrameUnwarper`, `TactileProcessor` and `Sensor` for each source. Live sources
|
||||||
cleanup. They use in-memory serial and runtime fakes, not physical devices:
|
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
|
```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
|
python -m unittest discover -s tests -v
|
||||||
```
|
```
|
||||||
|
|
||||||
Q6A UART wiring, installed ARM SDK compatibility, camera bandwidth, sustained
|
Unit tests separately verify startup ordering, full rollback, a uniform protocol,
|
||||||
frame rates and latency remain unverified. The shipped ARM wheel is compiled
|
removed commands/options, command framing and failure behavior. These tests do
|
||||||
`orisys 0.5.0`; local tests do not execute it. SDK capture and processing remain
|
not prove Q6A USB bandwidth or the shipped compiled ARM `orisys 0.5.0` wheel.
|
||||||
synchronous from this service's perspective. Commands are checked before each
|
Run the backend check in the board's installed environment to verify that SDK.
|
||||||
round; a slow grab, processing call, connection or camera scan can delay them.
|
Four live cameras, serial wiring, sustained frame rates and command latency
|
||||||
Serial writes have a finite timeout (default 1 second), and transport failure
|
still require hardware validation. A slow capture/processing call delays command
|
||||||
terminates the service with cleanup. At 115200 baud/8N1, the data budget is
|
handling. Serial writes time out after a configurable interval (default 1 second).
|
||||||
11,520 bytes/s, shared by all measurements and replies. Measure actual frame
|
At 115200 baud/8N1, all data and replies share 11,520 bytes/s.
|
||||||
rates and stop latency on the board before choosing a higher baud rate.
|
|
||||||
|
|||||||
+66
-179
@@ -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. Single-sensor force
|
The login console and data UART must be separate ports. Every configured camera
|
||||||
records retain the original format; multi-sensor records add a 1-based ID.
|
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.
|
Importing this module opens no devices; acquisition is owned by finger_api.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
@@ -12,14 +12,12 @@ 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
|
||||||
from pathlib import Path
|
|
||||||
|
|
||||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
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__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -28,32 +26,21 @@ 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, *, config="finger", default_source=0, sources=None,
|
self, ser, *, sources, configs=("finger",), loop_video=True,
|
||||||
configs=None, max_camera_index=10, loop_video=True, show=False,
|
show=False, verbose=False, read_only=False,
|
||||||
verbose=False, read_only=False, autostart=False,
|
|
||||||
):
|
):
|
||||||
self.ser = ser
|
self.ser = ser
|
||||||
self.bank = FingerSensorBank(
|
self.bank = FingerSensorBank(sources, configs=configs, verbose=verbose)
|
||||||
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.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.read_only = read_only
|
||||||
self.autostart = autostart or read_only
|
self._streaming = False
|
||||||
self._streaming = set()
|
|
||||||
self._should_quit = False
|
self._should_quit = False
|
||||||
self._last_search_source = None
|
|
||||||
self._partial = bytearray()
|
self._partial = bytearray()
|
||||||
self._lines = deque()
|
self._lines = deque()
|
||||||
self._discard_line = False
|
self._discard_line = False
|
||||||
|
|
||||||
@property
|
|
||||||
def multi_sensor(self):
|
|
||||||
return len(self.bank.sources) > 1
|
|
||||||
|
|
||||||
def reply(self, line):
|
def reply(self, line):
|
||||||
# SDK exceptions can contain newlines; every reply must be one record.
|
# SDK exceptions can contain newlines; every reply must be one record.
|
||||||
text = " ".join(str(line).splitlines()) + "\n"
|
text = " ".join(str(line).splitlines()) + "\n"
|
||||||
@@ -64,7 +51,7 @@ class UartFingerController:
|
|||||||
print(f"<< {text.rstrip()}", file=sys.stderr, flush=True)
|
print(f"<< {text.rstrip()}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
def close_runtime(self):
|
def close_runtime(self):
|
||||||
self._streaming.clear()
|
self._streaming = False
|
||||||
try:
|
try:
|
||||||
self.bank.close()
|
self.bank.close()
|
||||||
finally:
|
finally:
|
||||||
@@ -99,112 +86,27 @@ class UartFingerController:
|
|||||||
self._discard_line = True
|
self._discard_line = True
|
||||||
return self._lines.popleft() if self._lines else None
|
return self._lines.popleft() if self._lines else None
|
||||||
|
|
||||||
def _targets(self, args, *, connected=False):
|
def _read_round(self):
|
||||||
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")
|
|
||||||
try:
|
try:
|
||||||
from orisys.capture import list_cameras
|
readings = self.bank.read_all(loop_video=self.loop_video)
|
||||||
|
except EOFError as exc:
|
||||||
cameras = list_cameras(max_index=self.max_camera_index)
|
self._streaming = False
|
||||||
|
self.reply(f"OK eof {exc}")
|
||||||
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Camera search failed")
|
logger.exception("Acquisition round failed")
|
||||||
self.reply(f"ERR search {exc}")
|
self._streaming = False
|
||||||
|
self.reply(f"ERR read {exc}")
|
||||||
return
|
return
|
||||||
self._last_search_source = None
|
for reading in readings:
|
||||||
for cam in cameras:
|
self.reply(
|
||||||
path = (cam.path or f"/dev/video{cam.index}").strip() or f"/dev/video{cam.index}"
|
f"force,{reading.sensor_id},{reading.normal:.6f},"
|
||||||
self.reply(f"camera,{cam.index},{path},{cam.width}x{cam.height}")
|
f"{reading.shear_x:.6f},{reading.shear_y:.6f}"
|
||||||
if self._last_search_source is None:
|
)
|
||||||
self._last_search_source = cam.index
|
if self.show:
|
||||||
self.reply(f"OK search {len(cameras)}")
|
self._show(reading)
|
||||||
|
if self._should_quit:
|
||||||
def cmd_connect(self, args):
|
return
|
||||||
# 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 ""
|
|
||||||
self.reply(
|
|
||||||
f"force,{sensor_prefix}{reading.normal:.6f},{reading.shear_x:.6f},{reading.shear_y:.6f}"
|
|
||||||
)
|
|
||||||
if self.show:
|
|
||||||
self._show(reading)
|
|
||||||
|
|
||||||
def _show(self, reading):
|
def _show(self, reading):
|
||||||
import cv2
|
import cv2
|
||||||
@@ -219,7 +121,9 @@ class UartFingerController:
|
|||||||
cv2.imshow(f"UV Unrolled {reading.sensor_id}", arrows)
|
cv2.imshow(f"UV Unrolled {reading.sensor_id}", arrows)
|
||||||
key = cv2.waitKey(1) & 0xFF
|
key = cv2.waitKey(1) & 0xFF
|
||||||
if key == ord("q"):
|
if key == ord("q"):
|
||||||
self.cmd_quit([])
|
self._streaming = False
|
||||||
|
self._should_quit = True
|
||||||
|
self.reply("OK quit")
|
||||||
elif key == ord("r"):
|
elif key == ord("r"):
|
||||||
tactile.reset()
|
tactile.reset()
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -228,56 +132,42 @@ class UartFingerController:
|
|||||||
def _dispatch(self, line):
|
def _dispatch(self, line):
|
||||||
if self.read_only:
|
if self.read_only:
|
||||||
return True
|
return True
|
||||||
try:
|
parts = line.split()
|
||||||
parts = shlex.split(line)
|
|
||||||
except ValueError as exc:
|
|
||||||
self.reply(f"ERR command {exc}")
|
|
||||||
return True
|
|
||||||
if not parts:
|
if not parts:
|
||||||
return True
|
return True
|
||||||
cmd, args = parts[0].lower(), parts[1:]
|
command = parts[0].lower()
|
||||||
try:
|
if command not in {"start", "stop", "read", "quit"}:
|
||||||
if cmd == "search":
|
self.reply(f"ERR unknown {command}")
|
||||||
self.cmd_search(args)
|
elif len(parts) != 1:
|
||||||
elif cmd == "connect":
|
self.reply("ERR command start|stop|read|quit take no arguments")
|
||||||
self.cmd_connect(args)
|
elif command == "start":
|
||||||
elif cmd == "run":
|
self._streaming = True
|
||||||
self.cmd_run(args)
|
self.reply("OK start")
|
||||||
elif cmd == "read":
|
elif command == "stop":
|
||||||
for sensor_id in self._targets(args):
|
self._streaming = False
|
||||||
self._read_one(sensor_id, context="read")
|
self.reply("OK stop")
|
||||||
elif cmd == "stop":
|
elif command == "read":
|
||||||
self.cmd_stop(args)
|
self._read_round()
|
||||||
elif cmd in ("quit", "exit"):
|
elif command == "quit":
|
||||||
self.cmd_quit(args)
|
self._streaming = False
|
||||||
else:
|
self._should_quit = True
|
||||||
self.reply(f"ERR unknown {cmd}")
|
self.reply("OK quit")
|
||||||
except (ValueError, RuntimeError) as exc:
|
|
||||||
self.reply(f"ERR {cmd} {exc}")
|
|
||||||
return not self._should_quit
|
return not self._should_quit
|
||||||
|
|
||||||
def serve(self):
|
def serve(self):
|
||||||
self.reply("OK ready")
|
if set(self.bank.runtimes) != set(self.bank.sources):
|
||||||
if self.autostart:
|
raise RuntimeError("connect all configured cameras before serving UART")
|
||||||
self.cmd_connect(["all"])
|
self.reply(f"OK ready {len(self.bank.sources)}")
|
||||||
if self.bank.runtimes:
|
self._streaming = True
|
||||||
self.cmd_run([])
|
|
||||||
else:
|
|
||||||
raise RuntimeError("autostart could not connect any sensor")
|
|
||||||
while not self._should_quit:
|
while not self._should_quit:
|
||||||
# Bounded command work keeps a noisy host from starving acquisition.
|
|
||||||
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)
|
||||||
if line is None:
|
if line is None:
|
||||||
break
|
break
|
||||||
if not self._dispatch(line):
|
if not self._dispatch(line):
|
||||||
return
|
return
|
||||||
for sensor_id in sorted(self._streaming):
|
if self._streaming:
|
||||||
self._read_one(sensor_id, context="run")
|
self._read_round()
|
||||||
if self._should_quit:
|
|
||||||
return
|
|
||||||
|
|
||||||
|
|
||||||
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
|
||||||
@@ -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("--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", action="append", help="one shared config or repeat per sensor")
|
||||||
parser.add_argument("--camera-id", type=int, default=0)
|
parser.add_argument("--sources", required=True, nargs="+", help="1-4 camera indices, device paths or video files")
|
||||||
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("--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("--autostart", action="store_true", help="connect and run configured sources on startup")
|
parser.add_argument("--read-only", action="store_true", help="ignore all UART commands; exit from the login console")
|
||||||
parser.add_argument("--read-only", action="store_true", help="autostart and ignore all UART commands")
|
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if args.baud <= 0 or args.max_camera_index < 0:
|
if args.baud <= 0:
|
||||||
parser.error("baud must be positive; max camera index must be non-negative")
|
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")
|
||||||
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"]
|
configs = args.config or ["finger"]
|
||||||
try:
|
try:
|
||||||
sources = [parse_source(s) for s in args.sources] if args.sources else [
|
sources = [parse_source(s) for s in args.sources]
|
||||||
str(Path(args.video).resolve()) if args.video else args.camera_id,
|
|
||||||
]
|
|
||||||
controller = UartFingerController(
|
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,
|
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:
|
except ValueError as exc:
|
||||||
parser.error(str(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}; sources={sources}", file=sys.stderr, flush=True)
|
||||||
|
|||||||
+57
-37
@@ -40,13 +40,13 @@ class ForceReading:
|
|||||||
|
|
||||||
|
|
||||||
class FingerSensorBank:
|
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.
|
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=(0,), *, configs=("finger",), verbose=False):
|
def __init__(self, sources, *, configs=("finger",), verbose=False):
|
||||||
sources = tuple(parse_source(str(source)) for source in sources)
|
sources = tuple(parse_source(str(source)) for source in sources)
|
||||||
configs = tuple(configs)
|
configs = tuple(configs)
|
||||||
if not 1 <= len(sources) <= 4:
|
if not 1 <= len(sources) <= 4:
|
||||||
@@ -57,45 +57,71 @@ class FingerSensorBank:
|
|||||||
raise ValueError("camera index must be non-negative")
|
raise ValueError("camera index must be non-negative")
|
||||||
if len(configs) == 1:
|
if len(configs) == 1:
|
||||||
configs = configs * len(sources)
|
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.sources = dict(enumerate(sources, 1))
|
||||||
self.configs = dict(enumerate(configs, 1))
|
self.configs = dict(enumerate(configs, 1))
|
||||||
self.runtimes = {}
|
self.runtimes = {}
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
|
|
||||||
def validate_id(self, sensor_id: int) -> None:
|
def connect_all(self) -> None:
|
||||||
if sensor_id not in self.sources:
|
"""Prepare every source, rolling back all runtimes on any failure."""
|
||||||
raise ValueError(f"sensor ID must be 1..{len(self.sources)}")
|
|
||||||
|
|
||||||
def connect(self, sensor_id: int = 1, source: Source | None = None) -> Source:
|
|
||||||
from orisys.finger import FingerRuntime
|
from orisys.finger import FingerRuntime
|
||||||
|
|
||||||
self.validate_id(sensor_id)
|
if self.runtimes:
|
||||||
source = self.sources[sensor_id] if source is None else parse_source(str(source))
|
raise RuntimeError("already connected; close before reconnecting")
|
||||||
if isinstance(source, int) and source < 0:
|
sensor_id = None
|
||||||
raise ValueError("camera index must be non-negative")
|
|
||||||
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:
|
try:
|
||||||
ok, _ = runtime.warmup(color=False)
|
for sensor_id, source in self.sources.items():
|
||||||
if not ok:
|
if is_video_file(source) and not Path(source).is_file():
|
||||||
raise RuntimeError("warmup could not read a frame")
|
raise ValueError(f"file not found: {source}")
|
||||||
|
runtime = FingerRuntime.open(
|
||||||
|
config=self.configs[sensor_id], video=source, verbose=self.verbose,
|
||||||
|
)
|
||||||
|
self.runtimes[sensor_id] = runtime
|
||||||
|
ok, _ = runtime.warmup(color=False)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("warmup could not read a frame")
|
||||||
|
except Exception as exc:
|
||||||
|
try:
|
||||||
|
self.close()
|
||||||
|
except Exception:
|
||||||
|
logger.exception("Connection rollback failed")
|
||||||
|
raise RuntimeError(f"sensor {sensor_id}: {exc}") from exc
|
||||||
except BaseException:
|
except BaseException:
|
||||||
runtime.close()
|
self.close()
|
||||||
raise
|
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:
|
def read_all(self, *, loop_video=False) -> tuple[ForceReading, ...]:
|
||||||
self.validate_id(sensor_id)
|
"""Read a complete round. EOF/failure raises; no partial round is returned.
|
||||||
if sensor_id not in self.runtimes:
|
|
||||||
raise ValueError("not connected")
|
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]
|
runtime = self.runtimes[sensor_id]
|
||||||
ok, image = runtime.grab_and_process(color=False)
|
ok, image = runtime.grab_and_process(color=False)
|
||||||
if not ok and loop_video and is_video_file(self.sources[sensor_id]):
|
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"))
|
units = getattr(tactile, "force_units", ("unknown", "unknown", "unknown"))
|
||||||
return ForceReading(sensor_id, *forces, tuple(units), image)
|
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:
|
def close(self) -> None:
|
||||||
first_error = None
|
first_error = None
|
||||||
for sensor_id in list(self.runtimes):
|
for sensor_id in list(self.runtimes):
|
||||||
try:
|
try:
|
||||||
self.disconnect(sensor_id)
|
self.runtimes.pop(sensor_id).close()
|
||||||
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
|
||||||
|
|||||||
@@ -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())
|
||||||
+173
-227
@@ -103,176 +103,126 @@ class HardwareFreeTest(unittest.TestCase):
|
|||||||
self.addCleanup(modules.stop)
|
self.addCleanup(modules.stop)
|
||||||
|
|
||||||
|
|
||||||
class ExistingRegressionTests(HardwareFreeTest):
|
class ConnectionFirstTests(HardwareFreeTest):
|
||||||
def test_partial_command_waits_for_terminator(self):
|
def test_connects_every_source_before_ready_or_force(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):
|
|
||||||
for count in range(1, 5):
|
for count in range(1, 5):
|
||||||
with self.subTest(count=count):
|
with self.subTest(count=count):
|
||||||
ser = ByteSerial()
|
ser = ByteSerial()
|
||||||
controller = uart.UartFingerController(ser, sources=list(range(count)))
|
controller = uart.UartFingerController(ser, sources=list(range(count)))
|
||||||
controller.cmd_connect(["all"])
|
events = []
|
||||||
controller._dispatch("read all")
|
def open_runtime(events=events, **kwargs):
|
||||||
forces = [line for line in ser.output if line.startswith("force,")]
|
events.append(kwargs["video"])
|
||||||
expected = ["force,1.250000,-2.500000,3.750000"] if count == 1 else [
|
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)
|
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()
|
controller.close_runtime()
|
||||||
|
|
||||||
def test_selective_stop_and_reconnect_leave_other_sensor_running(self):
|
def test_one_failed_connection_rolls_back_every_opened_camera(self):
|
||||||
controller = uart.UartFingerController(ByteSerial(), sources=[0, 2, 4, 6])
|
bank = FingerSensorBank([0, 2, 4])
|
||||||
controller.cmd_connect(["all"])
|
opened = []
|
||||||
controller._dispatch("run all")
|
def open_runtime(**kwargs):
|
||||||
controller._dispatch("stop 2")
|
runtime = FakeRuntime(warmup_error=kwargs["video"] == 2)
|
||||||
self.assertEqual(controller._streaming, {1, 3, 4})
|
opened.append(runtime)
|
||||||
old = controller.bank.runtimes[3]
|
return runtime
|
||||||
controller._dispatch("connect 3 8")
|
with (patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime),
|
||||||
self.assertTrue(old.closed)
|
self.assertRaisesRegex(RuntimeError, "sensor 2")):
|
||||||
self.assertEqual(controller._streaming, {1, 4})
|
bank.connect_all()
|
||||||
self.assertEqual(controller.bank.sources[3], 8)
|
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()
|
ser = ByteSerial()
|
||||||
controller = uart.UartFingerController(ser, sources=[0, 2])
|
controller = uart.UartFingerController(ser, sources=[0, 2])
|
||||||
controller.cmd_connect(["all"])
|
for command in ["connect", "connect 2", "connect all", "run", "run all", "search", "exit"]:
|
||||||
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"]:
|
|
||||||
self.assertTrue(controller._dispatch(command))
|
self.assertTrue(controller._dispatch(command))
|
||||||
self.assertTrue(ser.output[-1].startswith("ERR "))
|
self.assertTrue(ser.output[-1].startswith("ERR unknown"))
|
||||||
|
|
||||||
|
def test_no_per_sensor_or_all_command_arguments(self):
|
||||||
class ProtocolTests(HardwareFreeTest):
|
|
||||||
def test_repeated_run_is_rejected_without_recursion(self):
|
|
||||||
ser = ByteSerial()
|
ser = ByteSerial()
|
||||||
controller = uart.UartFingerController(ser)
|
controller = uart.UartFingerController(ser, sources=[0, 2])
|
||||||
controller.cmd_connect([])
|
for command in ["start 1", "stop all", "read 2", "quit extra"]:
|
||||||
controller._dispatch("run")
|
self.assertTrue(controller._dispatch(command))
|
||||||
controller._dispatch("run")
|
self.assertTrue(ser.output[-1].startswith("ERR command"))
|
||||||
self.assertEqual(ser.output[-1], "ERR run already running")
|
|
||||||
self.assertEqual(controller._streaming, {1})
|
|
||||||
|
|
||||||
def test_camera_search_failure_is_reported_without_exiting(self):
|
def test_start_stop_and_read_control_entire_connected_bank(self):
|
||||||
ser = ByteSerial()
|
ser = ByteSerial()
|
||||||
controller = uart.UartFingerController(ser)
|
controller = uart.UartFingerController(ser, sources=[0, 2])
|
||||||
with patch("orisys.capture.list_cameras", side_effect=OSError("camera scan failed")):
|
controller.bank.connect_all()
|
||||||
self.assertTrue(controller._dispatch("search"))
|
controller._dispatch("stop")
|
||||||
self.assertEqual(ser.output[-1], "ERR search camera scan failed")
|
controller._dispatch("read")
|
||||||
|
self.assertEqual(ser.output[-2:], [
|
||||||
def test_legacy_connect_and_batched_stop_quit(self):
|
"force,1,1.250000,-2.500000,3.750000",
|
||||||
ser = ByteSerial(b"connect 2\r\nrun\nstop\nquit\n")
|
"force,2,1.250000,-2.500000,3.750000",
|
||||||
controller = uart.UartFingerController(ser)
|
])
|
||||||
controller.serve()
|
|
||||||
self.assertEqual(ser.output, ["OK ready", "OK connect 2 (camera)", "OK run", "OK stop", "OK quit"])
|
|
||||||
self.assertFalse(controller._streaming)
|
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):
|
def test_read_all_never_publishes_partial_round_on_processing_failure(self):
|
||||||
ser = ByteSerial(b"stop\nquit\n")
|
ser = ByteSerial()
|
||||||
controller = uart.UartFingerController(ser)
|
controller = uart.UartFingerController(ser, sources=[0, 2])
|
||||||
controller.cmd_connect([])
|
controller.bank.connect_all()
|
||||||
controller.cmd_run([])
|
controller.bank.runtimes[2].grab_error = True
|
||||||
controller.serve()
|
controller._streaming = True
|
||||||
self.assertFalse(any(line.startswith("force,") for line in ser.output))
|
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_read_only_autostarts_and_ignores_host_commands(self):
|
def test_serve_rejects_incomplete_bank_before_ready(self):
|
||||||
ser = ByteSerial(b"quit\nstop\nconnect 9\n" + b"a" * 1100 + b"\n")
|
ser = ByteSerial()
|
||||||
controller = uart.UartFingerController(ser, read_only=True)
|
controller = uart.UartFingerController(ser, sources=[0, 2])
|
||||||
|
with self.assertRaises(RuntimeError):
|
||||||
|
controller.serve()
|
||||||
|
self.assertEqual(ser.output, [])
|
||||||
|
|
||||||
|
|
||||||
|
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
|
original_write = ser.write
|
||||||
def write(data):
|
def write(data):
|
||||||
size = original_write(data)
|
size = original_write(data)
|
||||||
@@ -282,62 +232,69 @@ class ProtocolTests(HardwareFreeTest):
|
|||||||
ser.write = write
|
ser.write = write
|
||||||
with self.assertRaises(KeyboardInterrupt):
|
with self.assertRaises(KeyboardInterrupt):
|
||||||
controller.serve()
|
controller.serve()
|
||||||
self.assertIn("OK run", ser.output)
|
self.assertEqual(ser.output, ["OK ready 1", "force,1,1.250000,-2.500000,3.750000"])
|
||||||
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))
|
|
||||||
|
|
||||||
def test_long_line_is_discarded_until_delimiter(self):
|
def test_short_write_is_transport_failure(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):
|
|
||||||
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).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()
|
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"])
|
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 = FingerSensorBank([0, 2])
|
||||||
bank.connect(1)
|
bank.connect_all()
|
||||||
bank.connect(2)
|
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]
|
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"):
|
||||||
@@ -347,38 +304,27 @@ class ProtocolTests(HardwareFreeTest):
|
|||||||
|
|
||||||
|
|
||||||
class CliTests(HardwareFreeTest):
|
class CliTests(HardwareFreeTest):
|
||||||
def test_open_serial_has_finite_write_timeout_and_no_flow_control(self):
|
def test_sources_are_required_and_legacy_options_are_removed(self):
|
||||||
serial = types.ModuleType("serial")
|
for args in [[], ["--camera-id", "0"], ["--video", "a.mp4"],
|
||||||
serial.EIGHTBITS, serial.PARITY_NONE, serial.STOPBITS_ONE = 8, "N", 1
|
["--sources", "0", "--autostart"], ["--sources", "0", "--max-camera-index", "10"],
|
||||||
serial.Serial = types.SimpleNamespace()
|
["--sources", "0", "1", "2", "3", "4"], ["--sources", "0", "--timeout", "nan"]]:
|
||||||
with patch.dict(sys.modules, {"serial": serial}), patch.object(serial, "Serial") as factory:
|
with self.subTest(args=args), patch.object(uart, "open_serial") as serial:
|
||||||
uart.open_serial("/dev/ttyHS1", 115200, 0.1, 0.5)
|
with self.assertRaises(SystemExit) as status:
|
||||||
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:
|
|
||||||
uart.main(args)
|
uart.main(args)
|
||||||
self.assertEqual(exit_status.exception.code, 2)
|
self.assertEqual(status.exception.code, 2)
|
||||||
open_serial.assert_not_called()
|
serial.assert_not_called()
|
||||||
|
|
||||||
def test_transport_failure_returns_nonzero_and_closes_all_resources(self):
|
def test_serial_failure_closes_every_connected_runtime(self):
|
||||||
ser = ByteSerial()
|
opened = []
|
||||||
ser.closed = False
|
def open_runtime(**kwargs):
|
||||||
ser.close = lambda: setattr(ser, "closed", True)
|
runtime = FakeRuntime()
|
||||||
def serve(controller):
|
opened.append(runtime)
|
||||||
controller.cmd_connect(["all"])
|
return runtime
|
||||||
self.runtimes = list(controller.bank.runtimes.values())
|
with (patch("orisys.finger.FingerRuntime.open", side_effect=open_runtime),
|
||||||
raise OSError("UART disconnected")
|
patch.object(uart, "open_serial", side_effect=OSError("UART unavailable"))):
|
||||||
with patch.object(uart, "open_serial", return_value=ser), patch.object(uart.UartFingerController, "serve", serve):
|
|
||||||
self.assertEqual(uart.main(["--sources", "0", "2"]), 1)
|
self.assertEqual(uart.main(["--sources", "0", "2"]), 1)
|
||||||
self.assertTrue(ser.closed)
|
self.assertEqual(len(opened), 2)
|
||||||
self.assertTrue(all(runtime.closed for runtime in self.runtimes))
|
self.assertTrue(all(r.closed for r in opened))
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
Reference in New Issue
Block a user