457 lines
16 KiB
Python
457 lines
16 KiB
Python
#!/usr/bin/env python3
|
|
"""Headless UART finger service for 1-4 sensors.
|
|
|
|
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. 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
|
|
|
|
import argparse
|
|
import difflib
|
|
import logging
|
|
import math
|
|
import os
|
|
import shlex
|
|
import sys
|
|
import time
|
|
from collections import deque
|
|
|
|
os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0")
|
|
|
|
from finger_api import FingerSensorBank, is_video_file, parse_source
|
|
|
|
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:
|
|
"""Single-threaded command service; the caller owns serial and final cleanup."""
|
|
|
|
def __init__(
|
|
self, ser, *, config="finger", loop_video=True,
|
|
show=False, verbose=False, max_hz=0.0, prompt=False,
|
|
):
|
|
self.ser = ser
|
|
self.bank = FingerSensorBank(config=config, verbose=verbose)
|
|
self.loop_video = loop_video
|
|
self.show = show
|
|
self.verbose = verbose
|
|
self.max_hz = max_hz
|
|
self.prompt = prompt
|
|
self._streaming = False
|
|
self._should_quit = False
|
|
self._partial = bytearray()
|
|
self._lines = deque()
|
|
self._discard_line = False
|
|
|
|
# ------------------------------------------------------------------ io
|
|
|
|
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")
|
|
self.ser.flush()
|
|
if self.verbose:
|
|
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):
|
|
self._streaming = False
|
|
try:
|
|
self.bank.close()
|
|
finally:
|
|
if self.show:
|
|
import cv2
|
|
|
|
cv2.destroyAllWindows()
|
|
|
|
def _poll_command_line(self, *, blocking=True):
|
|
if self._lines:
|
|
line = self._lines.popleft()
|
|
if self.verbose:
|
|
print(f">> {line}", file=sys.stderr, flush=True)
|
|
return line
|
|
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)
|
|
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
|
|
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):
|
|
try:
|
|
readings = self.bank.read_all(loop_video=self.loop_video)
|
|
except EOFError as exc:
|
|
self._streaming = False
|
|
self.reply(f"OK eof {exc}")
|
|
self._maybe_prompt()
|
|
return
|
|
except Exception as exc:
|
|
logger.exception("Acquisition round failed")
|
|
self._streaming = False
|
|
self.reply(f"ERR read {exc}")
|
|
self._maybe_prompt()
|
|
return
|
|
for reading in readings:
|
|
self.reply(
|
|
f"force,{reading.sensor_id},{reading.normal:.6f},"
|
|
f"{reading.shear_x:.6f},{reading.shear_y:.6f}"
|
|
)
|
|
if self.show:
|
|
self._show(reading)
|
|
if self._should_quit:
|
|
return
|
|
|
|
def _show(self, reading):
|
|
import cv2
|
|
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._streaming = False
|
|
self._should_quit = True
|
|
self.reply("OK quit")
|
|
elif key == ord("r"):
|
|
tactile.reset()
|
|
except Exception:
|
|
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):
|
|
try:
|
|
parts = shlex.split(line)
|
|
except ValueError as exc:
|
|
self.reply(f"ERR command {exc}")
|
|
self._maybe_prompt()
|
|
return True
|
|
if not parts:
|
|
self._maybe_prompt()
|
|
return True
|
|
command = parts[0].lower()
|
|
args = parts[1:]
|
|
|
|
if command == "add":
|
|
self._cmd_add(args)
|
|
elif command == "load":
|
|
self._cmd_load(args)
|
|
elif command == "remove":
|
|
self._cmd_remove(args)
|
|
elif command == "start":
|
|
self._cmd_start(args)
|
|
elif command == "stop":
|
|
self._cmd_stop(args)
|
|
elif command == "read":
|
|
self._cmd_read(args)
|
|
elif command in ("quit", "exit"):
|
|
self._cmd_quit(args)
|
|
elif command == "list":
|
|
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
|
|
|
|
# ---------------------------------------------------------------- serve
|
|
|
|
def serve(self, *, start=False):
|
|
if start and not self.bank.runtimes:
|
|
raise RuntimeError("no sensors added")
|
|
self.reply(f"OK ready {len(self.bank.sources)}")
|
|
self._maybe_prompt()
|
|
self._streaming = start
|
|
while not self._should_quit:
|
|
for _ in range(16):
|
|
line = self._poll_command_line(blocking=not self._streaming)
|
|
if line is None:
|
|
break
|
|
if not self._dispatch(line):
|
|
return
|
|
if self._streaming:
|
|
t0 = time.monotonic()
|
|
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):
|
|
try:
|
|
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=None):
|
|
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("--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", default="finger", help="default config for add commands")
|
|
parser.add_argument("--setup", help="JSON file listing sources, configs and calibrations to connect")
|
|
parser.add_argument("--start", action="store_true", help="start output after loading --setup")
|
|
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(
|
|
"--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)
|
|
if args.start and not args.setup:
|
|
parser.error("--start requires --setup")
|
|
if args.baud <= 0:
|
|
parser.error("baud must be positive")
|
|
if any(not math.isfinite(t) or t <= 0 for t in (args.timeout, args.write_timeout)):
|
|
parser.error("timeouts must be finite and positive")
|
|
if not math.isfinite(args.max_hz) or args.max_hz < 0:
|
|
parser.error("--max-hz must be finite and non-negative")
|
|
controller = UartFingerController(
|
|
None, config=args.config, loop_video=args.loop_video,
|
|
show=args.show, verbose=args.verbose, max_hz=args.max_hz, prompt=args.prompt,
|
|
)
|
|
ser = None
|
|
result = 0
|
|
try:
|
|
if args.setup:
|
|
controller.bank.add_setup(args.setup)
|
|
ser = open_serial(args.port, args.baud, args.timeout, args.write_timeout)
|
|
controller.ser = ser
|
|
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)
|
|
except KeyboardInterrupt:
|
|
print("Interrupted.", file=sys.stderr)
|
|
except Exception:
|
|
logger.exception("UART service failed on %s", args.port)
|
|
result = 1
|
|
finally:
|
|
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__":
|
|
raise SystemExit(main())
|