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}