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}
63
64#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
65#[serde(rename_all = "camelCase")]
66pub struct AudioBridgeCandidate {
67    pub device_id: String,
68    pub display_name: String,
69    pub transport: String,
70    pub features: Vec<AudioDeviceFeature>,
71}
72
73#[derive(Debug, Clone, Serialize, Deserialize)]
74#[serde(rename_all = "camelCase")]
75pub struct DeviceSnapshot {
76    pub microphone_permission: MicrophonePermission,
77    #[serde(default)]
78    pub devices: Vec<AudioBridgeDevice>,
79    #[serde(default)]
80    pub candidates: Vec<AudioBridgeCandidate>,
81}
82
83#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
84#[serde(rename_all = "camelCase")]
85pub enum SmartSpeakerRuntimeState {
86    Disabled,
87    Starting,
88    Listening,
89    PlaybackMuted,
90    Capturing,
91    Submitting,
92    WaitingForReply,
93    ReplyPlaying,
94    FollowUpListening,
95    InputOffline,
96    OutputOffline,
97    PermissionDenied,
98    ModelError,
99}
100
101#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
102#[serde(rename_all = "camelCase", deny_unknown_fields)]
103pub struct SmartSpeaker {
104    pub id: String,
105    pub display_name: String,
106    pub input_device_id: String,
107    pub output_device_id: String,
108    pub enabled: bool,
109    pub online: bool,
110    pub state: SmartSpeakerRuntimeState,
111    #[serde(default, skip_serializing_if = "Option::is_none")]
112    pub reason: Option<String>,
113}
114
115#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
116#[serde(rename_all = "camelCase", deny_unknown_fields)]
117pub struct SmartSpeakerSnapshot {
118    #[serde(default)]
119    pub smart_speakers: Vec<SmartSpeaker>,
120}
121
122#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
123#[serde(rename_all = "camelCase", deny_unknown_fields)]
124pub struct SmartSpeakerCreateRequest {
125    pub display_name: String,
126    pub input_device_id: String,
127    pub output_device_id: String,
128    #[serde(default = "default_true")]
129    pub enabled: bool,
130}
131
132#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
133#[serde(rename_all = "camelCase", deny_unknown_fields)]
134pub struct SmartSpeakerUpdateRequest {
135    pub id: String,
136    #[serde(default, skip_serializing_if = "Option::is_none")]
137    pub display_name: Option<String>,
138    #[serde(default, skip_serializing_if = "Option::is_none")]
139    pub input_device_id: Option<String>,
140    #[serde(default, skip_serializing_if = "Option::is_none")]
141    pub output_device_id: Option<String>,
142    #[serde(default, skip_serializing_if = "Option::is_none")]
143    pub enabled: Option<bool>,
144}
145
146#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
147#[serde(rename_all = "camelCase", deny_unknown_fields)]
148pub struct SmartSpeakerRemoveRequest {
149    pub id: String,
150}
151
152#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
153#[serde(rename_all = "camelCase", deny_unknown_fields)]
154pub struct SmartSpeakerMutationResponse {
155    pub changed: bool,
156    #[serde(default, skip_serializing_if = "Option::is_none")]
157    pub smart_speaker: Option<SmartSpeaker>,
158}
159
160pub use super::agent_gateway::ReplyRequest as AgentReplyRequest;
161
162const fn default_true() -> bool {
163    true
164}
165
166#[derive(Debug, Clone, Serialize, Deserialize)]
167#[serde(rename_all = "camelCase")]
168pub struct PairingResponse {
169    pub changed: bool,
170    pub device: AudioBridgeDevice,
171}
172
173#[cfg(test)]
174mod tests {
175    use super::*;
176
177    #[test]
178    fn manifest_and_rust_audiobridge_contract_agree() {
179        let manifest: serde_json::Value =
180            serde_json::from_str(include_str!("../../../manifest/core.v1.json")).unwrap();
181        let contract = &manifest["protocols"]["audioBridgePlugin"];
182        assert_eq!(contract["version"], VERSION);
183        assert_eq!(contract["nodeType"], NODE_TYPE);
184        assert_eq!(contract["appTargetPrefix"], APP_TARGET_PREFIX);
185        assert_eq!(contract["runtimeTargetPrefix"], RUNTIME_TARGET_PREFIX);
186        assert_eq!(contract["routes"]["deviceSnapshot"], DEVICE_SNAPSHOT);
187        assert_eq!(contract["routes"]["deviceRefresh"], DEVICE_REFRESH);
188        assert_eq!(contract["routes"]["devicePair"], DEVICE_PAIR);
189        assert_eq!(contract["routes"]["deviceUnpair"], DEVICE_UNPAIR);
190        assert_eq!(contract["routes"]["deviceTest"], DEVICE_TEST);
191        assert_eq!(contract["routes"]["deviceListenSet"], DEVICE_LISTEN_SET);
192        assert_eq!(
193            contract["routes"]["smartSpeakerSnapshot"],
194            SMART_SPEAKER_SNAPSHOT
195        );
196        assert_eq!(
197            contract["routes"]["smartSpeakerCreate"],
198            SMART_SPEAKER_CREATE
199        );
200        assert_eq!(
201            contract["routes"]["smartSpeakerUpdate"],
202            SMART_SPEAKER_UPDATE
203        );
204        assert_eq!(
205            contract["routes"]["smartSpeakerRemove"],
206            SMART_SPEAKER_REMOVE
207        );
208        assert_eq!(contract["agentReplyTarget"], AGENT_REPLY_TARGET);
209        assert_eq!(
210            manifest["protocols"]["agentGateway"]["submitTarget"],
211            crate::node::contracts::agent_gateway::SUBMIT_TARGET
212        );
213    }
214
215    #[test]
216    fn public_contract_does_not_expose_platform_identity() {
217        let value = serde_json::to_value(AudioBridgeDevice {
218            device_id: "device-1".to_string(),
219            display_name: "Living Room Speaker".to_string(),
220            transport: "bluetooth".to_string(),
221            features: vec![AudioDeviceFeature::Speaker, AudioDeviceFeature::Microphone],
222            online: true,
223            listen_enabled: false,
224        })
225        .expect("serialize device");
226        assert!(value.get("platformKey").is_none());
227        assert!(value.get("endpointId").is_none());
228        assert_eq!(
229            value["features"],
230            serde_json::json!(["speaker", "microphone"])
231        );
232    }
233
234    #[test]
235    fn device_snapshot_is_the_direct_response_payload() {
236        let value = serde_json::to_value(DeviceSnapshot {
237            microphone_permission: MicrophonePermission::NotDetermined,
238            devices: Vec::new(),
239            candidates: Vec::new(),
240        })
241        .expect("serialize device snapshot");
242        assert!(value.get("devices").is_some());
243        assert!(value.get("candidates").is_some());
244        assert!(value.get("snapshot").is_none());
245    }
246
247    #[test]
248    fn smart_speaker_contract_keeps_input_and_output_bindings_explicit() {
249        let value = serde_json::to_value(SmartSpeakerCreateRequest {
250            display_name: "Kitchen".to_string(),
251            input_device_id: "microphone-1".to_string(),
252            output_device_id: "speaker-1".to_string(),
253            enabled: true,
254        })
255        .expect("serialize smart speaker request");
256        assert_eq!(value["inputDeviceId"], "microphone-1");
257        assert_eq!(value["outputDeviceId"], "speaker-1");
258        assert_eq!(value["enabled"], true);
259    }
260}