1use serde::{Deserialize, Serialize};
21
22#[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
35pub 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
46pub 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 pub fn ambient_capable(self) -> bool {
63 matches!(self, DeviceType::Necklace | DeviceType::Desk)
64 }
65}
66
67#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
78#[serde(rename_all = "snake_case")]
79pub enum ListenState {
80 Start,
81 Stop,
82}
83
84#[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#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
101#[serde(rename_all = "snake_case")]
102pub enum Surface {
103 Eink,
104 Lcd,
105}
106
107#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
113pub struct AudioFormat {
114 pub codec: String,
116 pub sample_rate: u32,
118 pub frame_ms: u32,
120}
121
122#[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#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
137#[serde(tag = "type", rename_all = "snake_case")]
138pub enum RhpClientMsg {
139 Hello {
141 device_id: String,
142 device_type: DeviceType,
143 fw_version: String,
144 relay: bool,
146 audio: AudioFormat,
147 caps: Caps,
148 },
149 Mode { value: Mode },
151 Listen { state: ListenState },
153 Text { content: String },
155 Abort,
157 CameraMeta {
159 w: u32,
160 h: u32,
161 fmt: String,
162 bytes: u32,
163 },
164 Telemetry {
166 battery_pct: i32,
167 rssi: i32,
168 charging: bool,
169 },
170 Ping,
172}
173
174#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
180#[serde(tag = "type", rename_all = "snake_case")]
181pub enum RhpServerMsg {
182 HelloAck {
184 session_id: String,
185 #[serde(skip_serializing_if = "Option::is_none", default)]
187 ambient_session_id: Option<String>,
188 tts: AudioFormat,
189 },
190 Stt {
193 text: String,
194 #[serde(rename = "final")]
195 final_: bool,
196 },
197 ChatDelta { text: String },
199 ChatEnd { conversation_id: String },
201 Emotion { value: Emotion },
203 TtsStart,
205 TtsEnd,
207 AmbientAck { segment_id: String },
209 AmbientSkip { reason: String },
211 Display {
213 surface: Surface,
214 widget: String,
215 payload: serde_json::Value,
216 },
217 Error { code: String, message: String },
219 Pong,
221}
222
223#[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#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
237pub struct PairResponse {
238 pub device_token: String,
240 pub node_url: String,
242}
243
244#[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 pub last_seen: Option<i64>,
253 pub online: bool,
254 pub battery_pct: Option<i32>,
256}
257
258#[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}