sonos_api/subscription.rs
1//! Managed UPnP subscription with lifecycle management
2//!
3//! This module provides a higher-level subscription API that handles the complete
4//! lifecycle of UPnP subscriptions with manual renewal and proper cleanup.
5
6use crate::services::events::{
7 RenewOperation, RenewRequest, RenewResponse, SubscribeOperation, SubscribeRequest,
8 UnsubscribeOperation, UnsubscribeRequest, UnsubscribeResponse,
9};
10use crate::{ApiError, Result, Service};
11use soap_client::SoapClient;
12use std::sync::{Arc, Mutex};
13use std::time::{Duration, SystemTime};
14
15/// A managed UPnP subscription with lifecycle management
16///
17/// This struct wraps the low-level subscription operations and provides:
18/// - Expiration tracking
19/// - Manual renewal with proper state updates
20/// - Proper cleanup on drop
21/// - Thread-safe state management
22///
23/// # Example
24/// ```rust,no_run
25/// use sonos_api::{SonosClient, Service};
26///
27/// # fn main() -> sonos_api::Result<()> {
28/// let client = SonosClient::new();
29/// let subscription = client.create_managed_subscription(
30/// "192.168.1.100",
31/// Service::AVTransport,
32/// "http://192.168.1.50:8080/callback",
33/// 1800
34/// )?;
35///
36/// // Check if renewal is needed
37/// if subscription.needs_renewal() {
38/// subscription.renew()?;
39/// }
40///
41/// // Clean up when done
42/// subscription.unsubscribe()?;
43/// # Ok(())
44/// # }
45/// ```
46#[derive(Debug)]
47pub struct ManagedSubscription {
48 /// UPnP subscription ID (SID) returned by the device
49 sid: String,
50 /// Device IP address
51 device_ip: String,
52 /// Service being subscribed to
53 service: Service,
54 /// Subscription state (protected by mutex)
55 state: Arc<Mutex<SubscriptionState>>,
56 /// SOAP client for making requests
57 soap_client: SoapClient,
58}
59
60#[derive(Debug)]
61struct SubscriptionState {
62 /// When this subscription expires
63 expires_at: SystemTime,
64 /// Whether the subscription is currently active
65 active: bool,
66 /// Timeout duration for this subscription
67 timeout_seconds: u32,
68}
69
70impl ManagedSubscription {
71 /// Create a new managed subscription by performing the initial subscribe operation
72 pub(crate) fn create(
73 device_ip: String,
74 service: Service,
75 callback_url: String,
76 timeout_seconds: u32,
77 soap_client: SoapClient,
78 ) -> Result<Self> {
79 let request = SubscribeRequest {
80 callback_url,
81 timeout_seconds,
82 };
83
84 let response = SubscribeOperation::execute(&soap_client, &device_ip, service, &request)?;
85
86 let state = SubscriptionState {
87 expires_at: SystemTime::now() + Duration::from_secs(response.timeout_seconds as u64),
88 active: true,
89 timeout_seconds: response.timeout_seconds,
90 };
91
92 Ok(Self {
93 sid: response.sid,
94 device_ip,
95 service,
96 state: Arc::new(Mutex::new(state)),
97 soap_client,
98 })
99 }
100
101 /// Send a UPnP unsubscribe request (internal use only)
102 fn unsubscribe_internal(
103 soap_client: &SoapClient,
104 device_ip: &str,
105 service: Service,
106 request: &UnsubscribeRequest,
107 ) -> Result<UnsubscribeResponse> {
108 UnsubscribeOperation::execute(soap_client, device_ip, service, request)
109 }
110
111 /// Send a UPnP renewal request (internal use only)
112 fn renew_internal(
113 soap_client: &SoapClient,
114 device_ip: &str,
115 service: Service,
116 request: &RenewRequest,
117 ) -> Result<RenewResponse> {
118 RenewOperation::execute(soap_client, device_ip, service, request)
119 }
120
121 /// Get the subscription ID
122 pub fn subscription_id(&self) -> &str {
123 &self.sid
124 }
125
126 /// Check if the subscription is still active and not expired
127 pub fn is_active(&self) -> bool {
128 let state = self.state.lock().unwrap();
129 state.active && SystemTime::now() < state.expires_at
130 }
131
132 /// Check if the subscription needs renewal
133 ///
134 /// Returns true if the subscription is active and will expire within
135 /// the renewal threshold (5 minutes by default).
136 pub fn needs_renewal(&self) -> bool {
137 self.time_until_renewal().is_some()
138 }
139
140 /// Get the time until renewal is needed
141 ///
142 /// Returns `Some(duration)` if renewal is needed within the threshold,
143 /// `None` if renewal is not needed or subscription is inactive.
144 pub fn time_until_renewal(&self) -> Option<Duration> {
145 let state = self.state.lock().unwrap();
146
147 if !state.active {
148 return None;
149 }
150
151 let now = SystemTime::now();
152 if now >= state.expires_at {
153 return Some(Duration::ZERO);
154 }
155
156 let time_until_expiry = state.expires_at.duration_since(now).ok()?;
157 let renewal_threshold = Duration::from_secs(300); // 5 minutes
158
159 if time_until_expiry <= renewal_threshold {
160 Some(time_until_expiry)
161 } else {
162 None
163 }
164 }
165
166 /// Get when the subscription expires
167 pub fn expires_at(&self) -> SystemTime {
168 let state = self.state.lock().unwrap();
169 state.expires_at
170 }
171
172 /// Manually renew the subscription
173 ///
174 /// This sends a renewal request to the device and updates the internal
175 /// expiration time based on the response.
176 ///
177 /// # Returns
178 /// `Ok(())` if renewal succeeded, `Err(ApiError)` if it failed.
179 ///
180 /// # Errors
181 /// - `ApiError::SubscriptionExpired` if the subscription has already expired
182 /// - Network or device errors from the renewal request
183 pub fn renew(&self) -> Result<()> {
184 let current_timeout = {
185 let state = self.state.lock().unwrap();
186 if !state.active {
187 return Err(ApiError::subscription_expired());
188 }
189 state.timeout_seconds
190 };
191
192 let request = RenewRequest {
193 sid: self.sid.clone(),
194 timeout_seconds: current_timeout,
195 };
196
197 let response =
198 Self::renew_internal(&self.soap_client, &self.device_ip, self.service, &request)?;
199
200 // Update state with new expiration time
201 {
202 let mut state = self.state.lock().unwrap();
203 state.expires_at =
204 SystemTime::now() + Duration::from_secs(response.timeout_seconds as u64);
205 state.timeout_seconds = response.timeout_seconds;
206 }
207
208 Ok(())
209 }
210
211 /// Unsubscribe and clean up the subscription
212 ///
213 /// This sends an unsubscribe request to the device and marks the
214 /// subscription as inactive. After calling this method, the subscription
215 /// should not be used for any further operations.
216 ///
217 /// # Returns
218 /// `Ok(())` if unsubscribe succeeded, `Err(ApiError)` if it failed.
219 /// Note that the subscription is marked inactive regardless of the result.
220 pub fn unsubscribe(&self) -> Result<()> {
221 // Mark as inactive first
222 {
223 let mut state = self.state.lock().unwrap();
224 state.active = false;
225 }
226
227 // Send unsubscribe request
228 let request = UnsubscribeRequest {
229 sid: self.sid.clone(),
230 };
231
232 Self::unsubscribe_internal(&self.soap_client, &self.device_ip, self.service, &request)
233 .map(|_| ())
234 }
235}
236
237impl Drop for ManagedSubscription {
238 fn drop(&mut self) {
239 // Mark as inactive
240 if let Ok(mut state) = self.state.lock() {
241 if state.active {
242 state.active = false;
243
244 // Attempt to unsubscribe, but don't panic if it fails
245 let request = UnsubscribeRequest {
246 sid: self.sid.clone(),
247 };
248
249 if let Err(e) = Self::unsubscribe_internal(
250 &self.soap_client,
251 &self.device_ip,
252 self.service,
253 &request,
254 ) {
255 eprintln!("⚠️ Failed to unsubscribe {} during drop: {}", self.sid, e);
256 }
257 }
258 }
259 }
260}