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}