haptic_skin

haptic_skin — control library for the HAPTIC.SKIN 8-motor navigation necklace.

Quick start::

from haptic_skin import haptic
haptic('turn_left', 0.8)      # buzz the left motor at 80 %

By default this talks to a built-in simulator (no hardware needed). Set the environment variable HAPTIC_SKIN_PORT (e.g. /dev/ttyACM0) to drive a real Mega, or build a Necklace yourself with an explicit transport.

 1"""haptic_skin — control library for the HAPTIC.SKIN 8-motor navigation necklace.
 2
 3Quick start::
 4
 5    from haptic_skin import haptic
 6    haptic('turn_left', 0.8)      # buzz the left motor at 80 %
 7
 8By default this talks to a built-in simulator (no hardware needed). Set the
 9environment variable ``HAPTIC_SKIN_PORT`` (e.g. ``/dev/ttyACM0``) to drive a
10real Mega, or build a :class:`Necklace` yourself with an explicit transport.
11"""
12
13from __future__ import annotations
14
15import os
16from typing import Optional
17
18from . import bridge, geo, nav, patterns, protocol, routing
19from .nav import Navigator, NavState
20from .necklace import BUILD_MOTOR_MAP, Direction, Necklace, resolve_direction
21from .routing import Route, Waypoint
22from .transport import (
23    FakeMega,
24    MockTransport,
25    SerialTransport,
26    Transport,
27    open_transport,
28)
29
30__all__ = [
31    "Direction",
32    "Necklace",
33    "Transport",
34    "SerialTransport",
35    "MockTransport",
36    "FakeMega",
37    "open_transport",
38    "resolve_direction",
39    "patterns",
40    "protocol",
41    "geo",
42    "routing",
43    "nav",
44    "bridge",
45    "Route",
46    "Waypoint",
47    "Navigator",
48    "NavState",
49    "connect",
50    "haptic",
51]
52
53__version__ = "0.1.0"
54
55_default: Optional[Necklace] = None
56
57
58def connect(port: Optional[str] = None) -> Necklace:
59    """Return a process-wide :class:`Necklace`, creating it on first use.
60
61    ``port`` defaults to ``$HAPTIC_SKIN_PORT``; if unset, a simulator is used.
62    """
63    global _default
64    if _default is None:
65        _default = Necklace(port=port or os.environ.get("HAPTIC_SKIN_PORT"),
66                            motor_map=BUILD_MOTOR_MAP)
67    return _default
68
69
70def haptic(direction, intensity: float = 1.0) -> str:
71    """One-liner: vibrate ``direction`` at ``intensity`` on the default necklace."""
72    return connect().haptic(direction, intensity)
class Direction(enum.IntEnum):
18class Direction(IntEnum):
19    """8 cardinal positions, in the wearer's frame. Index 0 = front (12 o'clock),
20    going clockwise. These ARE the motor indices."""
21
22    N = 0   # front
23    NE = 1
24    E = 2   # right
25    SE = 3
26    S = 4   # back
27    SW = 5
28    W = 6   # left
29    NW = 7

8 cardinal positions, in the wearer's frame. Index 0 = front (12 o'clock), going clockwise. These ARE the motor indices.

N = <Direction.N: 0>
NE = <Direction.NE: 1>
E = <Direction.E: 2>
SE = <Direction.SE: 3>
S = <Direction.S: 4>
SW = <Direction.SW: 5>
W = <Direction.W: 6>
NW = <Direction.NW: 7>
class Necklace:
 66class Necklace:
 67    """Owns a transport and the current motor state."""
 68
 69    def __init__(self, transport: Optional[Transport] = None,
 70                 port: Optional[str] = None,
 71                 motor_map: Optional[Sequence[int]] = None):
 72        self.transport = transport or open_transport(port)
 73        self.values: List[int] = [0] * protocol.NUM_MOTORS
 74        # motor_map[logical] = physical wire. Defaults to identity; connect()
 75        # passes BUILD_MOTOR_MAP for the mirrored prototype wiring.
 76        if motor_map is None:
 77            self.motor_map: List[int] = list(range(protocol.NUM_MOTORS))
 78        else:
 79            mm = list(motor_map)
 80            if sorted(mm) != list(range(protocol.NUM_MOTORS)):
 81                raise ValueError(
 82                    "motor_map must be a permutation of "
 83                    f"0..{protocol.NUM_MOTORS - 1}, got {motor_map!r}")
 84            self.motor_map = mm
 85
 86    # -- low level ----------------------------------------------------------
 87    # Set commands are fire-and-forget: the transport returns the (usually
 88    # empty) reply frame, which callers may ignore.
 89    def set_motor(self, index: int, value: int) -> bytes:
 90        if not 0 <= index < protocol.NUM_MOTORS:
 91            raise ValueError(
 92                f"motor index {index} out of range (valid 0..{protocol.NUM_MOTORS - 1})")
 93        self.values[index] = protocol.clamp_pwm(value)
 94        return self.transport.send(protocol.set_motor(self.motor_map[index], value))
 95
 96    def set_all(self, value: int) -> bytes:
 97        v = protocol.clamp_pwm(value)
 98        self.values = [v] * protocol.NUM_MOTORS
 99        return self.transport.send(protocol.set_all(value))
