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()
MAX_DUTY = 150
DUTY_CAP = 153
MAX_ACTIVE_MOTORS = 6
FW_VERSION = '1.0'
class Transport(abc.ABC):
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.

@abstractmethod
def send(self, frame: bytes) -> bytes:
35    @abstractmethod
36    def send(self, frame: bytes) -> bytes:  # pragma: no cover - interface
37        ...
def close(self) -> None:
39    def close(self) -> None:  # pragma: no cover - default no-op
40        pass
class FakeMega:
 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.)

motors: List[int]
history: List[List[int]]
def handle(self, frame: bytes) -> bytes:
 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"").

class MockTransport(Transport):
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).

device
sent: List[bytes]
def send(self, frame: bytes) -> bytes:
125    def send(self, frame: bytes) -> bytes:
126        self.sent.append(frame)
127        return self.device.handle(frame)
Inherited Members
Transport
close
class SerialTransport(Transport):
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).

SerialTransport(port: str, baud: int = 115200, timeout: float = 0.5)
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)
def send(self, frame: bytes) -> bytes:
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""
def close(self) -> None:
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
def open_transport(port: Optional[str] = None) -> Transport:
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.