File size: 12,032 Bytes
549c3f0
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
"""The HAL contract β€” what ChittiOS expects from a robot body.

Layer 1 of the three-layer architecture (spec Β§4.1, Β§6.1). The contract is
deliberately minimal so Core can ignore which body it is running on: audio in
and out, a small set of motion primitives, vision, and optional presence.

The OS sends primitives; the body decides how to express them. A body with no
antennas ignores ``ANTENNA_WIGGLE``. A body with a screen renders a face for
``ATTENTION``. Neither case is an error.

Refinement of spec Β§6.1
-----------------------
The spec sketches ``motion(primitive: MotionPrimitive, params: dict)``. This
module keeps that shape but replaces the untyped ``dict`` with a union of
per-primitive parameter dataclasses. A ``dict`` forces every adapter into
unchecked key lookups at the one boundary the whole design depends on being
stable β€” precisely where a type that lies is most expensive. ``motion()``
validates that the parameter object matches the primitive before dispatch.

Fold back into the spec as an erratum to Β§6.1.
"""

from __future__ import annotations

from collections.abc import AsyncIterator
from dataclasses import dataclass, field
from enum import Enum
from typing import Literal, Protocol, TypeAlias, runtime_checkable

import numpy as np
from numpy.typing import NDArray

# ─────────────────────────────────────────────────────────────────────────
# Media types
# ─────────────────────────────────────────────────────────────────────────

#: Audio is float32 normalized to [-1, 1] throughout.
#:
#: An earlier draft of this contract specified int16 on the reasoning that it
#: was the common native format. Checking the installed SDK showed otherwise:
#: ``MediaManager.get_audio_sample`` returns float32 and ``push_audio_sample``
#: expects it, and Whisper wants normalized float32 as well. int16 is a
#: transport format; float32 is the processing format, and choosing it avoids
#: a quantization round-trip at every stage boundary.
AUDIO_DTYPE = np.float32


@dataclass(frozen=True, slots=True)
class AudioChunk:
    """A block of mono or interleaved audio.

    Attributes:
        samples: float32 normalized to [-1, 1]. Shape ``(n,)`` for mono,
            ``(n, channels)`` interleaved otherwise.
        sample_rate_hz: Sample rate of ``samples``.
        channels: Channel count. Must agree with ``samples``.
        timestamp_s: Monotonic capture or playback time, in seconds.
    """

    samples: NDArray[np.float32]
    sample_rate_hz: int
    channels: int
    timestamp_s: float

    def __post_init__(self) -> None:
        if self.sample_rate_hz <= 0:
            raise ValueError(f"sample_rate_hz must be positive, got {self.sample_rate_hz}")
        if self.channels <= 0:
            raise ValueError(f"channels must be positive, got {self.channels}")
        expected_ndim = 1 if self.channels == 1 else 2
        if self.samples.ndim != expected_ndim:
            raise ValueError(
                f"{self.channels}-channel audio expects a {expected_ndim}-D array, "
                f"got shape {self.samples.shape}"
            )

    @property
    def duration_s(self) -> float:
        """Wall-clock duration of this chunk."""
        return len(self.samples) / self.sample_rate_hz


@dataclass(frozen=True, slots=True)
class ImageFrame:
    """A single camera frame.

    Frames are transient by contract. Spec Β§5.1 obligation 2 requires that a
    frame be discarded once an embedding has been extracted: no rolling
    buffer, no clip storage, no sightings gallery. Adapters must not retain a
    reference after yielding one, and Core must not persist one.

    Attributes:
        pixels: BGR uint8, shape ``(height, width, 3)``. BGR rather than RGB
            because the upstream YuNet detector expects it.
        timestamp_s: Monotonic capture time, in seconds.
    """

    pixels: NDArray[np.uint8]
    timestamp_s: float

    def __post_init__(self) -> None:
        if self.pixels.ndim != 3 or self.pixels.shape[2] != 3:
            raise ValueError(f"expected BGR frame of shape (h, w, 3), got {self.pixels.shape}")

    @property
    def width(self) -> int:
        return int(self.pixels.shape[1])

    @property
    def height(self) -> int:
        return int(self.pixels.shape[0])


class Presence(Enum):
    """Coarse "is anyone nearby" sensing.

    Deliberately coarse: presence is a cheap trigger, not an identity signal.
    Identity is the Identity subsystem's job and requires biometrics.
    """

    ABSENT = "absent"
    PRESENT = "present"
    UNKNOWN = "unknown"


# ─────────────────────────────────────────────────────────────────────────
# Motion primitives
# ─────────────────────────────────────────────────────────────────────────


class MotionPrimitive(Enum):
    """The vocabulary Core uses to ask a body to move (spec Β§6.1).

    Extensible: a new primitive is additive, and bodies that do not understand
    it ignore it.
    """

    ORIENT_HEAD = "orient_head"
    ATTENTION = "attention"
    THINKING = "thinking"
    SLEEP = "sleep"
    IDLE = "idle"
    ANTENNA_WIGGLE = "antenna_wiggle"
    EYE_COLOR = "eye_color"
    DISPLAY = "display"


def _require_unit_range(name: str, value: float) -> None:
    if not -1.0 <= value <= 1.0:
        raise ValueError(f"{name} must lie in [-1, 1], got {value}")


def _require_zero_to_one(name: str, value: float) -> None:
    if not 0.0 <= value <= 1.0:
        raise ValueError(f"{name} must lie in [0, 1], got {value}")


