Skip to main content

ryu_hardware/
protocol.rs

1//! Ryu Hardware Protocol (RHP) v1 — Rust mirror of the wire contract.
2//!
3//! This is the node-side implementation of the SAME contract defined in
4//! `apps/hardware/PROTOCOL.md` §3. The WebSocket handler
5//! (`apps/core/src/server/hardware_ws.rs`, wired in a later phase) (de)serializes
6//! these types on each TEXT frame; BINARY frames carry Opus/JPEG payloads and are
7//! not modeled here.
8//!
9//! Keep this in lockstep with the two sibling implementations:
10//!   - C    (firmware): apps/hardware/firmware/shared/protocol/include/rhp_protocol.h
11//!   - TS   (relay):    packages/protocol/src/hardware.ts
12//!
13//! Message `type` strings and field names are NORMATIVE. The serde attributes
14//! below are chosen so the emitted JSON matches the spec exactly:
15//!   - `#[serde(tag = "type", rename_all = "snake_case")]` makes the enum a
16//!     `{ "type": "..." }`-tagged union; snake_case yields `camera_meta`,
17//!     `hello_ack`, `tts_start`, `chat_delta`, etc.
18//!   - `Stt` renames to `stt`, `TtsStart`/`TtsEnd` to `tts_start`/`tts_end`.
19
20use serde::{Deserialize, Serialize};
21
22// ---------------------------------------------------------------------------
23// Enums
24// ---------------------------------------------------------------------------
25
26/// Physical device class. Wire: `watch` | `necklace` | `desk`.
27#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
28#[serde(rename_all = "snake_case")]
29pub enum DeviceType {
30    Watch,
31    Necklace,
32    Desk,
33}
34
35/// Wire string for a [`DeviceType`] (mirrors C `rhp_device_type_str`). Used by
36/// the SQLite registry, which stores the device class as its wire token rather
37/// than re-deriving it via serde.
38pub fn device_type_str(device_type: DeviceType) -> &'static str {
39    match device_type {
40        DeviceType::Watch => "watch",
41        DeviceType::Necklace => "necklace",
42        DeviceType::Desk => "desk",
43    }
44}
45
46/// Parse a [`DeviceType`] from its wire string (mirrors C `rhp_device_type_parse`).
47/// Returns `None` for any unrecognized value.
48pub fn parse_device_type(s: &str) -> Option<DeviceType> {
49    match s {
50        "watch" => Some(DeviceType::Watch),
51        "necklace" => Some(DeviceType::Necklace),
52        "desk" => Some(DeviceType::Desk),
53        _ => None,
54    }
55}
56
57impl DeviceType {
58    /// Whether this device class captures ambient audio for the 24/7 meeting
59    /// pipeline (PROTOCOL.md §4.2). The necklace + desk are always-on listeners;
60    /// the watch is interactive-only. The device's advertised `caps.mic` still
61    /// gates it at runtime — this is the class-level default.
62    pub fn ambient_capable(self) -> bool {
63        matches!(self, DeviceType::Necklace | DeviceType::Desk)
64    }
65}
66
67/// Device operating mode. Wire: `idle` | `chat` | `ambient`.
68#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
69#[serde(rename_all = "snake_case")]
70pub enum Mode {
71    Idle,
72    Chat,
73    Ambient,
74}
75
76/// Chat-turn boundary marker. Wire: `start` | `stop`.
77#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum ListenState {
80    Start,
81    Stop,
82}
83
84/// Face/emotion state driving the "Island eyes" renderer.
85/// Wire: `neutral` | `listening` | `thinking` | `happy` | `sad` | `surprised`
86///     | `speaking`.
87#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
88#[serde(rename_all = "snake_case")]
89pub enum Emotion {
90    Neutral,
91    Listening,
92    Thinking,
93    Happy,
94    Sad,
95    Surprised,
96    Speaking,
97}
98
99/// Display surface a `display` message targets. Wire: `eink` | `lcd`.
100#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum Surface {
103    Eink,
104    Lcd,
105}
106
107// ---------------------------------------------------------------------------
108// Shared sub-structs
109// ---------------------------------------------------------------------------
110
111/// Audio format descriptor (mic uplink in `hello`, TTS downlink in `hello_ack`).
112#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub struct AudioFormat {
114    /// Always `"opus"` in v1.
115    pub codec: String,
116    /// 16000 (mic uplink) or 24000 (TTS downlink).
117    pub sample_rate: u32,
118    /// 60 ms frames.
119    pub frame_ms: u32,
120}
121
122/// Capability profile a device advertises in `hello`.
123#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
124pub struct Caps {
125    pub display: bool,
126    pub camera: bool,
127    pub speaker: bool,
128    pub mic: bool,
129}
130
131// ---------------------------------------------------------------------------
132// Client -> Server (§3.1)
133// ---------------------------------------------------------------------------
134
135/// Every control message a device/relay sends to the node, tagged on `type`.
136#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
137#[serde(tag = "type", rename_all = "snake_case")]
138pub enum RhpClientMsg {
139    /// First frame on connect; identifies the device and its capabilities.
140    Hello {
141        device_id: String,
142        device_type: DeviceType,
143        fw_version: String,
144        /// True if tunneled via the phone (Mode A relay).
145        relay: bool,
146        audio: AudioFormat,
147        caps: Caps,
148    },
149    /// Device operating-mode change.
150    Mode { value: Mode },
151    /// Chat-turn boundary.
152    Listen { state: ListenState },
153    /// Typed/derived text input (fallback path).
154    Text { content: String },
155    /// Barge-in: stop current TTS + generation.
156    Abort,
157    /// Announces that the next BINARY frame is a JPEG of these dimensions.
158    CameraMeta {
159        w: u32,
160        h: u32,
161        fmt: String,
162        bytes: u32,
163    },
164    /// Periodic device telemetry.
165    Telemetry {
166        battery_pct: i32,
167        rssi: i32,
168        charging: bool,
169    },
170    /// Liveness probe.
171    Ping,
172}
173
174// ---------------------------------------------------------------------------
175// Server -> Client (§3.2)
176// ---------------------------------------------------------------------------
177
178/// Every control message the node sends to a device/relay, tagged on `type`.
179#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
180#[serde(tag = "type", rename_all = "snake_case")]
181pub enum RhpServerMsg {
182    /// Acknowledges `hello`; carries session ids and the TTS downlink format.
183    HelloAck {
184        session_id: String,
185        /// Present (long-running meeting id) only if the device is ambient-capable.
186        #[serde(skip_serializing_if = "Option::is_none", default)]
187        ambient_session_id: Option<String>,
188        tts: AudioFormat,
189    },
190    /// Live transcript of the user's speech (display it). `final_` serializes as
191    /// the wire name `final` (a Rust keyword), via the explicit field rename.
192    Stt {
193        text: String,
194        #[serde(rename = "final")]
195        final_: bool,
196    },
197    /// One streamed assistant-token chunk.
198    ChatDelta { text: String },
199    /// End of the streamed assistant turn.
200    ChatEnd { conversation_id: String },
201    /// Face state change.
202    Emotion { value: Emotion },
203    /// TTS audio is about to stream as BINARY Opus frames.
204    TtsStart,
205    /// End of the TTS audio stream.
206    TtsEnd,
207    /// An ambient chunk was transcribed and indexed.
208    AmbientAck { segment_id: String },
209    /// An ambient chunk was skipped (e.g. silence).
210    AmbientSkip { reason: String },
211    /// Desk ambient/e-ink display update. `payload` is widget-specific JSON.
212    Display {
213        surface: Surface,
214        widget: String,
215        payload: serde_json::Value,
216    },
217    /// Protocol or processing error.
218    Error { code: String, message: String },
219    /// Liveness response.
220    Pong,
221}
222
223// ---------------------------------------------------------------------------
224// Pairing & device-registry REST (§6)
225// ---------------------------------------------------------------------------
226
227/// POST /api/hardware/pair request body.
228#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
229pub struct PairRequest {
230    pub device_id: String,
231    pub pairing_nonce: String,
232    pub device_type: DeviceType,
233}
234
235/// POST /api/hardware/pair response body.
236#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
237pub struct PairResponse {
238    /// Per-device Bearer token used on the WS upgrade.
239    pub device_token: String,
240    /// The node's reachable URL the device should connect to.
241    pub node_url: String,
242}
243
244/// One entry in GET /api/hardware/devices.
245#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
246pub struct DeviceListItem {
247    pub device_id: String,
248    #[serde(rename = "type")]
249    pub device_type: DeviceType,
250    pub name: String,
251    /// Epoch milliseconds of last activity, or null if never seen.
252    pub last_seen: Option<i64>,
253    pub online: bool,
254    /// Latest reported battery percent, or null if unknown.
255    pub battery_pct: Option<i32>,
256}
257
258/// PATCH /api/hardware/devices/:id request body.
259#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
260pub struct DeviceUpdate {
261    #[serde(skip_serializing_if = "Option::is_none", default)]
262    pub name: Option<String>,
263    #[serde(skip_serializing_if = "Option::is_none", default)]
264    pub prefs: Option<serde_json::Value>,
265}
266
267#[cfg(test)]
268mod tests {
269    use super::*;
270
271    #[test]
272    fn enum_wire_strings() {
273        assert_eq!(
274            serde_json::to_string(&DeviceType::Necklace).unwrap(),
275            "\"necklace\""
276        );
277        assert_eq!(
278            serde_json::to_string(&Mode::Ambient).unwrap(),
279            "\"ambient\""
280        );
281        assert_eq!(
282            serde_json::to_string(&Emotion::Surprised).unwrap(),
283            "\"surprised\""
284        );
285        assert_eq!(serde_json::to_string(&Surface::Eink).unwrap(), "\"eink\"");
286    }
287
288    #[test]
289    fn client_hello_roundtrips() {
290        let raw = r#"{"type":"hello","device_id":"rhw_ab12","device_type":"watch","fw_version":"0.1.0","relay":false,"audio":{"codec":"opus","sample_rate":16000,"frame_ms":60},"caps":{"display":true,"camera":false,"speaker":true,"mic":true}}"#;
291        let msg: RhpClientMsg = serde_json::from_str(raw).unwrap();
292        match &msg {
293            RhpClientMsg::Hello {
294                device_id,
295                device_type,
296                ..
297            } => {
298                assert_eq!(device_id, "rhw_ab12");
299                assert_eq!(*device_type, DeviceType::Watch);
300            }
301            _ => panic!("expected hello"),
302        }
303    }
304
305    #[test]
306    fn camera_meta_tag_is_snake_case() {
307        let msg = RhpClientMsg::CameraMeta {
308            w: 640,
309            h: 480,
310            fmt: "jpeg".into(),
311            bytes: 18_234,
312        };
313        let json = serde_json::to_string(&msg).unwrap();
314        assert!(json.contains("\"type\":\"camera_meta\""), "{json}");
315    }
316
317    #[test]
318    fn server_stt_final_renames_to_keyword() {
319        let msg = RhpServerMsg::Stt {
320            text: "hello".into(),
321            final_: true,
322        };
323        let json = serde_json::to_string(&msg).unwrap();
324        assert!(json.contains("\"type\":\"stt\""), "{json}");
325        assert!(json.contains("\"final\":true"), "{json}");
326    }
327
328    #[test]
329    fn server_tts_and_pong_have_no_payload() {
330        assert_eq!(
331            serde_json::to_string(&RhpServerMsg::TtsStart).unwrap(),
332            "{\"type\":\"tts_start\"}"
333        );
334        assert_eq!(
335            serde_json::to_string(&RhpServerMsg::Pong).unwrap(),
336            "{\"type\":\"pong\"}"
337        );
338    }
339
340    #[test]
341    fn hello_ack_omits_absent_ambient_session() {
342        let msg = RhpServerMsg::HelloAck {
343            session_id: "s1".into(),
344            ambient_session_id: None,
345            tts: AudioFormat {
346                codec: "opus".into(),
347                sample_rate: 24_000,
348                frame_ms: 60,
349            },
350        };
351        let json = serde_json::to_string(&msg).unwrap();
352        assert!(!json.contains("ambient_session_id"), "{json}");
353    }
354
355    #[test]
356    fn device_list_item_uses_type_key() {
357        let item = DeviceListItem {
358            device_id: "d1".into(),
359            device_type: DeviceType::Desk,
360            name: "Desk".into(),
361            last_seen: Some(1_700_000_000_000),
362            online: true,
363            battery_pct: None,
364        };
365        let json = serde_json::to_string(&item).unwrap();
366        assert!(json.contains("\"type\":\"desk\""), "{json}");
367        assert!(json.contains("\"battery_pct\":null"), "{json}");
368    }
369}