haptic_skin.transport
Transports that carry binary protocol frames to the controller.
Two implementations:
SerialTransport— talks to a real Pico over USB CDC-ACM (pyserial).MockTransport— an in-process simulator (FakeMega) so the whole stack can be developed and tested with no hardware attached.
Both expose the same send(frame: bytes) -> bytes API: a command frame in,
a reply frame out (empty b"" when the device has nothing to say).
1"""Transports that carry binary protocol frames to the controller. 2 3Two implementations: 4 * ``SerialTransport`` — talks to a real Pico over USB CDC-ACM (pyserial). 5 * ``MockTransport`` — an in-process simulator (``FakeMega``) so the whole 6 stack can be developed and tested with no hardware attached. 7 8Both expose the same ``send(frame: bytes) -> bytes`` API: a command frame in, 9a reply frame out (empty ``b""`` when the device has nothing to say). 10""" 11 12from __future__ import annotations 13 14from abc import ABC, abstractmethod 15from typing import List, Optional 16 17from . import protocol 18 19# Power-guard / safety constants — mirror the firmware (firmware-rust-v2) so the 20# simulator behaves exactly like the real board: 21# * MAX_DUTY = config::MOTOR_MAX_DUTY (Motors::set clamps EVERY write) 22# * DUTY_CAP = config::POWER_GUARD_DUTY_CAP (SetAll only) 23# * MAX_ACTIVE_MOTORS = config::POWER_GUARD_MAX_CONCURRENT (SetAll only) 24MAX_DUTY = 150 25DUTY_CAP = 153 26MAX_ACTIVE_MOTORS = 6 27 28FW_VERSION = "1.0" 29 30 31class Transport(ABC): 32 """Sends one protocol frame and returns the controller's reply frame.""" 33 34 @abstractmethod 35 def send(self, frame: bytes) -> bytes: # pragma: no cover - interface 36 ... 37 38 def close(self) -> None: # pragma: no cover - default no-op 39 pass 40 41 def __enter__(self) -> "Transport": 42 return self 43 44 def __exit__(self, *exc) -> None: 45 self.close() 46 47 48class FakeMega: 49 """Pure-Python re-implementation of the firmware command handler. 50 51 Decodes binary frames, mirrors the power guard, and tracks motor state so 52 tests and offline development see the same behaviour as the hardware. 53 (Name kept for continuity; it now emulates the Pico/Rust firmware.) 54 """ 55 56 def __init__(self) -> None: 57 self.motors: List[int] = [0] * protocol.NUM_MOTORS 58 # history of every committed vector — handy for assertions in tests 59 self.history: List[List[int]] = [] 60 61 @staticmethod 62 def _clamp(value: int) -> int: 63 """Mirror ``Motors::set`` — every motor write is bounded to MAX_DUTY.""" 64 return max(0, min(MAX_DUTY, value)) 65 66 @staticmethod 67 def _power_guard(values: List[int]) -> List[int]: 68 """Mirror ``tasks::power_guard::apply`` (the SetAll path only). 69 70 1) cap each motor to ``DUTY_CAP``; 2) keep the ``MAX_ACTIVE_MOTORS`` 71 strongest non-zero motors, cut the rest to 0. 72 """ 73 v = [min(DUTY_CAP, max(0, x)) for x in values] 74 ranked = sorted((i for i, x in enumerate(v) if x > 0), 75 key=lambda i: v[i], reverse=True) 76 for i in ranked[MAX_ACTIVE_MOTORS:]: 77 v[i] = 0 78 return v 79 80 def _commit(self, values: List[int]) -> None: 81 self.motors = [self._clamp(v) for v in values] 82 self.history.append(list(self.motors)) 83 84 def handle(self, frame: bytes) -> bytes: 85 """Process one host->device frame, return the reply frame (or ``b""``).""" 86 decoded = protocol.decode_one(frame, start_byte=protocol.START_H2P) 87 if decoded is None: 88 return protocol.error() 89 cmd, payload = decoded 90 91 if cmd == protocol.CMD_PING: 92 return protocol.pong() 93 94 if cmd == protocol.CMD_STOP_ALL: 95 self._commit([0] * protocol.NUM_MOTORS) 96 return b"" 97 98 if cmd == protocol.CMD_SET_MOTOR: 99 # firmware SetMotor path: direct write, clamped, NO concurrency guard 100 if len(payload) != 2 or not 0 <= payload[0] < protocol.NUM_MOTORS: 101 return protocol.error() 102 req = list(self.motors) 103 req[payload[0]] = payload[1] 104 self._commit(req) 105 return b"" 106 107 if cmd == protocol.CMD_SET_ALL: 108 # firmware SetAll path: power guard (cap + concurrency) then clamp 109 if len(payload) != protocol.NUM_MOTORS: 110 return protocol.error() 111 self._commit(self._power_guard(list(payload))) 112 return b"" 113 114 return protocol.error() 115 116 117class MockTransport(Transport): 118 """In-process transport backed by :class:`FakeMega` (no hardware).""" 119 120 def __init__(self) -> None: 121 self.device = FakeMega() 122 self.sent: List[bytes] = [] 123 124 def send(self, frame: bytes) -> bytes: 125 self.sent.append(frame) 126 return self.device.handle(frame) 127 128 129def _is_ping(frame: bytes) -> bool: 130 return len(frame) >= 2 and frame[0] == protocol.START_H2P and frame[1] == protocol.CMD_PING 131 132 133class SerialTransport(Transport): 134 """Talks to a real Pico over USB CDC-ACM — resilient to unplug/replug. 135 136 Never raises if the board is missing: it keeps trying to (re)open in the 137 background so the bridge stays up across a disconnect, and reconnects on its 138 own when the Pico comes back. Only ``Ping`` frames wait for a reply; every 139 other frame is fire-and-forget (so streaming motor updates never block). 140 """ 141 142 def __init__(self, port: str, baud: int = 115200, timeout: float = 0.5): 143 try: 144 import serial # type: ignore 145 except ImportError as exc: # pragma: no cover 146 raise RuntimeError( 147 "pyserial is required for SerialTransport — `pip install pyserial`" 148 ) from exc 149 self._serial_mod = serial 150 self.port, self.baud, self.timeout = port, baud, timeout 151 self._serial = None 152 self._last_try = 0.0 153 self._open(initial=True) 154 155 def _open(self, initial: bool = False) -> None: 156 import time 157 try: 158 self._serial = self._serial_mod.Serial(self.port, self.baud, timeout=self.timeout) 159 if initial: 160 time.sleep(2.0) # let the board enumerate / settle 161 self._serial.reset_input_buffer() 162 print(f"[serial] connected to {self.port}") 163 except Exception: 164 self._serial = None 165 if initial: 166 print(f"[serial] {self.port} not available yet — will retry") 167 168 def _ensure(self) -> bool: 169 if self._serial is not None: 170 return True 171 import time 172 now = time.monotonic() 173 if now - self._last_try < 2.0: # throttle reconnect attempts 174 return False 175 self._last_try = now 176 self._open() 177 return self._serial is not None 178 179 def send(self, frame: bytes) -> bytes: 180 if not self._ensure(): 181 return b"" # board absent: no-op, keep running 182 try: 183 self._serial.write(frame) 184 self._serial.flush() 185 if _is_ping(frame): 186 return self._serial.read(64) # pong frame (or b"" on timeout) 187 return b"" # set commands are fire-and-forget 188 except Exception: # unplugged mid-session 189 try: 190 self._serial.close() 191 except Exception: 192 pass 193 self._serial = None 194 print(f"[serial] lost {self.port} — will reconnect") 195 return b"" 196 197 def close(self) -> None: 198 try: 199 if self._serial is not None: 200 self._serial.close() 201 except Exception: # pragma: no cover 202 pass 203 204 205def open_transport(port: Optional[str] = None) -> Transport: 206 """Open a real serial transport if ``port`` is given, else a mock.""" 207 if port: 208 return SerialTransport(port) 209 return MockTransport()
32class Transport(ABC): 33 """Sends one protocol frame and returns the controller's reply frame.""" 34 35 @abstractmethod 36 def send(self, frame: bytes) -> bytes: # pragma: no cover - interface 37 ... 38 39 def close(self) -> None: # pragma: no cover - default no-op 40 pass 41 42 def __enter__(self) -> "Transport": 43 return self 44 45 def __exit__(self, *exc) -> None: 46 self.close()
Sends one protocol frame and returns the controller's reply frame.
49class FakeMega: 50 """Pure-Python re-implementation of the firmware command handler. 51 52 Decodes binary frames, mirrors the power guard, and tracks motor state so 53 tests and offline development see the same behaviour as the hardware. 54 (Name kept for continuity; it now emulates the Pico/Rust firmware.) 55 """ 56 57 def __init__(self) -> None: 58 self.motors: List[int] = [0] * protocol.NUM_MOTORS 59 # history of every committed vector — handy for assertions in tests 60 self.history: List[List[int]] = [] 61 62 @staticmethod 63 def _clamp(value: int) -> int: 64 """Mirror ``Motors::set`` — every motor write is bounded to MAX_DUTY.""" 65 return max(0, min(MAX_DUTY, value)) 66 67 @staticmethod 68 def _power_guard(values: List[int]) -> List[int]: 69 """Mirror ``tasks::power_guard::apply`` (the SetAll path only). 70 71 1) cap each motor to ``DUTY_CAP``; 2) keep the ``MAX_ACTIVE_MOTORS`` 72 strongest non-zero motors, cut the rest to 0. 73 """ 74 v = [min(DUTY_CAP, max(0, x)) for x in values] 75 ranked = sorted((i for i, x in enumerate(v) if x > 0), 76 key=lambda i: v[i], reverse=True) 77 for i in ranked[MAX_ACTIVE_MOTORS:]: 78 v[i] = 0 79 return v 80 81 def _commit(self, values: List[int]) -> None: 82 self.motors = [self._clamp(v) for v in values] 83 self.history.append(list(self.motors)) 84 85 def handle(self, frame: bytes) -> bytes: 86 """Process one host->device frame, return the reply frame (or ``b""``).""" 87 decoded = protocol.decode_one(frame, start_byte=protocol.START_H2P) 88 if decoded is None: 89 return protocol.error() 90 cmd, payload = decoded 91 92 if cmd == protocol.CMD_PING: 93 return protocol.pong() 94 95 if cmd == protocol.CMD_STOP_ALL: 96 self._commit([0] * protocol.NUM_MOTORS) 97 return b"" 98 99 if cmd == protocol.CMD_SET_MOTOR: 100 # firmware SetMotor path: direct write, clamped, NO concurrency guard 101 if len(payload) != 2 or not 0 <= payload[0] < protocol.NUM_MOTORS: 102 return protocol.error() 103 req = list(self.motors) 104 req[payload[0]] = payload[1] 105 self._commit(req) 106 return b"" 107 108 if cmd == protocol.CMD_SET_ALL: 109 # firmware SetAll path: power guard (cap + concurrency) then clamp 110 if len(payload) != protocol.NUM_MOTORS: 111 return protocol.error() 112 self._commit(self._power_guard(list(payload))) 113 return b"" 114 115 return protocol.error()
Pure-Python re-implementation of the firmware command handler.
Decodes binary frames, mirrors the power guard, and tracks motor state so tests and offline development see the same behaviour as the hardware. (Name kept for continuity; it now emulates the Pico/Rust firmware.)
85 def handle(self, frame: bytes) -> bytes: 86 """Process one host->device frame, return the reply frame (or ``b""``).""" 87 decoded = protocol.decode_one(frame, start_byte=protocol.START_H2P) 88 if decoded is None: 89 return protocol.error() 90 cmd, payload = decoded 91 92 if cmd == protocol.CMD_PING: 93 return protocol.pong() 94 95 if cmd == protocol.CMD_STOP_ALL: 96 self._commit([0] * protocol.NUM_MOTORS) 97 return b"" 98 99 if cmd == protocol.CMD_SET_MOTOR: 100 # firmware SetMotor path: direct write, clamped, NO concurrency guard 101 if len(payload) != 2 or not 0 <= payload[0] < protocol.NUM_MOTORS: 102 return protocol.error() 103 req = list(self.motors) 104 req[payload[0]] = payload[1] 105 self._commit(req) 106 return b"" 107 108 if cmd == protocol.CMD_SET_ALL: 109 # firmware SetAll path: power guard (cap + concurrency) then clamp 110 if len(payload) != protocol.NUM_MOTORS: 111 return protocol.error() 112 self._commit(self._power_guard(list(payload))) 113 return b"" 114 115 return protocol.error()
Process one host->device frame, return the reply frame (or b"").
118class MockTransport(Transport): 119 """In-process transport backed by :class:`FakeMega` (no hardware).""" 120 121 def __init__(self) -> None: 122 self.device = FakeMega() 123 self.sent: List[bytes] = [] 124 125 def send(self, frame: bytes) -> bytes: 126 self.sent.append(frame) 127 return self.device.handle(frame)
In-process transport backed by FakeMega (no hardware).
134class SerialTransport(Transport): 135 """Talks to a real Pico over USB CDC-ACM — resilient to unplug/replug. 136 137 Never raises if the board is missing: it keeps trying to (re)open in the 138 background so the bridge stays up across a disconnect, and reconnects on its 139 own when the Pico comes back. Only ``Ping`` frames wait for a reply; every 140 other frame is fire-and-forget (so streaming motor updates never block). 141 """ 142 143 def __init__(self, port: str, baud: int = 115200, timeout: float = 0.5): 144 try: 145 import serial # type: ignore 146 except ImportError as exc: # pragma: no cover 147 raise RuntimeError( 148 "pyserial is required for SerialTransport — `pip install pyserial`" 149 ) from exc 150 self._serial_mod = serial 151 self.port, self.baud, self.timeout = port, baud, timeout 152 self._serial = None 153 self._last_try = 0.0 154 self._open(initial=True) 155 156 def _open(self, initial: bool = False) -> None: 157 import time 158 try: 159 self._serial = self._serial_mod.Serial(self.port, self.baud, timeout=self.timeout) 160 if initial: 161 time.sleep(2.0) # let the board enumerate / settle 162 self._serial.reset_input_buffer() 163 print(f"[serial] connected to {self.port}") 164 except Exception: 165 self._serial = None 166 if initial: 167 print(f"[serial] {self.port} not available yet — will retry") 168 169 def _ensure(self) -> bool: 170 if self._serial is not None: 171 return True 172 import time 173 now = time.monotonic() 174 if now - self._last_try < 2.0: # throttle reconnect attempts 175 return False 176 self._last_try = now 177 self._open() 178 return self._serial is not None 179 180 def send(self, frame: bytes) -> bytes: 181 if not self._ensure(): 182 return b"" # board absent: no-op, keep running 183 try: 184 self._serial.write(frame) 185 self._serial.flush() 186 if _is_ping(frame): 187 return self._serial.read(64) # pong frame (or b"" on timeout) 188 return b"" # set commands are fire-and-forget 189 except Exception: # unplugged mid-session 190 try: 191 self._serial.close() 192 except Exception: 193 pass 194 self._serial = None 195 print(f"[serial] lost {self.port} — will reconnect") 196 return b"" 197 198 def close(self) -> None: 199 try: 200 if self._serial is not None: 201 self._serial.close() 202 except Exception: # pragma: no cover 203 pass
Talks to a real Pico over USB CDC-ACM — resilient to unplug/replug.
Never raises if the board is missing: it keeps trying to (re)open in the
background so the bridge stays up across a disconnect, and reconnects on its
own when the Pico comes back. Only Ping frames wait for a reply; every
other frame is fire-and-forget (so streaming motor updates never block).
143 def __init__(self, port: str, baud: int = 115200, timeout: float = 0.5): 144 try: 145 import serial # type: ignore 146 except ImportError as exc: # pragma: no cover 147 raise RuntimeError( 148 "pyserial is required for SerialTransport — `pip install pyserial`" 149 ) from exc 150 self._serial_mod = serial 151 self.port, self.baud, self.timeout = port, baud, timeout 152 self._serial = None 153 self._last_try = 0.0 154 self._open(initial=True)
180 def send(self, frame: bytes) -> bytes: 181 if not self._ensure(): 182 return b"" # board absent: no-op, keep running 183 try: 184 self._serial.write(frame) 185 self._serial.flush() 186 if _is_ping(frame): 187 return self._serial.read(64) # pong frame (or b"" on timeout) 188 return b"" # set commands are fire-and-forget 189 except Exception: # unplugged mid-session 190 try: 191 self._serial.close() 192 except Exception: 193 pass 194 self._serial = None 195 print(f"[serial] lost {self.port} — will reconnect") 196 return b""
206def open_transport(port: Optional[str] = None) -> Transport: 207 """Open a real serial transport if ``port`` is given, else a mock.""" 208 if port: 209 return SerialTransport(port) 210 return MockTransport()
Open a real serial transport if port is given, else a mock.