上传文件至「serial」
This commit is contained in:
+243
-59
@@ -1,55 +1,100 @@
|
|||||||
#!/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.
|
||||||
|
|
||||||
The login console and data UART must be separate ports. Cameras are selected
|
The login console and data UART must be separate ports. Cameras are selected
|
||||||
explicitly by UART commands or a startup setup file. All force records include a sensor ID.
|
explicitly by UART commands or a startup setup file. All force records include
|
||||||
Importing this module opens no devices; acquisition is owned by finger_api.
|
a sensor ID. Importing this module opens no devices; acquisition is owned by
|
||||||
|
finger_api.
|
||||||
|
|
||||||
|
Commands (one line, case-insensitive, CR or LF terminated):
|
||||||
|
add SOURCE [CONFIG] [--calibration PATH]
|
||||||
|
load SETUP.json
|
||||||
|
remove SENSOR_ID
|
||||||
|
start | stop | read | quit | exit
|
||||||
|
list | status | help
|
||||||
|
|
||||||
|
Replies:
|
||||||
|
OK ready <n>
|
||||||
|
OK added <id> <kind> <source>
|
||||||
|
OK removed <id>
|
||||||
|
OK loaded <id> [<id> ...]
|
||||||
|
OK start | OK stop | OK quit
|
||||||
|
OK list <n>
|
||||||
|
sensor,<id>,<kind>,<source>,<config>,<calibration>
|
||||||
|
OK status sensors=<n> streaming=<0|1> loop_video=<0|1>
|
||||||
|
force,<id>,<fnormal>,<fshearx>,<fsheary>
|
||||||
|
OK eof <context>
|
||||||
|
ERR <context> <message>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
import difflib
|
||||||
import logging
|
import logging
|
||||||
import math
|
import math
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
import sys
|
import sys
|
||||||
|
import time
|
||||||
from collections import deque
|
from collections import deque
|
||||||
|
|
||||||
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, parse_source
|
from finger_api import FingerSensorBank, is_video_file, parse_source
|
||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
_KNOWN_COMMANDS = (
|
||||||
|
"add", "load", "remove", "start", "stop", "read", "quit", "exit",
|
||||||
|
"list", "status", "help",
|
||||||
|
)
|
||||||
|
|
||||||
|
_HELP_LINES = (
|
||||||
|
"add SOURCE [CONFIG] [--calibration PATH]",
|
||||||
|
"load SETUP.json",
|
||||||
|
"remove SENSOR_ID",
|
||||||
|
"start | stop | read | quit",
|
||||||
|
"list | status | help",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
class UartFingerController:
|
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", loop_video=True,
|
self, ser, *, config="finger", loop_video=True,
|
||||||
show=False, verbose=False,
|
show=False, verbose=False, max_hz=0.0, prompt=False,
|
||||||
):
|
):
|
||||||
self.ser = ser
|
self.ser = ser
|
||||||
self.bank = FingerSensorBank(config=config, verbose=verbose)
|
self.bank = FingerSensorBank(config=config, verbose=verbose)
|
||||||
self.loop_video = loop_video
|
self.loop_video = loop_video
|
||||||
self.show = show
|
self.show = show
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
|
self.max_hz = max_hz
|
||||||
|
self.prompt = prompt
|
||||||
self._streaming = False
|
self._streaming = False
|
||||||
self._should_quit = False
|
self._should_quit = False
|
||||||
self._partial = bytearray()
|
self._partial = bytearray()
|
||||||
self._lines = deque()
|
self._lines = deque()
|
||||||
self._discard_line = False
|
self._discard_line = False
|
||||||
|
|
||||||
|
# ------------------------------------------------------------------ io
|
||||||
|
|
||||||
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"
|
||||||
data = text.encode("utf-8")
|
data = text.encode("utf-8")
|
||||||
if self.ser.write(data) != len(data):
|
if self.ser.write(data) != len(data):
|
||||||
raise OSError("incomplete UART write")
|
raise OSError("incomplete UART write")
|
||||||
|
self.ser.flush()
|
||||||
if self.verbose:
|
if self.verbose:
|
||||||
print(f"<< {text.rstrip()}", file=sys.stderr, flush=True)
|
print(f"<< {text.rstrip()}", file=sys.stderr, flush=True)
|
||||||
|
|
||||||
|
def _maybe_prompt(self):
|
||||||
|
if self.prompt and not self._streaming:
|
||||||
|
self.reply(">")
|
||||||
|
|
||||||
def close_runtime(self):
|
def close_runtime(self):
|
||||||
self._streaming = False
|
self._streaming = False
|
||||||
try:
|
try:
|
||||||
@@ -62,7 +107,10 @@ class UartFingerController:
|
|||||||
|
|
||||||
def _poll_command_line(self, *, blocking=True):
|
def _poll_command_line(self, *, blocking=True):
|
||||||
if self._lines:
|
if self._lines:
|
||||||
return self._lines.popleft()
|
line = self._lines.popleft()
|
||||||
|
if self.verbose:
|
||||||
|
print(f">> {line}", file=sys.stderr, flush=True)
|
||||||
|
return line
|
||||||
waiting = self.ser.in_waiting
|
waiting = self.ser.in_waiting
|
||||||
if not waiting and not blocking:
|
if not waiting and not blocking:
|
||||||
return None
|
return None
|
||||||
@@ -82,7 +130,14 @@ class UartFingerController:
|
|||||||
if len(self._partial) > 1024:
|
if len(self._partial) > 1024:
|
||||||
self._partial.clear()
|
self._partial.clear()
|
||||||
self._discard_line = True
|
self._discard_line = True
|
||||||
return self._lines.popleft() if self._lines else None
|
if self._lines:
|
||||||
|
line = self._lines.popleft()
|
||||||
|
if self.verbose:
|
||||||
|
print(f">> {line}", file=sys.stderr, flush=True)
|
||||||
|
return line
|
||||||
|
return None
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- streaming
|
||||||
|
|
||||||
def _read_round(self):
|
def _read_round(self):
|
||||||
try:
|
try:
|
||||||
@@ -90,11 +145,13 @@ class UartFingerController:
|
|||||||
except EOFError as exc:
|
except EOFError as exc:
|
||||||
self._streaming = False
|
self._streaming = False
|
||||||
self.reply(f"OK eof {exc}")
|
self.reply(f"OK eof {exc}")
|
||||||
|
self._maybe_prompt()
|
||||||
return
|
return
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Acquisition round failed")
|
logger.exception("Acquisition round failed")
|
||||||
self._streaming = False
|
self._streaming = False
|
||||||
self.reply(f"ERR read {exc}")
|
self.reply(f"ERR read {exc}")
|
||||||
|
self._maybe_prompt()
|
||||||
return
|
return
|
||||||
for reading in readings:
|
for reading in readings:
|
||||||
self.reply(
|
self.reply(
|
||||||
@@ -127,75 +184,180 @@ class UartFingerController:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Sensor %s display failed", reading.sensor_id)
|
logger.exception("Sensor %s display failed", reading.sensor_id)
|
||||||
|
|
||||||
|
# ------------------------------------------------------------- dispatch
|
||||||
|
|
||||||
|
def _reply_hint(self, command: str):
|
||||||
|
close = difflib.get_close_matches(command, _KNOWN_COMMANDS, n=1)
|
||||||
|
suffix = f" (did you mean '{close[0]}'?)" if close else ""
|
||||||
|
self.reply(f"ERR unknown {command}{suffix}")
|
||||||
|
|
||||||
|
def _cmd_add(self, parts):
|
||||||
|
if self._streaming:
|
||||||
|
self.reply("ERR add stop streaming first")
|
||||||
|
return
|
||||||
|
parser = argparse.ArgumentParser(prog="add", add_help=False, exit_on_error=False)
|
||||||
|
parser.add_argument("source")
|
||||||
|
parser.add_argument("config", nargs="?")
|
||||||
|
parser.add_argument("--calibration")
|
||||||
|
try:
|
||||||
|
args = parser.parse_args(parts)
|
||||||
|
except (argparse.ArgumentError, SystemExit):
|
||||||
|
self.reply("ERR command expected add SOURCE [CONFIG] [--calibration PATH]")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
sensor_id = self.bank.add(
|
||||||
|
parse_source(args.source), config=args.config, cal_path=args.calibration,
|
||||||
|
)
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.reply(f"ERR add source not found: {args.source}")
|
||||||
|
except ValueError as exc:
|
||||||
|
self.reply(f"ERR add {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Sensor addition failed")
|
||||||
|
self.reply(
|
||||||
|
f"ERR add failed for {args.source}: {type(exc).__name__}: {exc}"
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
src = self.bank.sources[sensor_id]
|
||||||
|
kind = "file" if is_video_file(src) else "camera"
|
||||||
|
self.reply(f"OK added {sensor_id} {kind} {src}")
|
||||||
|
|
||||||
|
def _cmd_load(self, parts):
|
||||||
|
if len(parts) != 1:
|
||||||
|
self.reply("ERR command expected load SETUP.json")
|
||||||
|
return
|
||||||
|
if self._streaming:
|
||||||
|
self.reply("ERR load stop streaming first")
|
||||||
|
return
|
||||||
|
try:
|
||||||
|
sensor_ids = self.bank.add_setup(parts[0])
|
||||||
|
except FileNotFoundError:
|
||||||
|
self.reply(f"ERR load setup not found: {parts[0]}")
|
||||||
|
except ValueError as exc:
|
||||||
|
self.reply(f"ERR load {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Setup loading failed")
|
||||||
|
self.reply(f"ERR load failed: {type(exc).__name__}: {exc}")
|
||||||
|
else:
|
||||||
|
self.reply("OK loaded " + " ".join(map(str, sensor_ids)))
|
||||||
|
|
||||||
|
def _cmd_remove(self, parts):
|
||||||
|
if self._streaming:
|
||||||
|
self.reply("ERR remove stop streaming first")
|
||||||
|
return
|
||||||
|
if len(parts) != 1 or not parts[0].isdigit():
|
||||||
|
self.reply("ERR command expected remove SENSOR_ID")
|
||||||
|
return
|
||||||
|
sensor_id = int(parts[0])
|
||||||
|
try:
|
||||||
|
self.bank.remove_sensor(sensor_id)
|
||||||
|
except ValueError as exc:
|
||||||
|
self.reply(f"ERR remove {exc}")
|
||||||
|
except Exception as exc:
|
||||||
|
logger.exception("Sensor removal failed")
|
||||||
|
self.reply(f"ERR remove failed: {type(exc).__name__}: {exc}")
|
||||||
|
else:
|
||||||
|
self.reply(f"OK removed {sensor_id}")
|
||||||
|
|
||||||
|
def _cmd_list(self):
|
||||||
|
for info in self.bank.list_sensors():
|
||||||
|
cal = info.calibration or "-"
|
||||||
|
self.reply(
|
||||||
|
f"sensor,{info.sensor_id},{info.kind},{info.source},{info.config},{cal}"
|
||||||
|
)
|
||||||
|
self.reply(f"OK list {len(self.bank.sources)}")
|
||||||
|
|
||||||
|
def _cmd_status(self):
|
||||||
|
self.reply(
|
||||||
|
f"OK status sensors={len(self.bank.sources)} "
|
||||||
|
f"streaming={int(self._streaming)} "
|
||||||
|
f"loop_video={int(self.loop_video)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
def _cmd_help(self):
|
||||||
|
self.reply("OK help " + " | ".join(_HELP_LINES))
|
||||||
|
|
||||||
|
def _cmd_start(self, parts):
|
||||||
|
if parts:
|
||||||
|
self.reply("ERR command start takes no arguments")
|
||||||
|
return
|
||||||
|
if not self.bank.runtimes:
|
||||||
|
self.reply("ERR start no sensors added")
|
||||||
|
return
|
||||||
|
self._streaming = True
|
||||||
|
self.reply("OK start")
|
||||||
|
|
||||||
|
def _cmd_stop(self, parts):
|
||||||
|
if parts:
|
||||||
|
self.reply("ERR command stop takes no arguments")
|
||||||
|
return
|
||||||
|
self._streaming = False
|
||||||
|
self.reply("OK stop")
|
||||||
|
self._maybe_prompt()
|
||||||
|
|
||||||
|
def _cmd_read(self, parts):
|
||||||
|
if parts:
|
||||||
|
self.reply("ERR command read takes no arguments")
|
||||||
|
return
|
||||||
|
if not self.bank.runtimes:
|
||||||
|
self.reply("ERR read no sensors added")
|
||||||
|
return
|
||||||
|
self._read_round()
|
||||||
|
|
||||||
|
def _cmd_quit(self, parts):
|
||||||
|
if parts:
|
||||||
|
self.reply("ERR command quit takes no arguments")
|
||||||
|
return
|
||||||
|
self._streaming = False
|
||||||
|
self._should_quit = True
|
||||||
|
self.reply("OK quit")
|
||||||
|
|
||||||
def _dispatch(self, line):
|
def _dispatch(self, line):
|
||||||
try:
|
try:
|
||||||
parts = shlex.split(line)
|
parts = shlex.split(line)
|
||||||
except ValueError as exc:
|
except ValueError as exc:
|
||||||
self.reply(f"ERR command {exc}")
|
self.reply(f"ERR command {exc}")
|
||||||
|
self._maybe_prompt()
|
||||||
return True
|
return True
|
||||||
if not parts:
|
if not parts:
|
||||||
|
self._maybe_prompt()
|
||||||
return True
|
return True
|
||||||
command = parts[0].lower()
|
command = parts[0].lower()
|
||||||
|
args = parts[1:]
|
||||||
|
|
||||||
if command == "add":
|
if command == "add":
|
||||||
if self._streaming:
|
self._cmd_add(args)
|
||||||
self.reply("ERR add stop streaming first")
|
|
||||||
else:
|
|
||||||
parser = argparse.ArgumentParser(prog="add", add_help=False, exit_on_error=False)
|
|
||||||
parser.add_argument("source")
|
|
||||||
parser.add_argument("config", nargs="?")
|
|
||||||
parser.add_argument("--calibration")
|
|
||||||
try:
|
|
||||||
args = parser.parse_args(parts[1:])
|
|
||||||
except (argparse.ArgumentError, SystemExit):
|
|
||||||
self.reply("ERR command expected add SOURCE [CONFIG] [--calibration PATH]")
|
|
||||||
return True
|
|
||||||
try:
|
|
||||||
sensor_id = self.bank.add(
|
|
||||||
parse_source(args.source), config=args.config, cal_path=args.calibration,
|
|
||||||
)
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Sensor addition failed")
|
|
||||||
self.reply(f"ERR add {exc}")
|
|
||||||
else:
|
|
||||||
self.reply(f"OK added {sensor_id}")
|
|
||||||
elif command == "load":
|
elif command == "load":
|
||||||
if len(parts) != 2:
|
self._cmd_load(args)
|
||||||
self.reply("ERR command expected load SETUP.json")
|
elif command == "remove":
|
||||||
elif self._streaming:
|
self._cmd_remove(args)
|
||||||
self.reply("ERR load stop streaming first")
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
sensor_ids = self.bank.add_setup(parts[1])
|
|
||||||
except Exception as exc:
|
|
||||||
logger.exception("Setup loading failed")
|
|
||||||
self.reply(f"ERR load {exc}")
|
|
||||||
else:
|
|
||||||
self.reply("OK loaded " + " ".join(map(str, sensor_ids)))
|
|
||||||
elif command not in {"start", "stop", "read", "quit"}:
|
|
||||||
self.reply(f"ERR unknown {command}")
|
|
||||||
elif len(parts) != 1:
|
|
||||||
self.reply("ERR command start|stop|read|quit take no arguments")
|
|
||||||
elif command == "start":
|
elif command == "start":
|
||||||
if not self.bank.runtimes:
|
self._cmd_start(args)
|
||||||
self.reply("ERR start no sensors added")
|
|
||||||
else:
|
|
||||||
self._streaming = True
|
|
||||||
self.reply("OK start")
|
|
||||||
elif command == "stop":
|
elif command == "stop":
|
||||||
self._streaming = False
|
self._cmd_stop(args)
|
||||||
self.reply("OK stop")
|
|
||||||
elif command == "read":
|
elif command == "read":
|
||||||
self._read_round()
|
self._cmd_read(args)
|
||||||
elif command == "quit":
|
elif command in ("quit", "exit"):
|
||||||
self._streaming = False
|
self._cmd_quit(args)
|
||||||
self._should_quit = True
|
elif command == "list":
|
||||||
self.reply("OK quit")
|
self._cmd_list()
|
||||||
|
elif command == "status":
|
||||||
|
self._cmd_status()
|
||||||
|
elif command in ("help", "?"):
|
||||||
|
self._cmd_help()
|
||||||
|
else:
|
||||||
|
self._reply_hint(command)
|
||||||
|
|
||||||
|
self._maybe_prompt()
|
||||||
return not self._should_quit
|
return not self._should_quit
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------- serve
|
||||||
|
|
||||||
def serve(self, *, start=False):
|
def serve(self, *, start=False):
|
||||||
if start and not self.bank.runtimes:
|
if start and not self.bank.runtimes:
|
||||||
raise RuntimeError("no sensors added")
|
raise RuntimeError("no sensors added")
|
||||||
self.reply(f"OK ready {len(self.bank.sources)}")
|
self.reply(f"OK ready {len(self.bank.sources)}")
|
||||||
|
self._maybe_prompt()
|
||||||
self._streaming = start
|
self._streaming = start
|
||||||
while not self._should_quit:
|
while not self._should_quit:
|
||||||
for _ in range(16):
|
for _ in range(16):
|
||||||
@@ -205,7 +367,13 @@ class UartFingerController:
|
|||||||
if not self._dispatch(line):
|
if not self._dispatch(line):
|
||||||
return
|
return
|
||||||
if self._streaming:
|
if self._streaming:
|
||||||
|
t0 = time.monotonic()
|
||||||
self._read_round()
|
self._read_round()
|
||||||
|
if self.max_hz > 0 and self._streaming:
|
||||||
|
budget = 1.0 / self.max_hz - (time.monotonic() - t0)
|
||||||
|
if budget > 0:
|
||||||
|
time.sleep(budget)
|
||||||
|
|
||||||
|
|
||||||
def open_serial(port, baud, timeout, write_timeout=1.0):
|
def open_serial(port, baud, timeout, write_timeout=1.0):
|
||||||
try:
|
try:
|
||||||
@@ -220,7 +388,9 @@ def open_serial(port, baud, timeout, write_timeout=1.0):
|
|||||||
|
|
||||||
|
|
||||||
def main(argv=None):
|
def main(argv=None):
|
||||||
parser = argparse.ArgumentParser(description="Manual UART finger service: add, start, stop, read, quit")
|
parser = argparse.ArgumentParser(
|
||||||
|
description="Manual UART finger service: add, load, remove, start, stop, read, list, status, quit"
|
||||||
|
)
|
||||||
parser.add_argument("--port", default="/dev/ttyHS1")
|
parser.add_argument("--port", default="/dev/ttyHS1")
|
||||||
parser.add_argument("--baud", type=int, default=115200)
|
parser.add_argument("--baud", type=int, default=115200)
|
||||||
parser.add_argument("--timeout", type=float, default=0.1, help="idle read timeout in seconds")
|
parser.add_argument("--timeout", type=float, default=0.1, help="idle read timeout in seconds")
|
||||||
@@ -231,6 +401,14 @@ def main(argv=None):
|
|||||||
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(
|
||||||
|
"--max-hz", type=float, default=0.0,
|
||||||
|
help="cap force rounds per second while streaming (0 = unlimited)",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--prompt", action="store_true",
|
||||||
|
help="emit a '>' line before each idle command wait (off by default)",
|
||||||
|
)
|
||||||
args = parser.parse_args(argv)
|
args = parser.parse_args(argv)
|
||||||
if args.start and not args.setup:
|
if args.start and not args.setup:
|
||||||
parser.error("--start requires --setup")
|
parser.error("--start requires --setup")
|
||||||
@@ -238,9 +416,11 @@ def main(argv=None):
|
|||||||
parser.error("baud must be positive")
|
parser.error("baud must be positive")
|
||||||
if any(not math.isfinite(t) or t <= 0 for t in (args.timeout, args.write_timeout)):
|
if any(not math.isfinite(t) or t <= 0 for t in (args.timeout, args.write_timeout)):
|
||||||
parser.error("timeouts must be finite and positive")
|
parser.error("timeouts must be finite and positive")
|
||||||
|
if not math.isfinite(args.max_hz) or args.max_hz < 0:
|
||||||
|
parser.error("--max-hz must be finite and non-negative")
|
||||||
controller = UartFingerController(
|
controller = UartFingerController(
|
||||||
None, config=args.config, loop_video=args.loop_video,
|
None, config=args.config, loop_video=args.loop_video,
|
||||||
show=args.show, verbose=args.verbose,
|
show=args.show, verbose=args.verbose, max_hz=args.max_hz, prompt=args.prompt,
|
||||||
)
|
)
|
||||||
ser = None
|
ser = None
|
||||||
result = 0
|
result = 0
|
||||||
@@ -249,7 +429,11 @@ def main(argv=None):
|
|||||||
controller.bank.add_setup(args.setup)
|
controller.bank.add_setup(args.setup)
|
||||||
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}; {len(controller.bank.runtimes)} sensors ready", file=sys.stderr, flush=True)
|
print(
|
||||||
|
f"UART service on {args.port} @ {args.baud}; "
|
||||||
|
f"{len(controller.bank.runtimes)} sensors ready",
|
||||||
|
file=sys.stderr, flush=True,
|
||||||
|
)
|
||||||
controller.serve(start=args.start)
|
controller.serve(start=args.start)
|
||||||
except KeyboardInterrupt:
|
except KeyboardInterrupt:
|
||||||
print("Interrupted.", file=sys.stderr)
|
print("Interrupted.", file=sys.stderr)
|
||||||
|
|||||||
+51
-20
@@ -1,8 +1,10 @@
|
|||||||
"""Single-threaded finger acquisition API, independent of UART and the CLI.
|
"""Single-threaded finger acquisition API, independent of UART and the CLI.
|
||||||
|
|
||||||
Sensor IDs are 1-based, assigned after each successful add(). Calls and runtime
|
Sensor IDs are 1-based, assigned once per successful add() and never reused
|
||||||
ownership belong to one thread; close the bank in a finally block. Values use the
|
within a process, so a host that caches IDs stays correct across removals.
|
||||||
SDK's FNORMAL/FSHEARX/FSHEARY axes and calibration units, not assumed Newtons.
|
Calls and runtime ownership belong to one thread; close the bank in a finally
|
||||||
|
block. Values use the SDK's FNORMAL/FSHEARX/FSHEARY axes and calibration
|
||||||
|
units, not assumed Newtons.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
@@ -40,20 +42,31 @@ class ForceReading:
|
|||||||
image: object
|
image: object
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True)
|
||||||
|
class SensorInfo:
|
||||||
|
sensor_id: int
|
||||||
|
source: Source
|
||||||
|
kind: str # "camera" or "file"
|
||||||
|
config: str
|
||||||
|
calibration: str | None
|
||||||
|
|
||||||
|
|
||||||
class FingerSensorBank:
|
class FingerSensorBank:
|
||||||
"""Own up to four independently added runtimes; add() precedes read_all().
|
"""Own up to four independently added runtimes; add() precedes read_all().
|
||||||
|
|
||||||
Processing/connection failures raise exceptions; they never become zero force.
|
Processing/connection failures raise exceptions; they never become zero
|
||||||
A shared config can be reused, or each source can have its own config.
|
force. A shared config can be reused, or each source can have its own
|
||||||
|
config.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, *, config="finger", verbose=False):
|
def __init__(self, *, config="finger", verbose=False):
|
||||||
self.default_config = config
|
self.default_config = config
|
||||||
self.sources = {}
|
self.sources: dict[int, Source] = {}
|
||||||
self.configs = {}
|
self.configs: dict[int, str] = {}
|
||||||
self.runtimes = {}
|
self.runtimes: dict[int, object] = {}
|
||||||
self.calibration_paths = {}
|
self.calibration_paths: dict[int, str | None] = {}
|
||||||
self.verbose = verbose
|
self.verbose = verbose
|
||||||
|
self._next_id = 1
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _source_identity(source):
|
def _source_identity(source):
|
||||||
@@ -101,7 +114,8 @@ class FingerSensorBank:
|
|||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("New sensor cleanup failed")
|
logger.exception("New sensor cleanup failed")
|
||||||
raise
|
raise
|
||||||
sensor_id = len(self.runtimes) + 1
|
sensor_id = self._next_id
|
||||||
|
self._next_id += 1
|
||||||
self.sources[sensor_id] = source
|
self.sources[sensor_id] = source
|
||||||
self.configs[sensor_id] = config
|
self.configs[sensor_id] = config
|
||||||
self.runtimes[sensor_id] = runtime
|
self.runtimes[sensor_id] = runtime
|
||||||
@@ -157,12 +171,35 @@ class FingerSensorBank:
|
|||||||
except BaseException:
|
except BaseException:
|
||||||
for sensor_id in added:
|
for sensor_id in added:
|
||||||
try:
|
try:
|
||||||
self._close_one(sensor_id)
|
self.remove_sensor(sensor_id)
|
||||||
except Exception:
|
except Exception:
|
||||||
logger.exception("Setup rollback failed for sensor %s", sensor_id)
|
logger.exception("Setup rollback failed for sensor %s", sensor_id)
|
||||||
raise
|
raise
|
||||||
return tuple(added)
|
return tuple(added)
|
||||||
|
|
||||||
|
def remove_sensor(self, sensor_id: int) -> None:
|
||||||
|
"""Close and forget one sensor. Its ID is never reused."""
|
||||||
|
if sensor_id not in self.runtimes:
|
||||||
|
raise ValueError(f"no sensor {sensor_id}")
|
||||||
|
runtime = self.runtimes.pop(sensor_id)
|
||||||
|
self.sources.pop(sensor_id, None)
|
||||||
|
self.configs.pop(sensor_id, None)
|
||||||
|
self.calibration_paths.pop(sensor_id, None)
|
||||||
|
runtime.close()
|
||||||
|
|
||||||
|
def list_sensors(self) -> tuple[SensorInfo, ...]:
|
||||||
|
"""Snapshot of currently registered sensors, in ID order."""
|
||||||
|
return tuple(
|
||||||
|
SensorInfo(
|
||||||
|
sensor_id=sid,
|
||||||
|
source=self.sources[sid],
|
||||||
|
kind="file" if is_video_file(self.sources[sid]) else "camera",
|
||||||
|
config=self.configs.get(sid, "-"),
|
||||||
|
calibration=self.calibration_paths.get(sid),
|
||||||
|
)
|
||||||
|
for sid in sorted(self.runtimes)
|
||||||
|
)
|
||||||
|
|
||||||
def read_all(self, *, loop_video=False) -> tuple[ForceReading, ...]:
|
def read_all(self, *, loop_video=False) -> tuple[ForceReading, ...]:
|
||||||
"""Read a complete round. EOF/failure raises; no partial round is returned.
|
"""Read a complete round. EOF/failure raises; no partial round is returned.
|
||||||
|
|
||||||
@@ -171,7 +208,7 @@ class FingerSensorBank:
|
|||||||
if not self.runtimes:
|
if not self.runtimes:
|
||||||
raise RuntimeError("no sensors added")
|
raise RuntimeError("no sensors added")
|
||||||
readings = []
|
readings = []
|
||||||
for sensor_id in self.sources:
|
for sensor_id in sorted(self.runtimes):
|
||||||
try:
|
try:
|
||||||
reading = self._read(sensor_id, loop_video=loop_video)
|
reading = self._read(sensor_id, loop_video=loop_video)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
@@ -206,22 +243,16 @@ 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 _close_one(self, sensor_id):
|
|
||||||
runtime = self.runtimes.pop(sensor_id)
|
|
||||||
self.sources.pop(sensor_id)
|
|
||||||
self.configs.pop(sensor_id)
|
|
||||||
self.calibration_paths.pop(sensor_id)
|
|
||||||
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._close_one(sensor_id)
|
self.remove_sensor(sensor_id)
|
||||||
except Exception as exc:
|
except Exception as exc:
|
||||||
logger.exception("Sensor %s cleanup failed", sensor_id)
|
logger.exception("Sensor %s cleanup failed", sensor_id)
|
||||||
first_error = first_error or exc
|
first_error = first_error or exc
|
||||||
self.sources.clear()
|
self.sources.clear()
|
||||||
self.configs.clear()
|
self.configs.clear()
|
||||||
|
self.calibration_paths.clear()
|
||||||
if first_error is not None:
|
if first_error is not None:
|
||||||
raise first_error
|
raise first_error
|
||||||
|
|||||||
Reference in New Issue
Block a user