100
101    def set_vector(self, values: Sequence[int]) -> bytes:
102        if len(values) != protocol.NUM_MOTORS:
103            raise ValueError(
104                f"expected {protocol.NUM_MOTORS} values, got {len(values)}")
105        self.values = [protocol.clamp_pwm(v) for v in values]
106        phys = [0] * protocol.NUM_MOTORS
107        for logical, v in enumerate(values):
108            phys[self.motor_map[logical]] = v
109        return self.transport.send(protocol.set_vector(phys))
110
111    def stop(self) -> bytes:
112        self.values = [0] * protocol.NUM_MOTORS
113        return self.transport.send(protocol.stop())
114
115    def ping(self) -> bool:
116        reply = self.transport.send(protocol.ping())
117        frame = protocol.decode_one(reply, start_byte=protocol.START_P2H)
118        return frame is not None and frame[0] == protocol.EVT_PONG
119
120    def version(self) -> str:
121        return f"{protocol.FW_ID} N={protocol.NUM_MOTORS}"
122
123    # -- high level ---------------------------------------------------------
124    def haptic(self, direction, intensity: float = 1.0) -> str:
125        """Vibrate a single direction at 0.0..1.0 intensity (others off)."""
126        d = resolve_direction(direction)
127        vec = [0] * protocol.NUM_MOTORS
128        vec[int(d)] = protocol.intensity_to_pwm(intensity)
129        return self.set_vector(vec)
130
131    def close(self) -> None:
132        try:
133            self.stop()
134        finally:
135            self.transport.close()
136
137    def __enter__(self) -> "Necklace":
138        return self
139
140    def __exit__(self, *exc) -> None:
141        self.close()

Owns a transport and the current motor state.

Necklace( transport: Optional[Transport] = None, port: Optional[str] = None, motor_map: Optional[Sequence[int]] = None)
69    def __init__(self, transport: Optional[Transport] = None,
70                 port: Optional[str] = None,
71                 motor_map: Optional[Sequence[int]] = None):
72        self.transport = transport or open_transport(port)
73        self.values: List[int] = [0] * protocol.NUM_MOTORS
74        # motor_map[logical] = physical wire. Defaults to identity; connect()
75        # passes BUILD_MOTOR_MAP for the mirrored prototype wiring.
76        if motor_map is None:
77            self.motor_map: List[int] = list(range(protocol.NUM_MOTORS))
78        else:
79            mm = list(motor_map)
80            if sorted(mm) != list(range(protocol.NUM_MOTORS)):
81                raise ValueError(
82                    "motor_map must be a permutation of "
83                    f"0..{protocol.NUM_MOTORS - 1}, got {motor_map!r}")
84            self.motor_map = mm
transport
values: List[int]
def set_motor(self, index: int, value: int) -> bytes:
89    def set_motor(self, index: int, value: int) -> bytes:
90        if not 0 <= index < protocol.NUM_MOTORS:
91            raise ValueError(
92                f"motor index {index} out of range (valid 0..{protocol.NUM_MOTORS - 1})")
93        self.values[index] = protocol.clamp_pwm(value)
94        return self.transport.send(protocol.set_motor(self.motor_map[index], value))
def set_all(self, value: int) -> bytes:
96    def set_all(self, value: int) -> bytes:
97        v = protocol.clamp_pwm(value)
98        self.values = [v] * protocol.NUM_MOTORS
99        return self.transport.send(protocol.set_all(value))
def set_vector(self, values: Sequence[int]) -> bytes:
101    def set_vector(self, values: Sequence[int]) -> bytes:
102        if len(values) != protocol.NUM_MOTORS:
103            raise ValueError(
104                f"expected {protocol.NUM_MOTORS} values, got {len(values)}")
105        self.values = [protocol.clamp_pwm(v) for v in values]
106        phys = [0] * protocol.NUM_MOTORS
107        for logical, v in enumerate(values):
108            phys[self.motor_map[logical]] = v
109        return self.transport.send(protocol.set_vector(phys))
def stop(self) -> bytes:
111    def stop(self) -> bytes:
112        self.values = [0] * protocol.NUM_MOTORS
113        return self.transport.send(protocol.stop())
def ping(self) -> bool:
115    def ping(self) -> bool:
116        reply = self.transport.send(protocol.ping())
117        frame = protocol.decode_one(reply, start_byte=protocol.START_P2H)
118        return frame is not None and frame[0] == protocol.EVT_PONG
def version(self) -> str:
120    def version(self) -> str:
121        return f"{protocol.FW_ID} N={protocol.NUM_MOTORS}"
def haptic(self, direction, intensity: float = 1.0) -> str:
124    def haptic(self, direction, intensity: float = 1.0) -> str:
125        """Vibrate a single direction at 0.0..1.0 intensity (others off)."""
126        d = resolve_direction(direction)
127        vec = [0] * protocol.NUM_MOTORS
128        vec[int(d)] = protocol.intensity_to_pwm(intensity)
129        return self.set_vector(vec)

