haptic_skin.necklace

High-level controller for the 8-motor necklace.

Maps human directions to motor indices and exposes simple primitives (set_motor, set_vector, stop...). All vibration patterns (pulse, sweep, ...) live in haptic_skin.patterns; this class just sets PWM.

  1"""High-level controller for the 8-motor necklace.
  2
  3Maps human directions to motor indices and exposes simple primitives
  4(``set_motor``, ``set_vector``, ``stop``...). All vibration *patterns* (pulse,
  5sweep, ...) live in :mod:`haptic_skin.patterns`; this class just sets PWM.
  6"""
  7
  8from __future__ import annotations
  9
 10from enum import IntEnum
 11from typing import List, Optional, Sequence
 12
 13from . import protocol
 14from .transport import Transport, open_transport
 15
 16
 17class Direction(IntEnum):
 18    """8 cardinal positions, in the wearer's frame. Index 0 = front (12 o'clock),
 19    going clockwise. These ARE the motor indices."""
 20
 21    N = 0   # front
 22    NE = 1
 23    E = 2   # right
 24    SE = 3
 25    S = 4   # back
 26    SW = 5
 27    W = 6   # left
 28    NW = 7
 29
 30
 31# Friendly aliases accepted by :meth:`Necklace.haptic`.
 32_DIRECTION_ALIASES = {
 33    "front": Direction.N, "forward": Direction.N, "straight": Direction.N, "n": Direction.N,
 34    "ne": Direction.NE, "front_right": Direction.NE,
 35    "right": Direction.E, "turn_right": Direction.E, "e": Direction.E,
 36    "se": Direction.SE, "back_right": Direction.SE,
 37    "back": Direction.S, "behind": Direction.S, "u_turn": Direction.S, "s": Direction.S,
 38    "sw": Direction.SW, "back_left": Direction.SW,
 39    "left": Direction.W, "turn_left": Direction.W, "w": Direction.W,
 40    "nw": Direction.NW, "front_left": Direction.NW,
 41}
 42
 43
 44# Wiring permutation of the physical prototype: BUILD_MOTOR_MAP[logical_index] =
 45# the physical wire/motor actually sitting at that orientation. Measured on the
 46# bench 2026-07-02 — the collier was wound as a MIRROR of the canonical order, so
 47# orientation o maps to wire (5 - o) mod 8. Injected by :func:`connect`. The
 48# :class:`Necklace` class itself defaults to identity so the library stays
 49# build-agnostic (unit tests keep asserting logical index == physical index).
 50BUILD_MOTOR_MAP: List[int] = [5, 4, 3, 2, 1, 0, 7, 6]
 51
 52
 53def resolve_direction(d) -> Direction:
 54    """Accept a Direction, an int 0..7, or a name like 'turn_left'."""
 55    if isinstance(d, Direction):
 56        return d
 57    if isinstance(d, int):
 58        return Direction(d % protocol.NUM_MOTORS)
 59    key = str(d).strip().lower()
 60    if key in _DIRECTION_ALIASES:
 61        return _DIRECTION_ALIASES[key]
 62    raise ValueError(f"unknown direction: {d!r}")
 63
 64
 65class Necklace:
 66    """Owns a transport and the current motor state."""
 67
 68    def __init__(self, transport: Optional[Transport] = None,
 69                 port: Optional[str] = None,
 70                 motor_map: Optional[Sequence[int]] = None):
 71        self.transport = transport or open_transport(port)
 72        self.values: List[int] = [0] * protocol.NUM_MOTORS
 73        # motor_map[logical] = physical wire. Defaults to identity; connect()
 74        # passes BUILD_MOTOR_MAP for the mirrored prototype wiring.
 75        if motor_map is None:
 76            self.motor_map: List[int] = list(range(protocol.NUM_MOTORS))
 77        else:
 78            mm = list(motor_map)
 79            if sorted(mm) != list(range(protocol.NUM_MOTORS)):
 80                raise ValueError(
 81                    "motor_map must be a permutation of "
 82                    f"0..{protocol.NUM_MOTORS - 1}, got {motor_map!r}")
 83            self.motor_map = mm
 84
 85    # -- low level ----------------------------------------------------------
 86    # Set commands are fire-and-forget: the transport returns the (usually
 87    # empty) reply frame, which callers may ignore.
 88    def set_motor(self, index: int, value: int) -> bytes:
 89        if not 0 <= index < protocol.NUM_MOTORS:
 90            raise ValueError(
 91                f"motor index {index} out of range (valid 0..{protocol.NUM_MOTORS - 1})")
 92        self.values[index] = protocol.clamp_pwm(value)
 93        return self.transport.send(protocol.set_motor(self.motor_map[index], value))
 94
 95    def set_all(self, value: int) -> bytes:
 96        v = protocol.clamp_pwm(value)
 97        self.values = [v] * protocol.NUM_MOTORS
 98        return self.transport.send(protocol.set_all(value))
 99
100    def set_vector(self, values: Sequence[int]) -> bytes:
101        if len(values) != protocol.NUM_MOTORS:
102            raise ValueError(
103                f"expected {protocol.NUM_MOTORS} values, got {len(values)}")
104        self.values = [protocol.clamp_pwm(v) for v in values]
105        phys = [0] * protocol.NUM_MOTORS
106        for logical, v in enumerate(values):
107            phys[self.motor_map[logical]] = v
108        return self.transport.send(protocol.set_vector(phys))
109
110    def stop(self) -> bytes:
111        self.values = [0] * protocol.NUM_MOTORS
112        return self.transport.send(protocol.stop())
113
114    def ping(self) -> bool:
115        reply = self.transport.send(protocol.ping())
116        frame = protocol.decode_one(reply, start_byte=protocol.START_P2H)
117        return frame is not None and frame[0] == protocol.EVT_PONG
118
119    def version(self) -> str:
120        return f"{protocol.FW_ID} N={protocol.NUM_MOTORS}"
121
122    # -- high level ---------------------------------------------------------
123    def haptic(self, direction, intensity: float = 1.0) -> str:
124        """Vibrate a single direction at 0.0..1.0 intensity (others off)."""
125        d = resolve_direction(direction)
126        vec = [0] * protocol.NUM_MOTORS
127        vec[int(d)] = protocol.intensity_to_pwm(intensity)
128        return self.set_vector(vec)
129
130    def close(self) -> None:
131        try:
132            self.stop()
133        finally:
134            self.transport.close()
135
136    def __enter__(self) -> "Necklace":
137        return self
138
139    def __exit__(self, *exc) -> None:
140        self.close()
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>
BUILD_MOTOR_MAP: List[int] = [5, 4, 3, 2, 1, 0, 7, 6]
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'.

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[haptic_skin.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()