#!/usr/bin/env python3 """Headless UART finger service for 1-4 sensors; see ../docs/uart.md. The login console and data UART must be separate ports. Every configured camera is prepared before UART output. All force records include a 1-based sensor ID. Importing this module opens no devices; acquisition is owned by finger_api. """ from __future__ import annotations import argparse import logging import math import os import sys from collections import deque os.environ.setdefault("OPENCV_VIDEOIO_MSMF_ENABLE_HW_TRANSFORMS", "0") from finger_api import FingerSensorBank, parse_source logger = logging.getLogger(__name__) class UartFingerController: """Single-threaded command service; the caller owns serial and final cleanup.""" def __init__( self, ser, *, sources, configs=("finger",), loop_video=True, show=False, verbose=False, read_only=False, ): self.ser = ser self.bank = FingerSensorBank(sources, configs=configs, verbose=verbose) self.loop_video = loop_video self.show = show self.verbose = verbose self.read_only = read_only self._streaming = False self._should_quit = False self._partial = bytearray() self._lines = deque() self._discard_line = False 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()}", file=sys.stderr, flush=True) 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: 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 _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}") return except Exception as exc: logger.exception("Acquisition round failed") self._streaming = False self.reply(f"ERR read {exc}") 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) def _dispatch(self, line): if self.read_only: return True parts = line.split() if not parts: return True command = parts[0].lower() if 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": self._streaming = True self.reply("OK start") elif command == "stop": self._streaming = False self.reply("OK stop") elif command == "read": self._read_round() elif command == "quit": self._streaming = False self._should_quit = True self.reply("OK quit") return not self._should_quit def serve(self): if set(self.bank.runtimes) != set(self.bank.sources): raise RuntimeError("connect all configured cameras before serving UART") self.reply(f"OK ready {len(self.bank.sources)}") self._streaming = True 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: self._read_round() 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="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("--sources", required=True, nargs="+", help="1-4 camera indices, device paths or video files") 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("--read-only", action="store_true", help="ignore all UART commands; exit from the login console") args = parser.parse_args(argv) 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") configs = args.config or ["finger"] try: sources = [parse_source(s) for s in args.sources] controller = UartFingerController( None, sources=sources, configs=configs, loop_video=args.loop_video, show=args.show, verbose=args.verbose, read_only=args.read_only, ) except ValueError as exc: parser.error(str(exc)) ser = None result = 0 try: for sensor_id, source in controller.bank.sources.items(): print( f"Preparing sensor {sensor_id}: source={source} config={controller.bank.configs[sensor_id]}", file=sys.stderr, flush=True, ) controller.bank.connect_all() ser = open_serial(args.port, args.baud, args.timeout, args.write_timeout) controller.ser = ser print(f"UART service on {args.port} @ {args.baud}; sources={sources}", file=sys.stderr, flush=True) controller.serve() 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())