修复 UART 命令处理并支持 1–4 路传感器

增加只读启动和可调用采集 API,保留单路数据协议。通过 28 项模拟回归测试、Ruff 和文档检查;实机限制见 docs/uart.md。
This commit is contained in:
ykcheungab
2026-10-14 14:17:27 +08:00
parent 14d5b1b086
commit c970272361
5 changed files with 946 additions and 387 deletions
+4
View File
@@ -0,0 +1,4 @@
# Dragon Q6A deployment helpers
See [UART setup, commands and API](docs/uart.md) for controlling or passively
reading 1–4 sensors through a data UART separate from the TTL login console.
+141
View File
@@ -0,0 +1,141 @@
# 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.
## Launch from the login console
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.
From the board's `dragon-q6a` checkout:
```bash
# Host commands control connection and streaming (default: one camera).
python serial/example_uart.py --port /dev/ttyHS1 --config finger
# 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
# 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
# Start streaming immediately, while still accepting UART commands.
python serial/example_uart.py --port /dev/ttyHS1 --sources 0 2 --autostart
```
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.
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`.
## Command protocol
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.
| 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:
```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
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.
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.
## 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:
```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)
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.
## Verification and 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:
```bash
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.
+265 -372
View File
@@ -1,460 +1,353 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
"""UART finger controller for Radxa / Dragon Q6A (sensor-side receiver). """Headless UART finger service for 1-4 sensors; see ../docs/uart.md.
Combines ORY-FINGER processing (same path as ``examples/finger_pipeline.py``) The login console and data UART must be separate ports. Single-sensor force
with a line-based UART command interface. A host (or ``uart_send.py``-style records retain the original format; multi-sensor records add a 1-based ID.
transmitter) sends commands; this script replies and streams forces. Importing this module opens no devices; acquisition is owned by finger_api.
Dependencies (beyond orisys):
pip install pyserial
Default UART (Dragon Q6A):
port=/dev/ttyHS1 baud=115200 8N1
Commands (one line, case-insensitive):
search List cameras (/dev/video*) via orisys.capture.list_cameras
connect [source] Open FingerRuntime (camera index, /dev/videoN, or video file)
run Stream FNORMAL / FSHEARX / FSHEARY until stop (or EOF)
stop Stop force streaming; keep source connected
quit | exit Close runtime and serial, then exit
Replies:
camera,<index>,<path>,<WxH>
OK search <N>
OK connect <source>
OK run
force,<fnormal>,<fshearx>,<fsheary>
OK eof Video file ended (when --no-loop-video)
OK stop
OK quit
ERR <context> <message>
Examples:
# Live camera over UART
python scripts/example_uart.py --port /dev/ttyHS1 --config finger
# Offline debug from a recorded file (no camera needed)
python scripts/example_uart.py --port /dev/ttyHS1 --video /path/to/clip.mp4
# then UART: connect (uses --video) or connect /path/to/clip.mp4
# then UART: run
""" """
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import logging
import math
import os import os
import shlex import shlex
import sys import sys
from collections import deque
from pathlib import Path from pathlib import Path
from typing import Optional, Union
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
try: from finger_api import FingerSensorBank, is_video_file, parse_source
import serial
except ImportError as exc: # pragma: no cover
raise SystemExit(
"pyserial is required for this script.\n"
" pip install pyserial\n"
f"Import error: {exc}"
) from exc
import cv2 logger = logging.getLogger(__name__)
from orisys.capture import is_live_camera, list_cameras
from orisys.finger import FingerRuntime
Source = Union[int, str]
def _parse_source(token: str) -> Optional[Source]:
"""Accept camera index int, numeric string, V4L path, or filesystem path."""
text = str(token).strip().strip('"').strip("'")
if not text:
return None
if text.isdigit() or (text.startswith("-") and text[1:].isdigit()):
return int(text)
return text
def _is_video_file(source: Source) -> bool:
"""True when source is a filesystem video path (not a live camera)."""
if is_live_camera(source):
return False
if isinstance(source, int):
return False
path = Path(str(source))
return path.is_file() or path.suffix.lower() in {
".mp4",
".avi",
".mkv",
".mov",
".m4v",
".wmv",
".webm",
}
class UartFingerController: class UartFingerController:
"""Serial command dispatcher around a single FingerRuntime.""" """Single-threaded command service; the caller owns serial and final cleanup."""
def __init__( def __init__(
self, self, ser, *, config="finger", default_source=0, sources=None,
ser: serial.Serial, configs=None, max_camera_index=10, loop_video=True, show=False,
*, verbose=False, read_only=False, autostart=False,
config: str = "finger",
default_source: Source = 0,
max_camera_index: int = 10,
loop_video: bool = True,
show: bool = False,
verbose: bool = False,
): ):
self.ser = ser self.ser = ser
self.config = config self.bank = FingerSensorBank(
self.default_source = default_source 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.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.runtime: Optional[FingerRuntime] = None self.autostart = autostart or read_only
self._connected_source: Optional[Source] = None self._streaming = set()
self._last_search_source: Optional[Source] = None
self._streaming = False
self._should_quit = False self._should_quit = False
self._last_search_source = None
self._partial = bytearray()
self._lines = deque()
self._discard_line = False
def reply(self, line: str) -> None: @property
text = line.rstrip("\r\n") + "\n" def multi_sensor(self):
self.ser.write(text.encode("utf-8")) return len(self.bank.sources) > 1
self.ser.flush()
def reply(self, line):
# SDK exceptions can contain newlines; every reply must be one record.
text = " ".join(str(line).splitlines()) + "\n"
data = text.encode("utf-8")
if self.ser.write(data) != len(data):
raise OSError("incomplete UART write")
if self.verbose: if self.verbose:
print(f"<< {text.rstrip()}", flush=True) print(f"<< {text.rstrip()}", file=sys.stderr, flush=True)
def close_runtime(self) -> None: def close_runtime(self):
self._streaming = False self._streaming.clear()
if self.runtime is not None:
try: try:
self.runtime.close() self.bank.close()
except Exception: # noqa: BLE001 finally:
pass
self.runtime = None
self._connected_source = None
if self.show: if self.show:
try: import cv2
cv2.destroyAllWindows() cv2.destroyAllWindows()
except Exception: # noqa: BLE001
pass
def _resolve_connect_source(self, args: list[str]) -> Source: def _poll_command_line(self, *, blocking=True):
if self._lines:
return self._lines.popleft()
waiting = self.ser.in_waiting
if not waiting and not blocking:
return None
# Never readline(): a timeout may return only half of a command.
raw = self.ser.read(min(waiting, 4096) if waiting else 1)
if self.read_only:
return None
for byte in raw:
if byte in (10, 13):
if self._discard_line:
self._discard_line = False
self._partial.clear()
self.reply("ERR command line too long (max 1024 bytes)")
elif self._partial:
self._lines.append(self._partial.decode("utf-8", errors="replace").strip())
self._partial.clear()
elif not self._discard_line:
self._partial.append(byte)
if len(self._partial) > 1024:
self._partial.clear()
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: if args:
# Join remaining tokens so unquoted paths with spaces still work. raise ValueError("search takes no arguments")
joined = " ".join(args).strip() if self._streaming:
parsed = _parse_source(joined) raise ValueError("stop streaming before camera search")
if parsed is None:
raise ValueError("empty connect source")
return parsed
# Prefer --video default over last search so offline debug stays on file.
if isinstance(self.default_source, str) and not is_live_camera(self.default_source):
return self.default_source
if self._last_search_source is not None:
return self._last_search_source
return self.default_source
def cmd_search(self, _args: list[str]) -> None:
try: try:
from orisys.capture import list_cameras
cameras = list_cameras(max_index=self.max_camera_index) cameras = list_cameras(max_index=self.max_camera_index)
except Exception as exc: # noqa: BLE001 except Exception as exc:
logger.exception("Camera search failed")
self.reply(f"ERR search {exc}") self.reply(f"ERR search {exc}")
return return
self._last_search_source = None self._last_search_source = None
for cam in cameras: for cam in cameras:
path = (cam.path or f"/dev/video{cam.index}").strip() or f"/dev/video{cam.index}" path = (cam.path or f"/dev/video{cam.index}").strip() or f"/dev/video{cam.index}"
size = f"{cam.width}x{cam.height}" self.reply(f"camera,{cam.index},{path},{cam.width}x{cam.height}")
self.reply(f"camera,{cam.index},{path},{size}")
if self._last_search_source is None: if self._last_search_source is None:
self._last_search_source = cam.index self._last_search_source = cam.index
self.reply(f"OK search {len(cameras)}") self.reply(f"OK search {len(cameras)}")
def cmd_connect(self, args: list[str]) -> None: def cmd_connect(self, args):
try: # Legacy `connect <source>` targets sensor 1. `connect <id> <source>`
source = self._resolve_connect_source(args) # selects a slot explicitly; quote source paths containing spaces.
except ValueError as exc: if args and args[0].lower() == "all":
self.reply(f"ERR connect {exc}") if len(args) != 1:
raise ValueError("expected connect all")
for sensor_id in self.bank.sources:
self._connect_one(sensor_id)
return 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)
if isinstance(source, str) and not is_live_camera(source): def _connect_one(self, sensor_id, source=None):
path = Path(source) self.bank.validate_id(sensor_id)
if not path.is_file(): self._streaming.discard(sensor_id)
self.reply(f"ERR connect file not found: {source}") 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 return
kind = "file" if is_video_file(source) else "camera"
self.reply(f"OK connect{self._sensor_suffix(sensor_id)} {source} ({kind})")
self._streaming = False def cmd_run(self, args):
self.close_runtime() targets = self._targets(args, connected=True)
try: if not targets or any(i not in self.bank.runtimes for i in targets):
runtime = FingerRuntime.open( raise ValueError("not connected")
config=self.config, if set(targets).issubset(self._streaming):
video=source, raise ValueError("already running")
verbose=self.verbose, self._streaming.update(targets)
) self.reply("OK run" + (f" {' '.join(map(str, targets))}" if self.multi_sensor else ""))
runtime.warmup(color=False)
except Exception as exc: # noqa: BLE001
self.reply(f"ERR connect {exc}")
return
self.runtime = runtime def cmd_stop(self, args):
self._connected_source = source targets = self._targets(args)
kind = "file" if _is_video_file(source) else "camera" self._streaming.difference_update(targets)
self.reply(f"OK connect {source} ({kind})") self.reply("OK stop" + (f" {' '.join(map(str, targets))}" if self.multi_sensor else ""))
def _rewind_video(self) -> bool: def cmd_quit(self, args):
"""Seek connected file source back to frame 0. Return True on success.""" if args:
if self.runtime is None or self._connected_source is None: raise ValueError("quit takes no arguments")
return False self._streaming.clear()
if not _is_video_file(self._connected_source):
return False
cap = self.runtime.capture
if cap is None:
return False
try:
return bool(cap.set(cv2.CAP_PROP_POS_FRAMES, 0))
except Exception: # noqa: BLE001
return False
def _poll_command_line(self, *, blocking: bool = True) -> Optional[str]:
"""Read one command line if available; return None on timeout/empty."""
if not blocking and self.ser.in_waiting <= 0:
return None
raw = self.ser.readline()
if not raw:
return None
try:
line = raw.decode("utf-8", errors="replace").strip()
except Exception: # noqa: BLE001
return None
return line or None
def cmd_run(self, _args: list[str]) -> None:
if self.runtime is None:
self.reply("ERR run not connected")
return
self._streaming = True
self.reply("OK run")
tactile = self.runtime.tactile
is_file = self._connected_source is not None and _is_video_file(self._connected_source)
while self._streaming and not self._should_quit:
ok, unrolled = self.runtime.grab_and_process(color=False)
if not ok:
if is_file and self.loop_video and self._rewind_video():
if self.verbose:
print("video EOF: looping", flush=True)
continue
if is_file:
self.reply("OK eof")
else:
self.reply("ERR run grab failed")
self._streaming = False
break
tactile.compute_deformation()
tactile.compute_contact()
fnormal, fshearx, fsheary = tactile.read_info(
tactile.info.FNORMAL,
tactile.info.FSHEARX,
tactile.info.FSHEARY,
)
self.reply(f"force,{float(fnormal):.6f},{float(fshearx):.6f},{float(fsheary):.6f}")
if self.show:
try:
import orisys
arrows = orisys.util.draw_arrows(
unrolled,
tactile.flow,
threshold=0.4,
grid_spacing=10,
arrow_scale=1.0,
valid_mask=tactile.valid_mask,
)
cv2.imshow("UV Unrolled", arrows)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
self._should_quit = True
break
if key == ord("r"):
tactile.reset()
except Exception as exc: # noqa: BLE001
if self.verbose:
print(f"show error: {exc}", file=sys.stderr)
# Non-blocking command poll so force streaming is not delayed by UART timeout.
pending = self._poll_command_line(blocking=False)
if pending is not None:
if self.verbose:
print(f">> {pending}", flush=True)
if not self._dispatch(pending, allow_run=False):
break
def cmd_stop(self, _args: list[str]) -> None:
self._streaming = False
self.reply("OK stop")
def cmd_quit(self, _args: list[str]) -> None:
self._streaming = False
self._should_quit = True self._should_quit = True
self.reply("OK quit") self.reply("OK quit")
def _dispatch(self, line: str, *, allow_run: bool = True) -> bool: def _read_one(self, sensor_id, *, context):
"""Dispatch one command line. Return False if the main loop should exit.""" 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):
import cv2
import orisys
tactile = self.bank.runtimes[reading.sensor_id].tactile
try:
arrows = orisys.util.draw_arrows(
reading.image, tactile.flow, threshold=0.4, grid_spacing=10,
arrow_scale=1.0, valid_mask=tactile.valid_mask,
)
cv2.imshow(f"UV Unrolled {reading.sensor_id}", arrows)
key = cv2.waitKey(1) & 0xFF
if key == ord("q"):
self.cmd_quit([])
elif key == ord("r"):
tactile.reset()
except Exception:
logger.exception("Sensor %s display failed", reading.sensor_id)
def _dispatch(self, line):
if self.read_only:
return True
try: try:
parts = shlex.split(line) parts = shlex.split(line)
except ValueError: except ValueError as exc:
parts = line.split() self.reply(f"ERR command {exc}")
return True
if not parts: if not parts:
return True return True
cmd = parts[0].lower() cmd, args = parts[0].lower(), parts[1:]
args = parts[1:] try:
if cmd == "search": if cmd == "search":
self.cmd_search(args) self.cmd_search(args)
elif cmd == "connect": elif cmd == "connect":
self.cmd_connect(args) self.cmd_connect(args)
elif cmd == "run": elif cmd == "run":
if not allow_run:
self.reply("ERR run already running")
else:
self.cmd_run(args) self.cmd_run(args)
elif cmd == "read":
for sensor_id in self._targets(args):
self._read_one(sensor_id, context="read")
elif cmd == "stop": elif cmd == "stop":
self.cmd_stop(args) self.cmd_stop(args)
elif cmd in ("quit", "exit"): elif cmd in ("quit", "exit"):
self.cmd_quit(args) self.cmd_quit(args)
return False
else: else:
self.reply(f"ERR unknown {cmd}") self.reply(f"ERR unknown {cmd}")
except (ValueError, RuntimeError) as exc:
self.reply(f"ERR {cmd} {exc}")
return not self._should_quit return not self._should_quit
def serve(self) -> None: def serve(self):
self.reply("OK ready") self.reply("OK ready")
while not self._should_quit: if self.autostart:
line = self._poll_command_line() self.cmd_connect(["all"])
if line is None: if self.bank.runtimes:
continue self.cmd_run([])
if self.verbose:
print(f">> {line}", flush=True)
if not self._dispatch(line):
break
def open_serial(port: str, baud: int, timeout: float) -> serial.Serial:
try:
return serial.Serial(
port=port,
baudrate=baud,
bytesize=serial.EIGHTBITS,
parity=serial.PARITY_NONE,
stopbits=serial.STOPBITS_ONE,
timeout=timeout,
)
except serial.SerialException as exc:
print(f"Serial error: {exc}", file=sys.stderr)
print("Check:", file=sys.stderr)
print(f" 1. Device node {port} exists", file=sys.stderr)
print(" 2. User has access (sudo or dialout group)", file=sys.stderr)
print(" 3. Wiring / USB-TTL connection", file=sys.stderr)
raise SystemExit(1) from exc
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(
description="UART finger controller (search/connect/run/stop force stream)"
)
parser.add_argument("--port", default="/dev/ttyHS1", help="Serial device (default: /dev/ttyHS1)")
parser.add_argument("--baud", type=int, default=115200, help="Baud rate (default: 115200)")
parser.add_argument(
"--timeout",
type=float,
default=0.1,
help="Serial readline timeout seconds (default: 0.1)",
)
parser.add_argument("--config", "-c", default="finger", help="Orisys config name or path")
parser.add_argument(
"--camera-id",
type=int,
default=0,
help="Default camera index when connect has no argument (ignored if --video is set)",
)
parser.add_argument(
"--video",
default=None,
help="Default video file for connect (offline debug without a camera)",
)
parser.add_argument(
"--max-camera-index",
type=int,
default=10,
help="Highest index probed by search",
)
parser.add_argument(
"--loop-video",
dest="loop_video",
action="store_true",
default=True,
help="Loop video file on EOF during run (default: on)",
)
parser.add_argument(
"--no-loop-video",
dest="loop_video",
action="store_false",
help="Stop run with OK eof when video file ends",
)
parser.add_argument(
"--show",
action="store_true",
help="Show UV unrolled OpenCV window (local debug only)",
)
parser.add_argument("--verbose", action="store_true", help="Echo UART traffic and SDK logs")
args = parser.parse_args(argv)
if args.video:
video_path = Path(args.video)
if not video_path.is_file():
print(f"Video file not found: {args.video}", file=sys.stderr)
return 1
default_source: Source = str(video_path.resolve())
else: else:
default_source = args.camera_id raise RuntimeError("autostart could not connect any sensor")
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
ser = open_serial(args.port, args.baud, args.timeout)
controller = UartFingerController( def open_serial(port, baud, timeout, write_timeout=1.0):
ser, try:
config=args.config, import serial
default_source=default_source, except ImportError as exc:
max_camera_index=args.max_camera_index, raise RuntimeError("pyserial is required: python -m pip install pyserial") from exc
loop_video=args.loop_video, return serial.Serial(
show=args.show, port=port, baudrate=baud, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
verbose=args.verbose, stopbits=serial.STOPBITS_ONE, timeout=timeout, write_timeout=write_timeout,
exclusive=True,
) )
print(f"UART finger controller on {args.port} @ {args.baud} bps", flush=True)
print(f"Default source: {default_source}", flush=True)
print("Commands: search | connect [source|file] | run | stop | quit", flush=True)
def main(argv=None):
parser = argparse.ArgumentParser(description="UART finger control and read-only streaming")
parser.add_argument("--port", default="/dev/ttyHS1")
parser.add_argument("--baud", type=int, default=115200)
parser.add_argument("--timeout", type=float, default=0.1, help="idle read timeout in seconds")
parser.add_argument("--write-timeout", type=float, default=1.0)
parser.add_argument("--config", "-c", action="append", help="one shared config or repeat per sensor")
parser.add_argument("--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("--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")
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 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: 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,
]
controller = UartFingerController(
None, sources=sources, configs=configs, max_camera_index=args.max_camera_index,
loop_video=args.loop_video, show=args.show, verbose=args.verbose,
autostart=args.autostart, read_only=args.read_only,
)
except ValueError as exc:
parser.error(str(exc))
ser = None
result = 0
try:
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)
controller.serve() controller.serve()
except KeyboardInterrupt: except KeyboardInterrupt:
print("\nInterrupted.", flush=True) print("Interrupted.", file=sys.stderr)
try: except Exception:
controller.reply("OK quit") logger.exception("UART service failed on %s", args.port)
except Exception: # noqa: BLE001 result = 1
pass
finally: finally:
try:
controller.close_runtime() controller.close_runtime()
if ser.is_open: except Exception:
logger.exception("Runtime cleanup failed")
result = 1
finally:
if ser is not None:
ser.close() ser.close()
print("Serial closed.", flush=True) return result
return 0
if __name__ == "__main__": if __name__ == "__main__":
+136
View File
@@ -0,0 +1,136 @@
"""Single-threaded finger acquisition API, independent of UART and the CLI.
Sensor IDs are 1-based, fixed by source order. Calls and runtime ownership belong
to one thread; the caller must close the bank in a finally block. Values use the
SDK's FNORMAL/FSHEARX/FSHEARY axes and calibration units, not assumed Newtons.
"""
from __future__ import annotations
import logging
import math
from dataclasses import dataclass
from pathlib import Path
Source = int | str
logger = logging.getLogger(__name__)
def parse_source(token: str) -> Source:
text = token.strip()
if not text:
raise ValueError("empty source")
return int(text) if text.isdigit() or (text.startswith("-") and text[1:].isdigit()) else text
def is_video_file(source: Source) -> bool:
from orisys.capture import is_live_camera
return not is_live_camera(source) and not isinstance(source, int)
@dataclass(frozen=True)
class ForceReading:
sensor_id: int
normal: float
shear_x: float
shear_y: float
units: tuple[str, str, str]
image: object
class FingerSensorBank:
"""Own 1–4 independent runtimes. read() returns None at EOF/capture failure.
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):
sources = tuple(parse_source(str(source)) for source in sources)
configs = tuple(configs)
if not 1 <= len(sources) <= 4:
raise ValueError("provide 1 to 4 sources")
if len(configs) not in (1, len(sources)):
raise ValueError("provide one config or one config per source")
if any(isinstance(source, int) and source < 0 for source in sources):
raise ValueError("camera index must be non-negative")
if len(configs) == 1:
configs = configs * len(sources)
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:
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 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:
ok, _ = runtime.warmup(color=False)
if not ok:
raise RuntimeError("warmup could not read a frame")
except BaseException:
runtime.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")
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]):
import cv2
# One rewind attempt per read prevents an empty file from spinning forever.
if runtime.capture is not None and runtime.capture.set(cv2.CAP_PROP_POS_FRAMES, 0):
runtime.tactile.reset()
ok, image = runtime.grab_and_process(color=False)
if not ok:
return None
tactile = runtime.tactile
tactile.compute_deformation()
tactile.compute_contact()
forces = tuple(float(value) for value in tactile.read_info(
tactile.info.FNORMAL, tactile.info.FSHEARX, tactile.info.FSHEARY,
))
if not all(math.isfinite(value) for value in forces):
raise RuntimeError("non-finite force measurement")
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)
except Exception as exc:
logger.exception("Sensor %s cleanup failed", sensor_id)
first_error = first_error or exc
if first_error is not None:
raise first_error
+385
View File
@@ -0,0 +1,385 @@
"""Hardware-free protocol and sensor lifecycle regression tests."""
import io
import sys
import types
import unittest
from contextlib import redirect_stderr
from pathlib import Path
from unittest.mock import patch
SERIAL_DIR = Path(__file__).resolve().parents[1] / "serial"
sys.path.insert(0, str(SERIAL_DIR))
import example_uart as uart
from finger_api import FingerSensorBank
class ByteSerial:
def __init__(self, data=b""):
self.input = bytearray(data)
self.output = []
@property
def in_waiting(self):
return len(self.input)
def read(self, size=1):
result = bytes(self.input[:size])
del self.input[:size]
return result
def readline(self):
end = self.input.find(b"\n")
return self.read(end + 1 if end >= 0 else len(self.input))
def write(self, data):
self.output.append(data.decode().rstrip("\n"))
return len(data)
def flush(self):
pass
class FakeTactile:
info = types.SimpleNamespace(FNORMAL=1, FSHEARX=2, FSHEARY=3)
force_units = ("raw", "raw", "raw")
def compute_deformation(self):
pass
def compute_contact(self):
pass
def read_info(self, *args):
return 1.25, -2.5, 3.75
def reset(self):
pass
class FakeRuntime:
def __init__(self, *, warmup_error=False, warmup_ok=True, grab_error=False):
self.tactile = FakeTactile()
self.closed = False
self.warmup_error = warmup_error
self.warmup_ok = warmup_ok
self.grab_error = grab_error
self.capture = types.SimpleNamespace(set=lambda *args: True)
self.frames = 1
def warmup(self, **kwargs):
if self.warmup_error:
raise RuntimeError("warmup failed")
return self.warmup_ok, None
def grab_and_process(self, **kwargs):
if self.grab_error:
raise RuntimeError("processing failed")
if self.frames:
self.frames -= 1
return True, None
return False, None
def close(self):
self.closed = True
class HardwareFreeTest(unittest.TestCase):
def setUp(self):
stderr = redirect_stderr(io.StringIO())
stderr.__enter__()
self.addCleanup(stderr.__exit__, None, None, None)
finger = types.ModuleType("orisys.finger")
finger.FingerRuntime = types.SimpleNamespace(open=lambda **kwargs: FakeRuntime())
capture = types.ModuleType("orisys.capture")
capture.is_live_camera = lambda source: isinstance(source, int) or str(source).startswith("/dev/video")
capture.list_cameras = lambda **kwargs: []
package = types.ModuleType("orisys")
package.finger, package.capture = finger, capture
modules = patch.dict(sys.modules, {
"orisys": package, "orisys.finger": finger, "orisys.capture": capture,
})
modules.start()
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):
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 [
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_failed_sensor_does_not_stop_healthy_sensor(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"]:
self.assertTrue(controller._dispatch(command))
self.assertTrue(ser.output[-1].startswith("ERR "))
class ProtocolTests(HardwareFreeTest):
def test_repeated_run_is_rejected_without_recursion(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})
def test_camera_search_failure_is_reported_without_exiting(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"])
self.assertFalse(controller._streaming)
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([])
controller.serve()
self.assertFalse(any(line.startswith("force,") for line in 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)
original_write = ser.write
def write(data):
size = original_write(data)
if data.startswith(b"force,"):
raise KeyboardInterrupt()
return size
ser.write = write
with self.assertRaises(KeyboardInterrupt):
controller.serve()
self.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))
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):
ser = ByteSerial()
ser.write = lambda data: len(data) - 1
with self.assertRaises(OSError):
uart.UartFingerController(ser).reply("OK ready")
def test_error_newlines_cannot_inject_protocol_records(self):
ser = ByteSerial()
uart.UartFingerController(ser).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):
bank = FingerSensorBank([0, 2])
bank.connect(1)
bank.connect(2)
second = bank.runtimes[2]
bank.runtimes[1].close = lambda: (_ for _ in ()).throw(RuntimeError("close failed"))
with self.assertRaisesRegex(RuntimeError, "close failed"):
bank.close()
self.assertTrue(second.closed)
self.assertEqual(bank.runtimes, {})
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:
uart.main(args)
self.assertEqual(exit_status.exception.code, 2)
open_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):
self.assertEqual(uart.main(["--sources", "0", "2"]), 1)
self.assertTrue(ser.closed)
self.assertTrue(all(runtime.closed for runtime in self.runtimes))
if __name__ == "__main__":
unittest.main()