@dataclass(frozen=True, slots=True)
class OrientHead:
    """Point the head, in normalized units.

    Normalized rather than degrees so that Core never encodes one body's
    joint limits. Each adapter maps [-1, 1] onto its own safe range β€” for
    Reachy Mini, pitch and roll to Β±40Β°, yaw to Β±180Β° (spec Β§"Safety Limits").
    """

    yaw: float = 0.0
    pitch: float = 0.0
    roll: float = 0.0

    primitive = MotionPrimitive.ORIENT_HEAD

    def __post_init__(self) -> None:
        _require_unit_range("yaw", self.yaw)
        _require_unit_range("pitch", self.pitch)
        _require_unit_range("roll", self.roll)


@dataclass(frozen=True, slots=True)
class Attention:
    """Attend to a point in the body frame, in metres, or relax if ``None``."""

    target: tuple[float, float, float] | None = None

    primitive = MotionPrimitive.ATTENTION


@dataclass(frozen=True, slots=True)
class Thinking:
    """Signal deliberation. ``intensity`` scales how pronounced it is."""

    intensity: float = 0.5

    primitive = MotionPrimitive.THINKING

    def __post_init__(self) -> None:
        _require_zero_to_one("intensity", self.intensity)


@dataclass(frozen=True, slots=True)
class Sleep:
    """Adopt the sleep posture."""

    primitive = MotionPrimitive.SLEEP


@dataclass(frozen=True, slots=True)
class Idle:
    """Resume idle micro-motion β€” the difference between present and dormant."""

    primitive = MotionPrimitive.IDLE


AntennaSide: TypeAlias = Literal["left", "right", "both"]


@dataclass(frozen=True, slots=True)
class AntennaWiggle:
    """Wiggle one or both antennas. Ignored by bodies without antennas."""

    speed: float = 0.5
    side: AntennaSide = "both"

    primitive = MotionPrimitive.ANTENNA_WIGGLE

    def __post_init__(self) -> None:
        _require_zero_to_one("speed", self.speed)


EyeMode: TypeAlias = Literal["solid", "pulse", "blink"]


@dataclass(frozen=True, slots=True)
class EyeColor:
    """Set expressive eye colour. Ignored by bodies without eye rings."""

    rgb: tuple[int, int, int] = (255, 255, 255)
    mode: EyeMode = "solid"

    primitive = MotionPrimitive.EYE_COLOR

    def __post_init__(self) -> None:
        for channel, value in zip("rgb", self.rgb, strict=True):
            if not 0 <= value <= 255:
                raise ValueError(f"rgb channel {channel} must lie in [0, 255], got {value}")


@dataclass(frozen=True, slots=True)
class Display:
    """Render to a status surface. Ignored by bodies without a display.

    Spec Β§5.1 obligation 5 leans on this: identity state must be legible to
    the household, and on Reachy Mini this is where it becomes visible.
    """

    content: dict[str, str] = field(default_factory=dict)

    primitive = MotionPrimitive.DISPLAY


#: Every motion parameter object. Each carries the primitive it belongs to, so
#: an adapter can dispatch on ``command.primitive`` without a separate argument.
MotionCommand: TypeAlias = (
    OrientHead | Attention | Thinking | Sleep | Idle | AntennaWiggle | EyeColor | Display
)


# ─────────────────────────────────────────────────────────────────────────
# The contract
# ─────────────────────────────────────────────────────────────────────────


class VisionUnsupportedError(NotImplementedError):
    """Raised by ``vision_frame`` on a body with no camera."""


class PresenceUnsupportedError(NotImplementedError):
    """Raised by ``presence`` on a body with no presence sensor."""


@runtime_checkable
class HALAdapter(Protocol):
    """What a body must provide for ChittiOS Core to run on it.

    Four capability groups. Audio and motion are required; vision and presence
    may raise their respective ``*UnsupportedError``. An identity-enabled
    deployment requires vision β€” Core checks this at startup rather than
    discovering it at first recognition.

    All methods are non-blocking in the sense that motion returns once the
    motion is *queued*, not once it completes. Expression depends on this:
    speech must never block on motion, nor motion on speech (spec Β§4.2).
    """

    async def audio_in_stream(self) -> AsyncIterator[AudioChunk]:
        """Yield captured audio until the stream is closed."""
        ...

    async def audio_out(self, chunks: AsyncIterator[AudioChunk]) -> None:
        """Play a stream of audio, returning when it has been consumed."""
        ...

    async def motion(self, command: MotionCommand) -> None:
        """Queue one motion primitive. Returns once queued, not once complete.

        Unsupported primitives are ignored silently β€” that is the contract,
        not a failure. A body without antennas is still a valid body.
        """
        ...

    async def vision_frame(self) -> ImageFrame | None:
        """Return the most recent camera frame, or ``None`` if none is ready.

        Raises:
            VisionUnsupportedError: if the body has no camera.
        """
        ...

    async def presence(self) -> Presence:
        """Return coarse presence.

        Raises:
            PresenceUnsupportedError: if the body has no presence sensor.
        """
        ...


def validate_command(command: MotionCommand) -> MotionPrimitive:
    """Return the primitive a command belongs to, checking it is well-formed.

    Adapters call this before dispatch so that a malformed command fails at
    the boundary with a clear message, rather than partway through a motion.
    """
    primitive = getattr(command, "primitive", None)
    if not isinstance(primitive, MotionPrimitive):
        raise TypeError(
            f"{type(command).__name__} is not a motion command: it declares no MotionPrimitive."
        )
    return primitive