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.soap_client.call(
89            ip,
90            service_info.endpoint,
91            service_info.service_uri,
92            Op::ACTION,
93            &payload,
94        )?;
95
96        Op::parse_response(&xml)
97    }
98
99    /// Execute an enhanced UPnP operation with composability features
100    ///
101    /// This method executes a ComposableOperation that was built using the new
102    /// enhanced operation framework with validation, retry policies, and timeouts.
103    ///
104    /// # Arguments
105    /// * `ip` - The IP address of the Sonos device
106    /// * `operation` - A ComposableOperation instance
107    ///
108    /// # Returns
109    /// The parsed response data or an error
110    ///
111    /// # Example
112    /// ```rust,ignore
113    /// use sonos_api::operation::{OperationBuilder, ValidationLevel};
114    /// use sonos_api::services::av_transport;
115    ///
116    /// let client = SonosClient::new();
117    /// let play_op = av_transport::play("1".to_string())
118    ///     .with_validation(ValidationLevel::Comprehensive)
119    ///     .build()?;
120    ///
121    /// let response = client.execute_enhanced("192.168.1.100", play_op)?;
122    /// ```
123    pub fn execute_enhanced<Op: UPnPOperation>(
124        &self,
125        ip: &str,
126        operation: ComposableOperation<Op>,
127    ) -> Result<Op::Response> {
128        // Apply timeout if specified
129        let start_time = Instant::now();
130
131        // Build payload (includes validation)
132        let payload = operation
133            .build_payload()
134            .map_err(|e| ApiError::ParseError(format!("Validation error: {e}")))?;
135
136        let service_info = Op::SERVICE.info();
137
138        // Check timeout before call
139        if let Some(timeout) = operation.timeout() {
140            if start_time.elapsed() >= timeout {
141                return Err(ApiError::NetworkError("Operation timeout".to_string()));
142            }
143        }
144
145        // Execute SOAP call
146        let xml = self.soap_client.call(
147            ip,
148            service_info.endpoint,
149            service_info.service_uri,
150            Op::ACTION,
151            &payload,
152        )?;
153
154        operation.parse_response(&xml)
155    }
156
157    /// Subscribe to UPnP events from a service
158    ///
159    /// This creates a subscription to the specified service's event endpoint.
160    /// The device will then stream events (state changes) to the provided callback URL.
161    /// This is separate from control operations - subscriptions go to `/Event` endpoints
162    /// while control operations go to `/Control` endpoints.
163    ///
164    /// # Arguments
165    /// * `ip` - The IP address of the Sonos device
166    /// * `service` - The service to subscribe to (e.g., Service::AVTransport)
167    /// * `callback_url` - URL where the device will send event notifications
168    ///
169    /// # Returns
170    /// A managed subscription that handles lifecycle, renewal, and cleanup
171    ///
172    /// # Example
173    /// ```rust,ignore
174    /// use sonos_api::{SonosClient, Service};
175    ///
176    /// let client = SonosClient::new();
177    ///
178    /// // Subscribe to AVTransport events (play/pause state changes, etc.)
179    /// let subscription = client.subscribe(
180    ///     "192.168.1.100",
181    ///     Service::AVTransport,
182    ///     "http://192.168.1.50:8080/callback"
183    /// )?;
184    ///
185    /// // Now execute control operations separately
186    /// let play_op = av_transport::play("1".to_string()).build()?;
187    /// client.execute("192.168.1.100", play_op)?;
188    ///
189    /// // The subscription will receive events about the state changes
190    /// ```
191    pub fn subscribe(
192        &self,
193        ip: &str,
194        service: Service,
195        callback_url: &str,
196    ) -> Result<ManagedSubscription> {
197        self.create_managed_subscription(ip, service, callback_url, 1800)
198    }
199
200    /// Subscribe to UPnP events with custom timeout
201    ///
202    /// Same as `subscribe()` but allows specifying a custom timeout for the subscription.
203    ///
204    /// # Arguments
205    /// * `ip` - The IP address of the Sonos device
206    /// * `service` - The service to subscribe to
207    /// * `callback_url` - URL where the device will send event notifications
208    /// * `timeout_seconds` - How long the subscription should last (max: 86400 = 24 hours)
209    ///
210    /// # Returns
211    /// A managed subscription that handles lifecycle, renewal, and cleanup
212    pub fn subscribe_with_timeout(
213        &self,
214        ip: &str,
215        service: Service,
216        callback_url: &str,
217        timeout_seconds: u32,
218    ) -> Result<ManagedSubscription> {
219        self.create_managed_subscription(ip, service, callback_url, timeout_seconds)
220    }
221
222    /// Create a managed subscription with lifecycle management
223    ///
224    /// This method creates a UPnP subscription and returns a `ManagedSubscription`
225    /// that provides lifecycle management methods.
226    ///
227    /// # Arguments
228    /// * `ip` - The IP address of the Sonos device
229    /// * `service` - The service to subscribe to
230    /// * `callback_url` - The URL where events should be sent
231    /// * `timeout_seconds` - Initial timeout for the subscription
232    ///
233    /// # Returns
234    /// A `ManagedSubscription` that provides renewal and cleanup methods
235    ///
236    /// # Example
237    /// ```rust,no_run
238    /// use sonos_api::{SonosClient, Service};
239    ///
240    /// # fn main() -> sonos_api::Result<()> {
241    /// let client = SonosClient::new();
242    /// let subscription = client.create_managed_subscription(
243    ///     "192.168.1.100",
244    ///     Service::AVTransport,
245    ///     "http://192.168.1.50:8080/callback",
246    ///     1800
247    /// )?;
248    ///
249    /// // Check if renewal is needed and renew if so
250    /// if subscription.needs_renewal() {
251    ///     subscription.renew()?;
252    /// }
253    ///
254    /// // Clean up when done
255    /// subscription.unsubscribe()?;
256    /// # Ok(())
257    /// # }
258    /// ```
259    pub fn create_managed_subscription(
260        &self,
261        ip: &str,
262        service: Service,
263        callback_url: &str,
264        timeout_seconds: u32,
265    ) -> Result<ManagedSubscription> {
266        ManagedSubscription::create(
267            ip.to_string(),
268            service,
269            callback_url.to_string(),
270            timeout_seconds,
271            self.soap_client.clone(),
272        )
273    }
274}
275
276impl Default for SonosClient {
277    fn default() -> Self {
278        Self::new()
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285
286    #[test]
287    fn test_client_creation() {
288        let _client = SonosClient::new();
289        let _default_client = SonosClient::default();
290    }
291
292    #[test]
293    fn test_subscription_methods_signature() {
294        // Test that subscription methods have correct signatures
295        let _client = SonosClient::new();
296
297        // Test that the methods exist and have correct signatures by creating function pointers
298        let _subscribe_fn: fn(&SonosClient, &str, Service, &str) -> Result<ManagedSubscription> =
299            SonosClient::subscribe;
300
301        let _subscribe_timeout_fn: fn(
302            &SonosClient,
303            &str,
304            Service,
305            &str,
306            u32,
307        ) -> Result<ManagedSubscription> = SonosClient::subscribe_with_timeout;
308    }
309
310    #[test]
311    fn test_subscription_parameters() {
312        // Test that we can create the parameters needed for subscription calls
313        let _ip = "192.168.1.100";
314        let _service = Service::AVTransport;
315        let _callback_url = "http://callback.url";
316        let _timeout = 3600u32;
317
318        // Verify Service enum has the variants we need
319        assert_eq!(Service::AVTransport as i32, Service::AVTransport as i32);
320        assert_eq!(
321            Service::RenderingControl as i32,
322            Service::RenderingControl as i32
323        );
324    }
325
326    #[test]
327    fn test_subscription_delegates_to_create_managed() {
328        // Test that subscribe() correctly delegates to create_managed_subscription
329        let _client = SonosClient::new();
330
331        // We can't test the actual execution without a real device,
332        // but we can verify the methods compile and have correct signatures
333        let _subscription_fn = |client: &SonosClient| {
334            client.subscribe("192.168.1.100", Service::AVTransport, "http://callback")
335        };
336
337        let _timeout_subscription_fn = |client: &SonosClient| {
338            client.subscribe_with_timeout(
339                "192.168.1.100",
340                Service::AVTransport,
341                "http://callback",
342                1800,
343            )
344        };
345    }
346}