Skip to main content

sonos_api/
client.rs

1use crate::operation::{ComposableOperation, UPnPOperation};
2use crate::{ApiError, ManagedSubscription, Result, Service, SonosOperation};
3use soap_client::SoapClient;
4use std::time::Instant;
5
6/// A client for executing Sonos operations against actual devices
7///
8/// This client bridges the gap between the stateless operation definitions
9/// and actual network requests to Sonos speakers. It uses the soap-client
10/// crate to handle the underlying SOAP communication.
11///
12/// # Subscription Management
13///
14/// The primary API for managing UPnP event subscriptions is `create_managed_subscription()`,
15/// which returns a `ManagedSubscription` that handles all lifecycle management:
16///
17/// ```rust,no_run
18/// use sonos_api::{SonosClient, Service};
19///
20/// # fn main() -> sonos_api::Result<()> {
21/// let client = SonosClient::new();
22/// let subscription = client.create_managed_subscription(
23///     "192.168.1.100",
24///     Service::AVTransport,
25///     "http://callback.url",
26///     1800
27/// )?;
28///
29/// // Subscription handles renewal and cleanup automatically
30/// # Ok(())
31/// # }
32/// ```
33#[derive(Debug, Clone)]
34pub struct SonosClient {
35    soap_client: SoapClient,
36}
37
38impl SonosClient {
39    /// Create a new Sonos client using the shared SOAP client
40    ///
41    /// This uses the global shared SOAP client instance for maximum resource efficiency.
42    /// All SonosClient instances created this way share the same underlying HTTP client
43    /// and connection pool, reducing memory usage and improving performance.
44    pub fn new() -> Self {
45        Self {
46            soap_client: SoapClient::get().clone(),
47        }
48    }
49
50    /// Create a Sonos client with a custom SOAP client (for advanced use cases)
51    ///
52    /// Most applications should use `SonosClient::new()` instead. This method is
53    /// provided for cases where custom SOAP client configuration is needed.
54    pub fn with_soap_client(soap_client: SoapClient) -> Self {
55        Self { soap_client }
56    }
57
58    /// Execute a Sonos operation against a device
59    ///
60    /// This method takes any operation that implements `SonosOperation`,
61    /// constructs the appropriate SOAP request, sends it to the device,
62    /// and parses the response.
63    ///
64    /// # Arguments
65    /// * `ip` - The IP address of the Sonos device
66    /// * `request` - The operation request data
67    ///
68    /// # Returns
69    /// The parsed response data or an error
70    ///
71    /// # Example
72    /// ```rust,ignore
73    /// use sonos_api::client::SonosClient;
74    /// use sonos_api::services::av_transport::{GetTransportInfoOperation, GetTransportInfoRequest};
75    ///
76    /// let client = SonosClient::new();
77    /// let request = GetTransportInfoRequest { instance_id: 0 };
78    /// let response = client.execute::<GetTransportInfoOperation>("192.168.1.100", &request)?;
79    /// ```
80    pub fn execute<Op: SonosOperation>(
81        &self,
82        ip: &str,
83        request: &Op::Request,
84    ) -> Result<Op::Response> {
85        let service_info = Op::SERVICE.info();
86        let payload = Op::build_payload(request);
87
88        let xml = self
89            .soap_client
90            .call(
91                ip,
92                service_info.endpoint,
93                service_info.service_uri,
94                Op::ACTION,
95                &payload,
96            )
97            .map_err(|e| match e {
98                soap_client::SoapError::Network(msg) => ApiError::NetworkError(msg),
99                soap_client::SoapError::Parse(msg) => ApiError::ParseError(msg),
100                soap_client::SoapError::Fault(code) => ApiError::SoapFault(code),
101            })?;
102
103        Op::parse_response(&xml)
104    }
105
106    /// Execute an enhanced UPnP operation with composability features
107    ///
108    /// This method executes a ComposableOperation that was built using the new
109    /// enhanced operation framework with validation, retry policies, and timeouts.
110    ///
111    /// # Arguments
112    /// * `ip` - The IP address of the Sonos device
113    /// * `operation` - A ComposableOperation instance
114    ///
115    /// # Returns
116    /// The parsed response data or an error
117    ///
118    /// # Example
119    /// ```rust,ignore
120    /// use sonos_api::operation::{OperationBuilder, ValidationLevel};
121    /// use sonos_api::services::av_transport;
122    ///
123    /// let client = SonosClient::new();
124    /// let play_op = av_transport::play("1".to_string())
125    ///     .with_validation(ValidationLevel::Comprehensive)
126    ///     .build()?;
127    ///
128    /// let response = client.execute_enhanced("192.168.1.100", play_op)?;
129    /// ```
130    pub fn execute_enhanced<Op: UPnPOperation>(
131        &self,
132        ip: &str,
133        operation: ComposableOperation<Op>,
134    ) -> Result<Op::Response> {
135        // Apply timeout if specified
136        let start_time = Instant::now();
137
138        // Build payload (includes validation)
139        let payload = operation
140            .build_payload()
141            .map_err(|e| ApiError::ParseError(format!("Validation error: {e}")))?;
142
143        let service_info = Op::SERVICE.info();
144
145        // Check timeout before call
146        if let Some(timeout) = operation.timeout() {
147            if start_time.elapsed() >= timeout {
148                return Err(ApiError::NetworkError("Operation timeout".to_string()));
149            }
150        }
151
152        // Execute SOAP call
153        let xml = self
154            .soap_client
155            .call(
156                ip,
157                service_info.endpoint,
158                service_info.service_uri,
159                Op::ACTION,
160                &payload,
161            )
162            .map_err(|e| match e {
163                soap_client::SoapError::Network(msg) => ApiError::NetworkError(msg),
164                soap_client::SoapError::Parse(msg) => ApiError::ParseError(msg),
165                soap_client::SoapError::Fault(code) => ApiError::SoapFault(code),
166            })?;
167
168        operation.parse_response(&xml)
169    }
170
171    /// Subscribe to UPnP events from a service
172    ///
173    /// This creates a subscription to the specified service's event endpoint.
174    /// The device will then stream events (state changes) to the provided callback URL.
175    /// This is separate from control operations - subscriptions go to `/Event` endpoints
176    /// while control operations go to `/Control` endpoints.
177    ///
178    /// # Arguments
179    /// * `ip` - The IP address of the Sonos device
180    /// * `service` - The service to subscribe to (e.g., Service::AVTransport)
181    /// * `callback_url` - URL where the device will send event notifications
182    ///
183    /// # Returns
184    /// A managed subscription that handles lifecycle, renewal, and cleanup
185    ///
186    /// # Example
187    /// ```rust,ignore
188    /// use sonos_api::{SonosClient, Service};
189    ///
190    /// let client = SonosClient::new();
191    ///
192    /// // Subscribe to AVTransport events (play/pause state changes, etc.)
193    /// let subscription = client.subscribe(
194    ///     "192.168.1.100",
195    ///     Service::AVTransport,
196    ///     "http://192.168.1.50:8080/callback"
197    /// )?;
198    ///
199    /// // Now execute control operations separately
200    /// let play_op = av_transport::play("1".to_string()).build()?;
201    /// client.execute("192.168.1.100", play_op)?;
202    ///
203    /// // The subscription will receive events about the state changes
204    /// ```
205    pub fn subscribe(
206        &self,
207        ip: &str,
208        service: Service,
209        callback_url: &str,
210    ) -> Result<ManagedSubscription> {
211        self.create_managed_subscription(ip, service, callback_url, 1800)
212    }
213
214    /// Subscribe to UPnP events with custom timeout
215    ///
216    /// Same as `subscribe()` but allows specifying a custom timeout for the subscription.
217    ///
218    /// # Arguments
219    /// * `ip` - The IP address of the Sonos device
220    /// * `service` - The service to subscribe to
221    /// * `callback_url` - URL where the device will send event notifications
222    /// * `timeout_seconds` - How long the subscription should last (max: 86400 = 24 hours)
223    ///
224    /// # Returns
225    /// A managed subscription that handles lifecycle, renewal, and cleanup
226    pub fn subscribe_with_timeout(
227        &self,
228        ip: &str,
229        service: Service,
230        callback_url: &str,
231        timeout_seconds: u32,
232    ) -> Result<ManagedSubscription> {
233        self.create_managed_subscription(ip, service, callback_url, timeout_seconds)
234    }
235
236    /// Create a managed subscription with lifecycle management
237    ///
238    /// This method creates a UPnP subscription and returns a `ManagedSubscription`
239    /// that provides lifecycle management methods.
240    ///
241    /// # Arguments
242    /// * `ip` - The IP address of the Sonos device
243    /// * `service` - The service to subscribe to
244    /// * `callback_url` - The URL where events should be sent
245    /// * `timeout_seconds` - Initial timeout for the subscription
246    ///
247    /// # Returns
248    /// A `ManagedSubscription` that provides renewal and cleanup methods
249    ///
250    /// # Example
251    /// ```rust,no_run
252    /// use sonos_api::{SonosClient, Service};
253    ///
254    /// # fn main() -> sonos_api::Result<()> {
255    /// let client = SonosClient::new();
256    /// let subscription = client.create_managed_subscription(
257    ///     "192.168.1.100",
258    ///     Service::AVTransport,
259    ///     "http://192.168.1.50:8080/callback",
260    ///     1800
261    /// )?;
262    ///
263    /// // Check if renewal is needed and renew if so
264    /// if subscription.needs_renewal() {
265    ///     subscription.renew()?;
266    /// }
267    ///
268    /// // Clean up when done
269    /// subscription.unsubscribe()?;
270    /// # Ok(())
271    /// # }
272    /// ```
273    pub fn create_managed_subscription(
274        &self,
275        ip: &str,
276        service: Service,
277        callback_url: &str,
278        timeout_seconds: u32,
279    ) -> Result<ManagedSubscription> {
280        ManagedSubscription::create(
281            ip.to_string(),
282            service,
283            callback_url.to_string(),
284            timeout_seconds,
285            self.soap_client.clone(),
286        )
287    }
288}
289
290impl Default for SonosClient {
291    fn default() -> Self {
292        Self::new()
293    }
294}
295
296#[cfg(test)]
297mod tests {
298    use super::*;
299
300    #[test]
301    fn test_client_creation() {
302        let _client = SonosClient::new();
303        let _default_client = SonosClient::default();
304    }
305
306    #[test]
307    fn test_subscription_methods_signature() {
308        // Test that subscription methods have correct signatures
309        let _client = SonosClient::new();
310
311        // Test that the methods exist and have correct signatures by creating function pointers
312        let _subscribe_fn: fn(&SonosClient, &str, Service, &str) -> Result<ManagedSubscription> =
313            SonosClient::subscribe;
314
315        let _subscribe_timeout_fn: fn(
316            &SonosClient,
317            &str,
318            Service,
319            &str,
320            u32,
321        ) -> Result<ManagedSubscription> = SonosClient::subscribe_with_timeout;
322    }
323
324    #[test]
325    fn test_subscription_parameters() {
326        // Test that we can create the parameters needed for subscription calls
327        let _ip = "192.168.1.100";
328        let _service = Service::AVTransport;
329        let _callback_url = "http://callback.url";
330        let _timeout = 3600u32;
331
332        // Verify Service enum has the variants we need
333        assert_eq!(Service::AVTransport as i32, Service::AVTransport as i32);
334        assert_eq!(
335            Service::RenderingControl as i32,
336            Service::RenderingControl as i32
337        );
338    }
339
340    #[test]
341    fn test_subscription_delegates_to_create_managed() {
342        // Test that subscribe() correctly delegates to create_managed_subscription
343        let _client = SonosClient::new();
344
345        // We can't test the actual execution without a real device,
346        // but we can verify the methods compile and have correct signatures
347        let _subscription_fn = |client: &SonosClient| {
348            client.subscribe("192.168.1.100", Service::AVTransport, "http://callback")
349        };
350
351        let _timeout_subscription_fn = |client: &SonosClient| {
352            client.subscribe_with_timeout(
353                "192.168.1.100",
354                Service::AVTransport,
355                "http://callback",
356                1800,
357            )
358        };
359    }
360}