Vibrate a single direction at 0.0..1.0 intensity (others off).

def close(self) -> None:
131    def close(self) -> None:
132        try:
133            self.stop()
134        finally:
135            self.transport.close()
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 SerialTransport(haptic_skin.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
class MockTransport(haptic_skin.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 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"").

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.

def resolve_direction(d) -> Direction:
54def resolve_direction(d) -> Direction:
55    """Accept a Direction, an int 0..7, or a name like 'turn_left'."""
56    if isinstance(d, Direction):
57        return d
58    if isinstance(d, int):
59        return Direction(d % protocol.NUM_MOTORS)
60    key = str(d).strip().lower()
61    if key in _DIRECTION_ALIASES:
62        return _DIRECTION_ALIASES[key]
63    raise ValueError(f"unknown direction: {d!r}")

Accept a Direction, an int 0..7, or a name like 'turn_left'.

@dataclass
class Route:
37@dataclass
38class Route:
39    waypoints: List[Waypoint]
40
41    def __len__(self) -> int:
42        return len(self.waypoints)
43
44    def to_json(self) -> str:
45        return json.dumps(
46            {"waypoints": [vars(w) for w in self.waypoints]}, indent=2
47        )
Route(waypoints: List[Waypoint])
waypoints: List[Waypoint]
def to_json(self) -> str:
44    def to_json(self) -> str:
45        return json.dumps(
46            {"waypoints": [vars(w) for w in self.waypoints]}, indent=2
47        )
@dataclass
class Waypoint:
25@dataclass
26class Waypoint:
27    lat: float
28    lon: float
29    next_bearing: float          # compass bearing to follow after this point
30    instruction: str = ""
31
32    @property
33    def point(self) -> LatLon:
34        return (self.lat, self.lon)
Waypoint(lat: float, lon: float, next_bearing: float, instruction: str = '')
lat: float
lon: float
next_bearing: float
instruction: str = ''
point: Tuple[float, float]
32    @property
33    def point(self) -> LatLon:
34        return (self.lat, self.lon)
def connect(port: Optional[str] = None) -> Necklace:
59def connect(port: Optional[str] = None) -> Necklace:
60    """Return a process-wide :class:`Necklace`, creating it on first use.
61
62    ``port`` defaults to ``$HAPTIC_SKIN_PORT``; if unset, a simulator is used.
63    """
64    global _default
65    if _default is None:
66        _default = Necklace(port=port or os.environ.get("HAPTIC_SKIN_PORT"),
67                            motor_map=BUILD_MOTOR_MAP)
68    return _default

Return a process-wide Necklace, creating it on first use.

port defaults to $HAPTIC_SKIN_PORT; if unset, a simulator is used.

def haptic(direction, intensity: float = 1.0) -> str:
71def haptic(direction, intensity: float = 1.0) -> str:
72    """One-liner: vibrate ``direction`` at ``intensity`` on the default necklace."""
73    return connect().haptic(direction, intensity)

One-liner: vibrate direction at intensity on the default necklace.