sonos_api/services/group_management/
operations.rs1use crate::operation::parse_sonos_bool;
13use crate::{define_upnp_operation, Validate};
14use paste::paste;
15use serde::{Deserialize, Serialize};
16
17#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
23pub struct AddMemberOperationRequest {
24 pub member_id: String,
26 pub boot_seq: u32,
28}
29
30impl Validate for AddMemberOperationRequest {}
31
32#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
34pub struct AddMemberResponse {
35 pub current_transport_settings: String,
37 pub current_uri: String,
39 pub group_uuid_joined: String,
41 pub reset_volume_after: bool,
43 pub volume_av_transport_uri: String,
45}
46
47pub struct AddMemberOperation;
49
50impl crate::operation::UPnPOperation for AddMemberOperation {
51 type Request = AddMemberOperationRequest;
52 type Response = AddMemberResponse;
53
54 const SERVICE: crate::service::Service = crate::service::Service::GroupManagement;
55 const ACTION: &'static str = "AddMember";
56
57 fn build_payload(request: &Self::Request) -> Result<String, crate::operation::ValidationError> {
58 <Self::Request as Validate>::validate(request, crate::operation::ValidationLevel::Basic)?;
59 Ok(format!(
60 "<InstanceID>0</InstanceID><MemberID>{}</MemberID><BootSeq>{}</BootSeq>",
61 crate::operation::xml_escape(&request.member_id),
62 request.boot_seq
63 ))
64 }
65
66 fn parse_response(xml: &xmltree::Element) -> Result<Self::Response, crate::error::ApiError> {
67 let current_transport_settings = xml
68 .get_child("CurrentTransportSettings")
69 .and_then(|e| e.get_text())
70 .map(|s| s.to_string())
71 .unwrap_or_default();
72
73 let current_uri = xml
74 .get_child("CurrentURI")
75 .and_then(|e| e.get_text())
76 .map(|s| s.to_string())
77 .unwrap_or_default();
78
79 let group_uuid_joined = xml
80 .get_child("GroupUUIDJoined")
81 .and_then(|e| e.get_text())
82 .map(|s| s.to_string())
83 .unwrap_or_default();
84
85 let reset_volume_after = parse_sonos_bool(xml, "ResetVolumeAfter");
86
87 let volume_av_transport_uri = xml
88 .get_child("VolumeAVTransportURI")
89 .and_then(|e| e.get_text())
90 .map(|s| s.to_string())
91 .unwrap_or_default();
92
93 Ok(AddMemberResponse {
94 current_transport_settings,
95 current_uri,
96 group_uuid_joined,
97 reset_volume_after,
98 volume_av_transport_uri,
99 })
100 }
101}
102
103pub fn add_member_operation(
105 member_id: String,
106 boot_seq: u32,
107) -> crate::operation::OperationBuilder<AddMemberOperation> {
108 let request = AddMemberOperationRequest {
109 member_id,
110 boot_seq,
111 };
112 crate::operation::OperationBuilder::new(request)
113}
114
115define_upnp_operation! {
120 operation: RemoveMemberOperation,
121 action: "RemoveMember",
122 service: GroupManagement,
123 request: {
124 member_id: String,
125 },
126 response: (),
127 payload: |req| {
128 format!("<InstanceID>{}</InstanceID><MemberID>{}</MemberID>",
129 req.instance_id,
130 crate::operation::xml_escape(&req.member_id))
131 },
132 parse: |_xml| Ok(()),
133}
134
135impl Validate for RemoveMemberOperationRequest {}
136
137define_upnp_operation! {
142 operation: ReportTrackBufferingResultOperation,
143 action: "ReportTrackBufferingResult",
144 service: GroupManagement,
145 request: {
146 member_id: String,
147 result_code: i32,
148 },
149 response: (),
150 payload: |req| {
151 format!(
152 "<InstanceID>{}</InstanceID><MemberID>{}</MemberID><ResultCode>{}</ResultCode>",
153 req.instance_id,
154 crate::operation::xml_escape(&req.member_id),
155 req.result_code
156 )
157 },
158 parse: |_xml| Ok(()),
159}
160
161impl Validate for ReportTrackBufferingResultOperationRequest {}
162
163define_upnp_operation! {
168 operation: SetSourceAreaIdsOperation,
169 action: "SetSourceAreaIds",
170 service: GroupManagement,
171 request: {
172 desired_source_area_ids: String,
173 },
174 response: (),
175 payload: |req| {
176 format!(
177 "<InstanceID>{}</InstanceID><DesiredSourceAreaIds>{}</DesiredSourceAreaIds>",
178 req.instance_id,
179 crate::operation::xml_escape(&req.desired_source_area_ids)
180 )
181 },
182 parse: |_xml| Ok(()),
183}
184
185impl Validate for SetSourceAreaIdsOperationRequest {}
186
187pub use add_member_operation as add_member;
192pub use remove_member_operation as remove_member;
193pub use report_track_buffering_result_operation as report_track_buffering_result;
194pub use set_source_area_ids_operation as set_source_area_ids;
195
196#[cfg(test)]
201mod tests {
202 use super::*;
203 use crate::operation::UPnPOperation;
204
205 #[test]
208 fn test_add_member_builder() {
209 let op = add_member_operation("RINCON_123".to_string(), 42)
210 .build()
211 .unwrap();
212 assert_eq!(op.request().member_id, "RINCON_123");
213 assert_eq!(op.request().boot_seq, 42);
214 assert_eq!(op.metadata().action, "AddMember");
215 assert_eq!(op.metadata().service, "GroupManagement");
216 }
217
218 #[test]
219 fn test_add_member_payload() {
220 let request = AddMemberOperationRequest {
221 member_id: "RINCON_ABC123".to_string(),
222 boot_seq: 100,
223 };
224 let payload = AddMemberOperation::build_payload(&request).unwrap();
225 assert!(payload.contains("<InstanceID>0</InstanceID>"));
226 assert!(payload.contains("<MemberID>RINCON_ABC123</MemberID>"));
227 assert!(payload.contains("<BootSeq>100</BootSeq>"));
228 }
229
230 #[test]
231 fn test_add_member_payload_escapes_xml_special_chars() {
232 let request = AddMemberOperationRequest {
233 member_id: "RINCON_123</MemberID><BootSeq>999</BootSeq><Foo>bar".to_string(),
234 boot_seq: 42,
235 };
236 let payload = AddMemberOperation::build_payload(&request).unwrap();
237 assert!(!payload.contains("</MemberID><BootSeq>999"));
239 assert!(payload.contains("</MemberID>"));
240 assert!(payload.contains("<BootSeq>42</BootSeq>"));
241 }
242
243 #[test]
244 fn test_add_member_response_parsing_reset_volume_true() {
245 let xml_str = r#"<AddMemberResponse>
246 <CurrentTransportSettings>settings</CurrentTransportSettings>
247 <CurrentURI>x-rincon:RINCON_123</CurrentURI>
248 <GroupUUIDJoined>group-uuid-123</GroupUUIDJoined>
249 <ResetVolumeAfter>1</ResetVolumeAfter>
250 <VolumeAVTransportURI>x-rincon:RINCON_456</VolumeAVTransportURI>
251 </AddMemberResponse>"#;
252 let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
253 let response = AddMemberOperation::parse_response(&xml).unwrap();
254
255 assert_eq!(response.current_transport_settings, "settings");
256 assert_eq!(response.current_uri, "x-rincon:RINCON_123");
257 assert_eq!(response.group_uuid_joined, "group-uuid-123");
258 assert!(response.reset_volume_after);
259 assert_eq!(response.volume_av_transport_uri, "x-rincon:RINCON_456");
260 }
261
262 #[test]
263 fn test_add_member_response_parsing_reset_volume_false() {
264 let xml_str = r#"<AddMemberResponse>
265 <CurrentTransportSettings></CurrentTransportSettings>
266 <CurrentURI></CurrentURI>
267 <GroupUUIDJoined></GroupUUIDJoined>
268 <ResetVolumeAfter>0</ResetVolumeAfter>
269 <VolumeAVTransportURI></VolumeAVTransportURI>
270 </AddMemberResponse>"#;
271 let xml = xmltree::Element::parse(xml_str.as_bytes()).unwrap();
272 let response = AddMemberOperation::parse_response(&xml).unwrap();
273
274 assert!(!response.reset_volume_after);
275 }
276
277 #[test]
280 fn test_remove_member_builder() {
281 let op = remove_member_operation("RINCON_456".to_string())
282 .build()
283 .unwrap();
284 assert_eq!(op.request().member_id, "RINCON_456");
285 assert_eq!(op.metadata().action, "RemoveMember");
286 assert_eq!(op.metadata().service, "GroupManagement");
287 }
288
289 #[test]
290 fn test_remove_member_payload() {
291 let request = RemoveMemberOperationRequest {
292 member_id: "RINCON_XYZ".to_string(),
293 instance_id: 0,
294 };
295 let payload = RemoveMemberOperation::build_payload(&request).unwrap();
296 assert!(payload.contains("<MemberID>RINCON_XYZ</MemberID>"));
297 assert!(payload.contains("<InstanceID>0</InstanceID>"));
298 }
299
300 #[test]
303 fn test_report_track_buffering_result_builder() {
304 let op = report_track_buffering_result_operation("RINCON_789".to_string(), 0)
305 .build()
306 .unwrap();
307 assert_eq!(op.request().member_id, "RINCON_789");
308 assert_eq!(op.request().result_code, 0);
309 assert_eq!(op.metadata().action, "ReportTrackBufferingResult");
310 assert_eq!(op.metadata().service, "GroupManagement");
311 }
312
313 #[test]
314 fn test_report_track_buffering_result_payload() {
315 let request = ReportTrackBufferingResultOperationRequest {
316 member_id: "RINCON_ABC".to_string(),
317 result_code: -1,
318 instance_id: 0,
319 };
320 let payload = ReportTrackBufferingResultOperation::build_payload(&request).unwrap();
321 assert!(payload.contains("<MemberID>RINCON_ABC</MemberID>"));
322 assert!(payload.contains("<ResultCode>-1</ResultCode>"));
323 }
324
325 #[test]
328 fn test_set_source_area_ids_builder() {
329 let op = set_source_area_ids_operation("area1,area2".to_string())
330 .build()
331 .unwrap();
332 assert_eq!(op.request().desired_source_area_ids, "area1,area2");
333 assert_eq!(op.metadata().action, "SetSourceAreaIds");
334 assert_eq!(op.metadata().service, "GroupManagement");
335 }
336
337 #[test]
338 fn test_set_source_area_ids_payload() {
339 let request = SetSourceAreaIdsOperationRequest {
340 desired_source_area_ids: "source-area-123".to_string(),
341 instance_id: 0,
342 };
343 let payload = SetSourceAreaIdsOperation::build_payload(&request).unwrap();
344 assert!(payload.contains("<DesiredSourceAreaIds>source-area-123</DesiredSourceAreaIds>"));
345 }
346
347 #[test]
350 fn test_service_constant() {
351 assert_eq!(
352 AddMemberOperation::SERVICE,
353 crate::service::Service::GroupManagement
354 );
355 assert_eq!(
356 RemoveMemberOperation::SERVICE,
357 crate::service::Service::GroupManagement
358 );
359 assert_eq!(
360 ReportTrackBufferingResultOperation::SERVICE,
361 crate::service::Service::GroupManagement
362 );
363 assert_eq!(
364 SetSourceAreaIdsOperation::SERVICE,
365 crate::service::Service::GroupManagement
366 );
367 }
368}
369
370#[cfg(test)]
375mod property_tests {
376 use super::*;
377 use crate::operation::{UPnPOperation, ValidationLevel};
378 use proptest::prelude::*;
379
380 proptest! {
390 #![proptest_config(ProptestConfig::with_cases(100))]
391
392 #[test]
394 fn prop_add_member_bool_parsing(reset_vol in proptest::bool::ANY) {
395 let xml_value = if reset_vol { "1" } else { "0" };
396 let xml_str = format!(r#"<AddMemberResponse>
397 <CurrentTransportSettings>test-settings</CurrentTransportSettings>
398 <CurrentURI>x-rincon:RINCON_TEST</CurrentURI>
399 <GroupUUIDJoined>test-group-uuid</GroupUUIDJoined>
400 <ResetVolumeAfter>{xml_value}</ResetVolumeAfter>
401 <VolumeAVTransportURI>x-rincon:RINCON_VOL</VolumeAVTransportURI>
402 </AddMemberResponse>"#);
403
404 let xml = xmltree::Element::parse(xml_str.as_bytes())
405 .expect("XML should parse successfully");
406 let response = AddMemberOperation::parse_response(&xml)
407 .expect("Response parsing should succeed");
408
409 prop_assert_eq!(
410 response.reset_volume_after,
411 reset_vol,
412 "ResetVolumeAfter '{}' should parse to {}",
413 xml_value,
414 reset_vol
415 );
416 }
417 }
418
419 fn member_id_strategy() -> impl Strategy<Value = String> {
430 prop::string::string_regex("[A-Za-z0-9_-]{0,50}").unwrap()
431 }
432
433 fn source_area_ids_strategy() -> impl Strategy<Value = String> {
435 prop::string::string_regex("[A-Za-z0-9,_-]{0,100}").unwrap()
436 }
437
438 proptest! {
439 #![proptest_config(ProptestConfig::with_cases(100))]
440
441 #[test]
443 fn prop_remove_member_validation_passes(member_id in member_id_strategy()) {
444 let request = RemoveMemberOperationRequest {
445 member_id,
446 instance_id: 0,
447 };
448 let result = <RemoveMemberOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
449 prop_assert!(
450 result.is_ok(),
451 "RemoveMember validation should always pass, got: {:?}",
452 result
453 );
454 }
455
456 #[test]
458 fn prop_report_track_buffering_result_validation_passes(
459 member_id in member_id_strategy(),
460 result_code in prop::num::i32::ANY,
461 ) {
462 let request = ReportTrackBufferingResultOperationRequest {
463 member_id,
464 result_code,
465 instance_id: 0,
466 };
467 let result = <ReportTrackBufferingResultOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
468 prop_assert!(
469 result.is_ok(),
470 "ReportTrackBufferingResult validation should always pass, got: {:?}",
471 result
472 );
473 }
474
475 #[test]
477 fn prop_set_source_area_ids_validation_passes(
478 desired_source_area_ids in source_area_ids_strategy(),
479 ) {
480 let request = SetSourceAreaIdsOperationRequest {
481 desired_source_area_ids,
482 instance_id: 0,
483 };
484 let result = <SetSourceAreaIdsOperationRequest as Validate>::validate(&request, ValidationLevel::Basic);
485 prop_assert!(
486 result.is_ok(),
487 "SetSourceAreaIds validation should always pass, got: {:?}",
488 result
489 );
490 }
491 }
492}