修复 UART 命令处理并支持 1–4 路传感器
增加只读启动和可调用采集 API,保留单路数据协议。通过 28 项模拟回归测试、Ruff 和文档检查;实机限制见 docs/uart.md。
This commit is contained in:
+280
-387
@@ -1,460 +1,353 @@
|
||||
#!/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``)
|
||||
with a line-based UART command interface. A host (or ``uart_send.py``-style
|
||||
transmitter) sends commands; this script replies and streams forces.
|
||||
|
||||
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
|
||||
The login console and data UART must be separate ports. Single-sensor force
|
||||
records retain the original format; multi-sensor records add a 1-based ID.
|
||||
Importing this module opens no devices; acquisition is owned by finger_api.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import math
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from collections import deque
|
||||
from pathlib import Path
|
||||
from typing import Optional, Union
|
||||
|
||||
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
||||
|
||||
try:
|
||||
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
|
||||
from finger_api import FingerSensorBank, is_video_file, parse_source
|
||||
|
||||
import cv2
|
||||
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",
|
||||
}
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class UartFingerController:
|
||||
"""Serial command dispatcher around a single FingerRuntime."""
|
||||
"""Single-threaded command service; the caller owns serial and final cleanup."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ser: serial.Serial,
|
||||
*,
|
||||
config: str = "finger",
|
||||
default_source: Source = 0,
|
||||
max_camera_index: int = 10,
|
||||
loop_video: bool = True,
|
||||
show: bool = False,
|
||||
verbose: bool = False,
|
||||
self, ser, *, config="finger", default_source=0, sources=None,
|
||||
configs=None, max_camera_index=10, loop_video=True, show=False,
|
||||
verbose=False, read_only=False, autostart=False,
|
||||
):
|
||||
self.ser = ser
|
||||
self.config = config
|
||||
self.default_source = default_source
|
||||
self.bank = FingerSensorBank(
|
||||
sources if sources is not None else (default_source,),
|
||||
configs=configs if configs is not None else (config,), verbose=verbose,
|
||||
)
|
||||
self.max_camera_index = max_camera_index
|
||||
self.loop_video = loop_video
|
||||
self.show = show
|
||||
self.verbose = verbose
|
||||
|
||||
self.runtime: Optional[FingerRuntime] = None
|
||||
self._connected_source: Optional[Source] = None
|
||||
self._last_search_source: Optional[Source] = None
|
||||
self._streaming = False
|
||||
self.read_only = read_only
|
||||
self.autostart = autostart or read_only
|
||||
self._streaming = set()
|
||||
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:
|
||||
text = line.rstrip("\r\n") + "\n"
|
||||
self.ser.write(text.encode("utf-8"))
|
||||
self.ser.flush()
|
||||
@property
|
||||
def multi_sensor(self):
|
||||
return len(self.bank.sources) > 1
|
||||
|
||||
def reply(self, line):
|
||||
# SDK exceptions can contain newlines; every reply must be one record.
|
||||
text = " ".join(str(line).splitlines()) + "\n"
|
||||
data = text.encode("utf-8")
|
||||
if self.ser.write(data) != len(data):
|
||||
raise OSError("incomplete UART write")
|
||||
if self.verbose:
|
||||
print(f"<< {text.rstrip()}", flush=True)
|
||||
print(f"<< {text.rstrip()}", file=sys.stderr, flush=True)
|
||||
|
||||
def close_runtime(self) -> None:
|
||||
self._streaming = False
|
||||
if self.runtime is not None:
|
||||
try:
|
||||
self.runtime.close()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
self.runtime = None
|
||||
self._connected_source = None
|
||||
if self.show:
|
||||
try:
|
||||
cv2.destroyAllWindows()
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
|
||||
def _resolve_connect_source(self, args: list[str]) -> Source:
|
||||
if args:
|
||||
# Join remaining tokens so unquoted paths with spaces still work.
|
||||
joined = " ".join(args).strip()
|
||||
parsed = _parse_source(joined)
|
||||
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:
|
||||
def close_runtime(self):
|
||||
self._streaming.clear()
|
||||
try:
|
||||
self.bank.close()
|
||||
finally:
|
||||
if self.show:
|
||||
import cv2
|
||||
|
||||
cv2.destroyAllWindows()
|
||||
|
||||
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:
|
||||
raise ValueError("search takes no arguments")
|
||||
if self._streaming:
|
||||
raise ValueError("stop streaming before camera search")
|
||||
try:
|
||||
from orisys.capture import list_cameras
|
||||
|
||||
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}")
|
||||
return
|
||||
|
||||
self._last_search_source = None
|
||||
for cam in cameras:
|
||||
path = (cam.path or f"/dev/video{cam.index}").strip() or f"/dev/video{cam.index}"
|
||||
size = f"{cam.width}x{cam.height}"
|
||||
self.reply(f"camera,{cam.index},{path},{size}")
|
||||
self.reply(f"camera,{cam.index},{path},{cam.width}x{cam.height}")
|
||||
if self._last_search_source is None:
|
||||
self._last_search_source = cam.index
|
||||
self.reply(f"OK search {len(cameras)}")
|
||||
|
||||
def cmd_connect(self, args: list[str]) -> None:
|
||||
try:
|
||||
source = self._resolve_connect_source(args)
|
||||
except ValueError as exc:
|
||||
self.reply(f"ERR connect {exc}")
|
||||
def cmd_connect(self, args):
|
||||
# Legacy `connect <source>` targets sensor 1. `connect <id> <source>`
|
||||
# selects a slot explicitly; quote source paths containing spaces.
|
||||
if args and args[0].lower() == "all":
|
||||
if len(args) != 1:
|
||||
raise ValueError("expected connect all")
|
||||
for sensor_id in self.bank.sources:
|
||||
self._connect_one(sensor_id)
|
||||
return
|
||||
sensor_id = 1
|
||||
if len(args) >= 2 and args[0].isdigit():
|
||||
sensor_id = int(args[0])
|
||||
args = args[1:]
|
||||
source = parse_source(" ".join(args)) if args else None
|
||||
if (source is None and not self.multi_sensor and self._last_search_source is not None
|
||||
and not is_video_file(self.bank.sources[1])):
|
||||
source = self._last_search_source
|
||||
self._connect_one(sensor_id, source)
|
||||
|
||||
if isinstance(source, str) and not is_live_camera(source):
|
||||
path = Path(source)
|
||||
if not path.is_file():
|
||||
self.reply(f"ERR connect file not found: {source}")
|
||||
return
|
||||
|
||||
self._streaming = False
|
||||
self.close_runtime()
|
||||
def _connect_one(self, sensor_id, source=None):
|
||||
self.bank.validate_id(sensor_id)
|
||||
self._streaming.discard(sensor_id)
|
||||
try:
|
||||
runtime = FingerRuntime.open(
|
||||
config=self.config,
|
||||
video=source,
|
||||
verbose=self.verbose,
|
||||
)
|
||||
runtime.warmup(color=False)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
self.reply(f"ERR connect {exc}")
|
||||
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})")
|
||||
|
||||
self.runtime = runtime
|
||||
self._connected_source = source
|
||||
kind = "file" if _is_video_file(source) else "camera"
|
||||
self.reply(f"OK connect {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 _rewind_video(self) -> bool:
|
||||
"""Seek connected file source back to frame 0. Return True on success."""
|
||||
if self.runtime is None or self._connected_source is None:
|
||||
return False
|
||||
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 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 _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
|
||||
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 _dispatch(self, line: str, *, allow_run: bool = True) -> bool:
|
||||
"""Dispatch one command line. Return False if the main loop should exit."""
|
||||
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):
|
||||
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:
|
||||
parts = shlex.split(line)
|
||||
except ValueError:
|
||||
parts = line.split()
|
||||
except ValueError as exc:
|
||||
self.reply(f"ERR command {exc}")
|
||||
return True
|
||||
if not parts:
|
||||
return True
|
||||
cmd = parts[0].lower()
|
||||
args = parts[1:]
|
||||
|
||||
if cmd == "search":
|
||||
self.cmd_search(args)
|
||||
elif cmd == "connect":
|
||||
self.cmd_connect(args)
|
||||
elif cmd == "run":
|
||||
if not allow_run:
|
||||
self.reply("ERR run already running")
|
||||
else:
|
||||
cmd, args = parts[0].lower(), parts[1:]
|
||||
try:
|
||||
if cmd == "search":
|
||||
self.cmd_search(args)
|
||||
elif cmd == "connect":
|
||||
self.cmd_connect(args)
|
||||
elif cmd == "run":
|
||||
self.cmd_run(args)
|
||||
elif cmd == "stop":
|
||||
self.cmd_stop(args)
|
||||
elif cmd in ("quit", "exit"):
|
||||
self.cmd_quit(args)
|
||||
return False
|
||||
else:
|
||||
self.reply(f"ERR unknown {cmd}")
|
||||
elif cmd == "read":
|
||||
for sensor_id in self._targets(args):
|
||||
self._read_one(sensor_id, context="read")
|
||||
elif cmd == "stop":
|
||||
self.cmd_stop(args)
|
||||
elif cmd in ("quit", "exit"):
|
||||
self.cmd_quit(args)
|
||||
else:
|
||||
self.reply(f"ERR unknown {cmd}")
|
||||
except (ValueError, RuntimeError) as exc:
|
||||
self.reply(f"ERR {cmd} {exc}")
|
||||
return not self._should_quit
|
||||
|
||||
def serve(self) -> None:
|
||||
def serve(self):
|
||||
self.reply("OK ready")
|
||||
if self.autostart:
|
||||
self.cmd_connect(["all"])
|
||||
if self.bank.runtimes:
|
||||
self.cmd_run([])
|
||||
else:
|
||||
raise RuntimeError("autostart could not connect any sensor")
|
||||
while not self._should_quit:
|
||||
line = self._poll_command_line()
|
||||
if line is None:
|
||||
continue
|
||||
if self.verbose:
|
||||
print(f">> {line}", flush=True)
|
||||
if not self._dispatch(line):
|
||||
break
|
||||
# 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
|
||||
|
||||
|
||||
def open_serial(port: str, baud: int, timeout: float) -> serial.Serial:
|
||||
def open_serial(port, baud, timeout, write_timeout=1.0):
|
||||
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
|
||||
import serial
|
||||
except ImportError as exc:
|
||||
raise RuntimeError("pyserial is required: python -m pip install pyserial") from exc
|
||||
return serial.Serial(
|
||||
port=port, baudrate=baud, bytesize=serial.EIGHTBITS, parity=serial.PARITY_NONE,
|
||||
stopbits=serial.STOPBITS_ONE, timeout=timeout, write_timeout=write_timeout,
|
||||
exclusive=True,
|
||||
)
|
||||
|
||||
|
||||
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")
|
||||
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.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:
|
||||
default_source = args.camera_id
|
||||
|
||||
ser = open_serial(args.port, args.baud, args.timeout)
|
||||
controller = UartFingerController(
|
||||
ser,
|
||||
config=args.config,
|
||||
default_source=default_source,
|
||||
max_camera_index=args.max_camera_index,
|
||||
loop_video=args.loop_video,
|
||||
show=args.show,
|
||||
verbose=args.verbose,
|
||||
)
|
||||
|
||||
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)
|
||||
|
||||
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:
|
||||
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()
|
||||
except KeyboardInterrupt:
|
||||
print("\nInterrupted.", flush=True)
|
||||
try:
|
||||
controller.reply("OK quit")
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
print("Interrupted.", file=sys.stderr)
|
||||
except Exception:
|
||||
logger.exception("UART service failed on %s", args.port)
|
||||
result = 1
|
||||
finally:
|
||||
controller.close_runtime()
|
||||
if ser.is_open:
|
||||
ser.close()
|
||||
print("Serial closed.", flush=True)
|
||||
return 0
|
||||
try:
|
||||
controller.close_runtime()
|
||||
except Exception:
|
||||
logger.exception("Runtime cleanup failed")
|
||||
result = 1
|
||||
finally:
|
||||
if ser is not None:
|
||||
ser.close()
|
||||
return result
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
Reference in New Issue
Block a user