QUELLCODE / PY
driver.py
src/hardware_box/hid/driver.py
1# OpenAss: nur privater, experimenteller Gebrauch; auf eigene Verantwortung.2# Niemals Unternehmensrechner, Produktivsysteme, Industrieanlagen oder3# sicherheitskritische Systeme anschließen oder bedienen.4# Haftungshinweis und gesetzliche Grenzen: ../../../DISCLAIMER.txt.5"""Fail-closed serial HID driver and a reviewable, output-free dry-run driver.67Pico UART0 uses GP0/GP1; Pi UART uses GPIO14/15 (header pins 8/10), at 1152008baud with 3.3 V logic and shared ground. The Pico's9USB goes only to the target; it is not the serial control channel. ARM also10requires the firmware's physical local grant. No action is automatically11retried after an uncertain acknowledgement. A failed link ceases PING so the12firmware's 750 ms lease releases all keys/buttons. Recovery is an explicit13start() followed by a new physical grant and arm().14"""1516import asyncio17from collections.abc import Callable, Mapping18import logging19import threading20import time21from typing import Any2223from .keyboard import KeyboardLayout, KeyStroke24from .mapping import Geometry25from .protocol import MAX_FRAME_BYTES, MAX_SEQUENCE, ProtocolError, decode_frame, encode_frame, require_ok2627_LOG = logging.getLogger(__name__)28_BUTTONS = {"left": 1, "right": 2, "middle": 4}29_POINTER_TYPES = {"move", "click", "double_click", "drag", "scroll"}30_ARMED_COMMANDS = {"ABS", "REL", "KEY", "RELEASE", "PING"}313233class HIDError(RuntimeError):34 pass353637def _int(value: object, name: str, minimum: int, maximum: int) -> int:38 if type(value) is not int or not minimum <= value <= maximum:39 raise ValueError(f"{name} must be an integer in {minimum}..{maximum}")40 return value414243def validate_action(action: Mapping[str, Any], geometry: Geometry | None, keyboard_layout: str = "us") -> dict[str, Any]:44 """Pure full-action preflight; call before approvals and before any output.4546 Policy on sensitive operations belongs to the safety controller. These47 limits additionally bound individual HID operations and reject unsupported48 text even if its valid prefix could otherwise have already been typed.49 """50 if not isinstance(action, Mapping) or not isinstance(action.get("type"), str):51 raise ValueError("action must contain a string type")52 kind = action["type"]53 if kind == "doubleclick":54 kind = "double_click"55 result: dict[str, Any] = {"type": kind}56 keys_by_type = {57 "move": {"x", "y"}, "click": {"x", "y", "button"},58 "double_click": {"x", "y", "button"},59 "drag": {"path", "x1", "y1", "x2", "y2"},60 "scroll": {"x", "y", "scroll_x", "scroll_y"},61 "type": {"text"}, "keypress": {"keys", "key"},62 "wait": {"milliseconds"}, "screenshot": set(),63 }64 if kind not in keys_by_type:65 raise ValueError(f"unsupported action: {kind}")66 if set(action) - ({"type"} | keys_by_type[kind]):67 raise ValueError("unknown action fields")68 if kind in _POINTER_TYPES:69 if not isinstance(geometry, Geometry):70 raise ValueError("pointer actions require calibrated capture geometry")7172 def point(x: object, y: object) -> dict[str, int]:73 # Geometry also rejects non-int, non-finite, out-of-image and bar points.74 assert geometry is not None75 geometry.to_hid(x, y)76 return {"x": x, "y": y} # type: ignore[dict-item]7778 if kind in {"move", "click", "double_click", "scroll"}:79 result.update(point(action.get("x"), action.get("y")))80 if kind in {"click", "double_click"}:81 button = action.get("button", "left")82 if not isinstance(button, str) or button not in _BUTTONS:83 raise ValueError("button must be left, right or middle")84 result["button"] = button85 if kind == "drag":86 if "path" in action and any(k in action for k in ("x1", "y1", "x2", "y2")):87 raise ValueError("drag must use either path or endpoint coordinates")88 path = action.get("path")89 if path is None:90 path = [point(action.get("x1"), action.get("y1")), point(action.get("x2"), action.get("y2"))]91 if not isinstance(path, (list, tuple)) or not 2 <= len(path) <= 256:92 raise ValueError("drag path must have 2..256 points")93 points = []94 for item in path:95 if not isinstance(item, Mapping) or set(item) != {"x", "y"}:96 raise ValueError("drag points must contain only x and y")97 points.append(point(item["x"], item["y"]))98 result["path"] = points99 if kind == "scroll":100 result["scroll_x"] = _int(action.get("scroll_x", 0), "scroll_x", -12000, 12000)101 result["scroll_y"] = _int(action.get("scroll_y", 0), "scroll_y", -12000, 12000)102 layout = KeyboardLayout(keyboard_layout)103 if kind == "type":104 text = action.get("text")105 if not isinstance(text, str) or len(text) > 4096:106 raise ValueError("text must contain at most 4096 characters")107 layout.preflight_text(text)108 result["text"] = text109 if kind == "keypress":110 if "key" in action and "keys" in action:111 raise ValueError("keypress must use either key or keys")112 keys = action.get("keys", action.get("key"))113 layout.keypress(keys)114 result["keys"] = keys if isinstance(keys, str) else list(keys)115 if kind == "wait":116 result["milliseconds"] = _int(action.get("milliseconds", 1000), "milliseconds", 0, 5000)117 return result118119120class _SerialLink:121 """One outstanding request at a time, including heartbeat and emergency stop."""122123 def __init__(self, serial: Any, acknowledgement_timeout: float) -> None:124 self.serial = serial125 self.timeout = acknowledgement_timeout126 self.lock = threading.Lock()127 self.sequence = 0128129 def request(self, operation: str, arguments: tuple[int, ...] = ()) -> tuple[str, ...]:130 with self.lock:131 self.sequence = self.sequence % MAX_SEQUENCE + 1132 sequence = self.sequence133 message = encode_frame(sequence, operation, arguments)134 deadline = time.monotonic() + self.timeout135 try:136 written = self.serial.write(message)137 if written != len(message):138 raise ProtocolError("incomplete serial write")139 # No flush(): some platforms can block indefinitely draining a140 # disconnected UART. write_timeout already bounds submission.141 remaining = deadline - time.monotonic()142 if remaining <= 0:143 raise ProtocolError("acknowledgement timed out")144 self.serial.timeout = remaining145 raw = self.serial.read_until(b"\n", MAX_FRAME_BYTES + 1)146 if time.monotonic() > deadline:147 raise ProtocolError("acknowledgement timed out")148 return require_ok(decode_frame(raw), sequence)149 except ProtocolError:150 raise151 except Exception as exc:152 raise ProtocolError(f"serial request failed: {type(exc).__name__}") from exc153154155class _Actions:156 """Common absolute-pointer executor; relative positioning is not exposed."""157158 keyboard: KeyboardLayout159 _armed: bool160 _fault: str | None161 _scroll_residual_x: int162 _scroll_residual_y: int163164 def preflight(self, action: Mapping[str, Any], geometry: Geometry | None = None) -> dict[str, Any]:165 return validate_action(action, geometry, self.keyboard.name)166167 async def _request(self, operation: str, arguments: tuple[int, ...] = (), *, expected_generation: int | None = None) -> tuple[str, ...]:168 raise NotImplementedError169170 async def _report(self, operation: str, arguments: tuple[int, ...] = ()) -> None:171 if not self._armed or self._fault or self._execution_generation != self._stop_generation:172 raise HIDError("HID is disarmed, faulted or this action was stopped")173 await self._request(operation, arguments, expected_generation=self._execution_generation)174175 async def _release(self) -> None:176 # Faults disarm and STOP handles release; no action may resume after it.177 if self._armed and not self._fault and self._execution_generation == self._stop_generation:178 await self._report("RELEASE")179180 async def _stroke(self, stroke: KeyStroke) -> None:181 try:182 await self._report("KEY", stroke.report_arguments())183 await asyncio.sleep(0.015)184 finally:185 await self._release()186187 async def execute(self, action: Mapping[str, Any], geometry: Geometry | None = None) -> None:188 canonical = self.preflight(action, geometry)189 submitted_generation = self._stop_generation190 async with self._action_lock:191 if not self._armed or self._fault or submitted_generation != self._stop_generation:192 raise HIDError("HID is disarmed, faulted or the queued action was stopped")193 self._execution_generation = self._stop_generation194 kind = canonical["type"]195 if kind in {"wait", "screenshot"}:196 if kind == "wait":197 await asyncio.sleep(canonical["milliseconds"] / 1000)198 return199 if kind in {"move", "click", "double_click", "scroll"}:200 assert geometry is not None201 hx, hy = geometry.to_hid(canonical["x"], canonical["y"])202 await self._report("ABS", (hx, hy, 0))203 if kind == "move":204 return205 if kind in {"click", "double_click"}:206 for index in range(2 if kind == "double_click" else 1):207 if index:208 await asyncio.sleep(0.12)209 try:210 await self._report("ABS", (hx, hy, _BUTTONS[canonical["button"]]))211 await asyncio.sleep(0.02)212 finally:213 await self._release()214 elif kind == "drag":215 assert geometry is not None216 points = [geometry.to_hid(p["x"], p["y"]) for p in canonical["path"]]217 await self._report("ABS", (*points[0], 0))218 try:219 for point in points:220 await self._report("ABS", (*point, 1))221 await asyncio.sleep(0.015)222 finally:223 await self._release()224 elif kind == "scroll":225 # Computer Use scroll units are pixels; HID wheel is detents.226 # Preserve small remainders instead of converting 1 px to 1227 # notch. 120 px/notch is a conservative configurable-by-code228 # baseline, not an OS-independent pixel-distance guarantee.229 sx = self._scroll_residual_x + canonical["scroll_x"]230 sy = self._scroll_residual_y + canonical["scroll_y"]231 pan = abs(sx) // 120 * (1 if sx >= 0 else -1)232 down = abs(sy) // 120 * (1 if sy >= 0 else -1)233 self._scroll_residual_x, self._scroll_residual_y = sx - pan * 120, sy - down * 120234 if pan or down:235 await self._report("REL", (0, 0, -down, pan, 0))236 elif kind == "type":237 for stroke in self.keyboard.preflight_text(canonical["text"]):238 await self._stroke(stroke)239 elif kind == "keypress":240 await self._stroke(self.keyboard.keypress(canonical["keys"]))241242243class RealHID(_Actions):244 def __init__(self, serial_port: str = "/dev/serial0", keyboard_layout: str = "us", baudrate: int = 115200,245 acknowledgement_timeout: float = 0.35, *, serial_factory: Callable[..., Any] | None = None) -> None:246 if not isinstance(serial_port, str) or not serial_port:247 raise ValueError("serial_port is required")248 if baudrate != 115200:249 raise ValueError("HB1 firmware requires 115200 baud")250 if type(acknowledgement_timeout) not in {int, float} or not 0 < acknowledgement_timeout <= 0.35:251 raise ValueError("acknowledgement_timeout must be in (0, 0.35]")252 self.serial_port, self.baudrate = serial_port, baudrate253 self.acknowledgement_timeout = acknowledgement_timeout254 self.keyboard = KeyboardLayout(keyboard_layout)255 self._serial_factory = serial_factory256 self._link: _SerialLink | None = None257 self._armed = False258 self._fault: str | None = None259 self._heartbeat: asyncio.Task | None = None260 self._action_lock = asyncio.Lock()261 self._lifecycle_lock = asyncio.Lock()262 self._wire_lock = asyncio.Lock()263 self._stop_generation = 0264 self._execution_generation = 0265 self._scroll_residual_x = self._scroll_residual_y = 0266 self._last_ack: float | None = None267268 def health_snapshot(self) -> dict[str, Any]:269 return {"connected": self._link is not None, "armed": self._armed, "fault": self._fault,270 "keyboard_layout": self.keyboard.name, "last_ack_monotonic": self._last_ack,271 "dry_run": False, "lock_state_warning": "Target CapsLock and NumLock must be known off"}272273 async def _cancel_heartbeat(self) -> None:274 task, self._heartbeat = self._heartbeat, None275 if task and task is not asyncio.current_task():276 task.cancel()277 try:278 await task279 except asyncio.CancelledError:280 pass281282 async def _exchange(self, operation: str, arguments: tuple[int, ...] = (), *, expected_generation: int | None = None) -> tuple[str, ...]:283 async with self._wire_lock:284 if self._link is None:285 raise HIDError("serial link is not started")286 if operation in _ARMED_COMMANDS and not self._armed:287 raise HIDError("output was stopped while request was waiting")288 if expected_generation is not None and expected_generation != self._stop_generation:289 raise HIDError("this action was stopped while request was waiting")290 task = asyncio.create_task(asyncio.to_thread(self._link.request, operation, arguments))291 try:292 result = await asyncio.shield(task)293 self._last_ack = time.monotonic()294 return result295 except asyncio.CancelledError:296 # A cancelled coroutine does not stop its serial thread. Settle297 # the bounded request before another caller can use the link.298 try:299 await asyncio.shield(task)300 except Exception:301 pass302 raise303304 async def _best_effort_stop(self) -> None:305 if self._link:306 try:307 await self._exchange("STOP")308 except (Exception, asyncio.CancelledError) as exc:309 if self._fault is None:310 self._fault = f"STOP: {type(exc).__name__}: {exc}"311312 async def _request(self, operation: str, arguments: tuple[int, ...] = (), *, expected_generation: int | None = None) -> tuple[str, ...]:313 try:314 return await self._exchange(operation, arguments, expected_generation=expected_generation)315 except (Exception, asyncio.CancelledError) as exc:316 if operation in _ARMED_COMMANDS and (not self._armed or (expected_generation is not None and expected_generation != self._stop_generation)):317 # stop() already owns disarm/release; intentional heartbeat318 # cancellation must not turn a clean manual stop into a fault.319 raise320 self._armed = False321 self._fault = f"{operation}: {type(exc).__name__}: {exc}"322 # Clear the lease-renewal condition before awaiting anything.323 await self._cancel_heartbeat()324 await self._best_effort_stop()325 if isinstance(exc, asyncio.CancelledError):326 raise327 raise HIDError(self._fault) from exc328329 async def start(self) -> None:330 """Explicit reconnect/reset. This always leaves output disarmed."""331 async with self._lifecycle_lock:332 if self._armed:333 raise HIDError("stop the armed driver before reconnecting")334 self._stop_generation += 1335 await self._cancel_heartbeat()336 if self._link:337 old_link, self._link = self._link, None338 await asyncio.to_thread(old_link.serial.close)339 self._fault = None340 factory = self._serial_factory341 if factory is None:342 import serial343 factory = serial.Serial344 try:345 open_task = asyncio.create_task(asyncio.to_thread(346 factory, self.serial_port, self.baudrate,347 timeout=self.acknowledgement_timeout, write_timeout=self.acknowledgement_timeout))348 try:349 port = await asyncio.shield(open_task)350 except asyncio.CancelledError:351 # An OS serial open still running in a thread must not leak352 # its eventual port after the caller cancels startup.353 port = await asyncio.shield(open_task)354 await asyncio.to_thread(port.close)355 raise356 self._link = _SerialLink(port, self.acknowledgement_timeout)357 reply = await self._request("HELLO")358 if reply != ("1",):359 raise HIDError("unsupported firmware protocol version")360 except (Exception, asyncio.CancelledError) as exc:361 self._armed = False362 self._fault = f"start: {type(exc).__name__}: {exc}"363 if self._link:364 link, self._link = self._link, None365 await asyncio.to_thread(link.serial.close)366 raise367 self._scroll_residual_x = self._scroll_residual_y = 0368 _LOG.warning("HID target layout %s: CapsLock/NumLock must be known off", self.keyboard.name)369370 async def arm(self) -> None:371 async with self._lifecycle_lock:372 if self._link is None or self._fault:373 raise HIDError("start/restart the driver before arming")374 if self._armed:375 return376 generation = self._stop_generation377 await self._request("ARM")378 if generation != self._stop_generation:379 await self._best_effort_stop()380 raise HIDError("arm was cancelled by stop")381 self._armed = True382 self._heartbeat = asyncio.create_task(self._heartbeat_loop(), name="hid-lease-heartbeat")383384 async def _heartbeat_loop(self) -> None:385 try:386 while self._armed and not self._fault:387 await self._request("PING")388 await asyncio.sleep(0.1)389 except (HIDError, asyncio.CancelledError):390 return391392 async def stop(self) -> None:393 # Do not wait for the action lock: disarm ongoing typing/drag immediately.394 self._stop_generation += 1395 self._armed = False396 self._scroll_residual_x = self._scroll_residual_y = 0397 await self._cancel_heartbeat()398 async with self._lifecycle_lock:399 if self._link:400 await self._best_effort_stop()401402 async def close(self) -> None:403 await self.stop()404 async with self._lifecycle_lock:405 # Another caller may have won the lock to arm after stop released406 # it. Closing is authoritative, so disarm again under this lock.407 self._stop_generation += 1408 self._armed = False409 await self._cancel_heartbeat()410 await self._best_effort_stop()411 if self._link:412 link, self._link = self._link, None413 await asyncio.to_thread(link.serial.close)414415416class DryHID(_Actions):417 """Records primitive commands, produces no serial or target HID output."""418419 def __init__(self, serial_port: str = "dry", keyboard_layout: str = "us", baudrate: int = 115200,420 acknowledgement_timeout: float = 0.35, **_: Any) -> None:421 self.keyboard = KeyboardLayout(keyboard_layout)422 self.commands: list[tuple[str, tuple[int, ...]]] = []423 self._armed = False424 self._connected = False425 self._fault: str | None = None426 self._action_lock = asyncio.Lock()427 self._stop_generation = 0428 self._execution_generation = 0429 self._scroll_residual_x = self._scroll_residual_y = 0430431 def health_snapshot(self) -> dict[str, Any]:432 return {"connected": self._connected, "armed": self._armed, "fault": self._fault,433 "keyboard_layout": self.keyboard.name, "dry_run": True,434 "lock_state_warning": "Target CapsLock and NumLock must be known off"}435436 async def _request(self, operation: str, arguments: tuple[int, ...] = (), *, expected_generation: int | None = None) -> tuple[str, ...]:437 if expected_generation is not None and expected_generation != self._stop_generation:438 raise HIDError("this action was stopped")439 self.commands.append((operation, arguments))440 return ("1",) if operation == "HELLO" else ()441442 async def start(self) -> None:443 self._stop_generation += 1444 self._connected, self._armed, self._fault = True, False, None445 self._scroll_residual_x = self._scroll_residual_y = 0446 await self._request("HELLO")447448 async def arm(self) -> None:449 if not self._connected:450 raise HIDError("start the driver before arming")451 await self._request("ARM")452 self._armed = True453454 async def stop(self) -> None:455 self._stop_generation += 1456 self._armed = False457 self._scroll_residual_x = self._scroll_residual_y = 0458 if self._connected:459 await self._request("STOP")460461 async def close(self) -> None:462 await self.stop()463 self._connected = False