Skip to main content

core_api/node/contracts/
audiobridge.rs

1use serde::{Deserialize, Serialize};
2
3pub const VERSION: &str = crate::node::V1;
4pub const NODE_TYPE: &str = "audiobridge";
5pub const APP_TARGET_PREFIX: &str = "/app/plugin/audiobridge/";
6pub const RUNTIME_TARGET_PREFIX: &str = "/audiobridge/app/";
7pub const DEVICE_SNAPSHOT: &str = "device/snapshot";
8pub const DEVICE_REFRESH: &str = "device/refresh";
9pub const DEVICE_PAIR: &str = "device/pair";
10pub const DEVICE_UNPAIR: &str = "device/unpair";
11pub const DEVICE_TEST: &str = "device/test";
12pub const DEVICE_LISTEN_SET: &str = "device/listen";
13pub const SMART_SPEAKER_SNAPSHOT: &str = "smart-speaker/snapshot";
14pub const SMART_SPEAKER_CREATE: &str = "smart-speaker/create";
15pub const SMART_SPEAKER_UPDATE: &str = "smart-speaker/update";
16pub const SMART_SPEAKER_REMOVE: &str = "smart-speaker/remove";
17pub const AGENT_REPLY_TARGET: &str = "/audiobridge/agent/reply";
18
19#[derive(Debug, Clone, Default, Serialize, Deserialize)]
20#[serde(rename_all = "camelCase", deny_unknown_fields)]
21pub struct EmptyRequest {}
22
23#[derive(Debug, Clone, Serialize, Deserialize)]
24#[serde(rename_all = "camelCase", deny_unknown_fields)]
25pub struct DeviceRequest {
26    pub device_id: String,
27}
28
29#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
30#[serde(rename_all = "camelCase", deny_unknown_fields)]
31pub struct DeviceListenSetRequest {
32    pub device_id: String,
33    pub enabled: bool,
34}
35
36#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
37#[serde(rename_all = "lowercase")]
38pub enum AudioDeviceFeature {
39    Speaker,
40    Microphone,
41}
42
43#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
44#[serde(rename_all = "camelCase")]
45pub enum MicrophonePermission {
46    NotDetermined,
47    Granted,
48    Denied,
49    Restricted,
50    Unavailable,
51}
52
53#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
54#[serde(rename_all = "camelCase")]
55pub struct AudioBridgeDevice {
56    pub device_id: String,
57    pub display_name: String,
58    pub transport: String,
59    pub features: Vec<AudioDeviceFeature>,
60    pub online: bool,
61    pub listen_enabled: bool,
62    pub listen_runtime: ListenRuntimeStatus,
63}
64
65#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
66#[serde(rename_all = "camelCase")]
67pub enum ListenRuntimeState {
68    Disabled,
69    Starting,
70    Listening,
71    Failed,
72}
73
74#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
75#[serde(rename_all = "camelCase")]
76pub struct ListenRuntimeStatus {
77    pub state: ListenRuntimeState,
78    #[serde(skip_serializing_if = "Option::is_none")]
79    pub reason: Option<String>,
80}
81
82#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
83#[serde(rename_all = "camelCase")]
84pub struct AudioBridgeCandidate {
85    pub device_id: String,
86    pub display_name: String,
87    pub transport: String,
88    pub features: Vec<AudioDeviceFeature>,
89}
90
91#[derive(Debug, Clone, Serialize, Deserialize)]
92#[serde(rename_all = "camelCase")]
93pub struct DeviceSnapshot {
94    pub microphone_permission: MicrophonePermission,
95    #[serde(default)]
96    pub devices: Vec<AudioBridgeDevice>,
97    #[serde(default)]
98    pub candidates: Vec<AudioBridgeCandidate>,
99}
100
101#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "camelCase")]
103pub enum SmartSpeakerRuntimeState {
104    Disabled,
105    Starting,
106    Listening,
107    PlaybackMuted,
108    Capturing,
109    Submitting,
110    WaitingForReply,
111    ReplyPlaying,
112    FollowUpListening,
113    InputOffline,
114    OutputOffline,
115    PermissionDenied,
116    ModelError,
117    RuntimeError,
118}
119
120#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
121#[serde(rename_all = "camelCase", deny_unknown_fields)]
122pub struct SmartSpeaker {
123    pub id: String,
124    pub display_name: String,
125    pub input_device_id: String,
126    pub output_device_id: String,
127    pub enabled: bool,
128    pub online: bool,
129    pub state: SmartSpeakerRuntimeState,
130    #[serde(default, skip_serializing_if = "Option::is_none")]
131    pub reason: Option<String>,
132}
133
134#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
135#[serde(rename_all = "camelCase", deny_unknown_fields)]
136pub struct SmartSpeakerSnapshot {
137    #[serde(default)]
138    pub smart_speakers: Vec<SmartSpeaker>,
139}
140
141#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
142#[serde(rename_all = "camelCase", deny_unknown_fields)]
143pub struct SmartSpeakerCreateRequest {
144    pub display_name: String,
145    pub input_device_id: String,
146    pub output_device_id: String,
147    #[serde(default = "default_true")]
148    pub enabled: bool,
149}
150
151#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
152#[serde(rename_all = "camelCase", deny_unknown_fields)]
153pub struct SmartSpeakerUpdateRequest {
154    pub id: String,
155    #[serde(default, skip_serializing_if = "Option::is_none")]
156    pub display_name: Option<String>,
157    #[serde(default, skip_serializing_if = "Option::is_none")]
158    pub input_device_id: Option<String>,
159    #[serde(default, skip_serializing_if = "Option::is_none")]
160    pub output_device_id: Option<String>,
161    #[serde(default, skip_serializing_if = "Option::is_none")]
162    pub enabled: Option<bool>,
163}
164
165#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
166#[serde(rename_all = "camelCase", deny_unknown_fields)]
167pub struct SmartSpeakerRemoveRequest {
168    pub id: String,
169}
170
171#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
172#[serde(rename_all = "camelCase", deny_unknown_fields)]
173pub struct SmartSpeakerMutationResponse {
174    pub changed: bool,
175    #[serde(default, skip_serializing_if = "Option::is_none")]
176    pub smart_speaker: Option<SmartSpeaker>,
177}
178
179pub use super::agent_gateway::ReplyRequest as AgentReplyRequest;
180
181const fn default_true() -> bool {
182    true
183}
184
185#[derive(Debug, Clone, Serialize, Deserialize)]
186#[serde(rename_all = "camelCase")]
187pub struct PairingResponse {
188    pub changed: bool,
189    pub device: AudioBridgeDevice,
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195
196    #[test]
197    fn manifest_and_rust_audiobridge_contract_agree() {
198        let manifest: serde_json::Value =
199            serde_json::from_str(include_str!("../../../manifest/core.v1.json")).unwrap();
200        let contract = &manifest["protocols"]["audioBridgePlugin"];
201        assert_eq!(contract["version"], VERSION);
202        assert_eq!(contract["nodeType"], NODE_TYPE);
203        assert_eq!(contract["appTargetPrefix"], APP_TARGET_PREFIX);
204        assert_eq!(contract["runtimeTargetPrefix"], RUNTIME_TARGET_PREFIX);
205        assert_eq!(contract["routes"]["deviceSnapshot"], DEVICE_SNAPSHOT);
206        assert_eq!(contract["routes"]["deviceRefresh"], DEVICE_REFRESH);
207        assert_eq!(contract["routes"]["devicePair"], DEVICE_PAIR);
208        assert_eq!(contract["routes"]["deviceUnpair"], DEVICE_UNPAIR);
209        assert_eq!(contract["routes"]["deviceTest"], DEVICE_TEST);
210        assert_eq!(contract["routes"]["deviceListenSet"], DEVICE_LISTEN_SET);
211        assert_eq!(
212            contract["routes"]["smartSpeakerSnapshot"],
213            SMART_SPEAKER_SNAPSHOT
214        );
215        assert_eq!(
216            contract["routes"]["smartSpeakerCreate"],
217            SMART_SPEAKER_CREATE
218        );
219        assert_eq!(
220            contract["routes"]["smartSpeakerUpdate"],
221            SMART_SPEAKER_UPDATE
222        );
223        assert_eq!(
224            contract["routes"]["smartSpeakerRemove"],
225            SMART_SPEAKER_REMOVE
226        );
227        assert_eq!(contract["agentReplyTarget"], AGENT_REPLY_TARGET);
228        assert_eq!(
229            manifest["protocols"]["agentGateway"]["submitTarget"],
230            crate::node::contracts::agent_gateway::SUBMIT_TARGET
231        );
232    }
233
234    #[test]
235    fn public_contract_does_not_expose_platform_identity() {
236        let value = serde_json::to_value(AudioBridgeDevice {
237            device_id: "device-1".to_string(),
238            display_name: "Living Room Speaker".to_string(),
239            transport: "bluetooth".to_string(),
240            features: vec![AudioDeviceFeature::Speaker, AudioDeviceFeature::Microphone],
241            online: true,
242            listen_enabled: false,
243            listen_runtime: ListenRuntimeStatus {
244                state: ListenRuntimeState::Disabled,
245                reason: None,
246            },
247        })
248        .expect("serialize device");
249        assert!(value.get("platformKey").is_none());
250        assert!(value.get("endpointId").is_none());
251        assert_eq!(value["listenRuntime"]["state"], "disabled");
252        assert_eq!(
253            value["features"],
254            serde_json::json!(["speaker", "microphone"])
255        );
256    }
257
258    #[test]
259    fn runtime_failures_are_not_model_or_device_failures() {
260        assert_eq!(
261            serde_json::to_value(SmartSpeakerRuntimeState::RuntimeError).unwrap(),
262            "runtimeError"
263        );
264        let status = ListenRuntimeStatus {
265            state: ListenRuntimeState::Failed,
266            reason: Some("input_stream_unavailable".into()),
267        };
268        let value = serde_json::to_value(&status).unwrap();
269        assert_eq!(value["state"], "failed");
270        assert_eq!(
271            serde_json::from_value::<ListenRuntimeStatus>(value).unwrap(),
272            status
273        );
274    }
275
276    #[test]
277    fn device_snapshot_is_the_direct_response_payload() {
278        let value = serde_json::to_value(DeviceSnapshot {
279            microphone_permission: MicrophonePermission::NotDetermined,
280            devices: Vec::new(),
281            candidates: Vec::new(),
282        })
283        .expect("serialize device snapshot");
284        assert!(value.get("devices").is_some());
285        assert!(value.get("candidates").is_some());
286        assert!(value.get("snapshot").is_none());
287    }
288
289    #[test]
290    fn smart_speaker_contract_keeps_input_and_output_bindings_explicit() {
291        let value = serde_json::to_value(SmartSpeakerCreateRequest {
292            display_name: "Kitchen".to_string(),
293            input_device_id: "microphone-1".to_string(),
294            output_device_id: "speaker-1".to_string(),
295            enabled: true,
296        })
297        .expect("serialize smart speaker request");
298        assert_eq!(value["inputDeviceId"], "microphone-1");
299        assert_eq!(value["outputDeviceId"], "speaker-1");
300        assert_eq!(value["enabled"], true);
301    }
302}