use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum DeviceType {
Watch,
Necklace,
Desk,
}
pub fn device_type_str(device_type: DeviceType) -> &'static str {
match device_type {
DeviceType::Watch => "watch",
DeviceType::Necklace => "necklace",
DeviceType::Desk => "desk",
}
}
pub fn parse_device_type(s: &str) -> Option<DeviceType> {
match s {
"watch" => Some(DeviceType::Watch),
"necklace" => Some(DeviceType::Necklace),
"desk" => Some(DeviceType::Desk),
_ => None,
}
}
impl DeviceType {
pub fn ambient_capable(self) -> bool {
matches!(self, DeviceType::Necklace | DeviceType::Desk)
}
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Mode {
Idle,
Chat,
Ambient,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum ListenState {
Start,
Stop,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Emotion {
Neutral,
Listening,
Thinking,
Happy,
Sad,
Surprised,
Speaking,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Surface {
Eink,
Lcd,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AudioFormat {
pub codec: String,
pub sample_rate: u32,
pub frame_ms: u32,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct Caps {
pub display: bool,
pub camera: bool,
pub speaker: bool,
pub mic: bool,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RhpClientMsg {
Hello {
device_id: String,
device_type: DeviceType,
fw_version: String,
relay: bool,
audio: AudioFormat,
caps: Caps,
},
Mode { value: Mode },
Listen { state: ListenState },
Text { content: String },
Abort,
CameraMeta {
w: u32,
h: u32,
fmt: String,
bytes: u32,
},
Telemetry {
battery_pct: i32,
rssi: i32,
charging: bool,
},
Ping,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
pub enum RhpServerMsg {
HelloAck {
session_id: String,
#[serde(skip_serializing_if = "Option::is_none", default)]
ambient_session_id: Option<String>,
tts: AudioFormat,
},
Stt {
text: String,
#[serde(rename = "final")]
final_: bool,
},
ChatDelta { text: String },
ChatEnd { conversation_id: String },
Emotion { value: Emotion },
TtsStart,
TtsEnd,
AmbientAck { segment_id: String },
AmbientSkip { reason: String },
Display {
surface: Surface,
widget: String,
payload: serde_json::Value,
},
Error { code: String, message: String },
Pong,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairRequest {
pub device_id: String,
pub pairing_nonce: String,
pub device_type: DeviceType,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct PairResponse {
pub device_token: String,
pub node_url: String,
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct DeviceListItem {
pub device_id: String,
#[serde(rename = "type")]
pub device_type: DeviceType,
pub name: String,
pub last_seen: Option<i64>,
pub online: bool,
pub battery_pct: Option<i32>,
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct DeviceUpdate {
#[serde(skip_serializing_if = "Option::is_none", default)]
pub name: Option<String>,
#[serde(skip_serializing_if = "Option::is_none", default)]
pub prefs: Option<serde_json::Value>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn enum_wire_strings() {
assert_eq!(
serde_json::to_string(&DeviceType::Necklace).unwrap(),
"\"necklace\""
);
assert_eq!(
serde_json::to_string(&Mode::Ambient).unwrap(),
"\"ambient\""
);
assert_eq!(
serde_json::to_string(&Emotion::Surprised).unwrap(),
"\"surprised\""
);
assert_eq!(serde_json::to_string(&Surface::Eink).unwrap(), "\"eink\"");
}
#[test]
fn client_hello_roundtrips() {
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}}"#;
let msg: RhpClientMsg = serde_json::from_str(raw).unwrap();
match &msg {
RhpClientMsg::Hello {
device_id,
device_type,
..
} => {
assert_eq!(device_id, "rhw_ab12");
assert_eq!(*device_type, DeviceType::Watch);
}
_ => panic!("expected hello"),
}
}
#[test]
fn camera_meta_tag_is_snake_case() {
let msg = RhpClientMsg::CameraMeta {
w: 640,
h: 480,
fmt: "jpeg".into(),
bytes: 18_234,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"type\":\"camera_meta\""), "{json}");
}
#[test]
fn server_stt_final_renames_to_keyword() {
let msg = RhpServerMsg::Stt {
text: "hello".into(),
final_: true,
};
let json = serde_json::to_string(&msg).unwrap();
assert!(json.contains("\"type\":\"stt\""), "{json}");
assert!(json.contains("\"final\":true"), "{json}");
}
#[test]
fn server_tts_and_pong_have_no_payload() {
assert_eq!(
serde_json::to_string(&RhpServerMsg::TtsStart).unwrap(),
"{\"type\":\"tts_start\"}"
);
assert_eq!(
serde_json::to_string(&RhpServerMsg::Pong).unwrap(),
"{\"type\":\"pong\"}"
);
}
#[test]
fn hello_ack_omits_absent_ambient_session() {
let msg = RhpServerMsg::HelloAck {
session_id: "s1".into(),
ambient_session_id: None,
tts: AudioFormat {
codec: "opus".into(),
sample_rate: 24_000,
frame_ms: 60,
},
};
let json = serde_json::to_string(&msg).unwrap();
assert!(!json.contains("ambient_session_id"), "{json}");
}
#[test]
fn device_list_item_uses_type_key() {
let item = DeviceListItem {
device_id: "d1".into(),
device_type: DeviceType::Desk,
name: "Desk".into(),
last_seen: Some(1_700_000_000_000),
online: true,
battery_pct: None,
};
let json = serde_json::to_string(&item).unwrap();
assert!(json.contains("\"type\":\"desk\""), "{json}");
assert!(json.contains("\"battery_pct\":null"), "{json}");
}
}