1use crate::llm::actions::{
13 client_trait::{Client, ClientActionResult},
14 protocol_trait::Protocol,
15 ActionDefinition, Parameter, ParameterDefinition,
16};
17use crate::protocol::log_template::LogTemplate;
18use crate::protocol::EventType;
19use crate::server::ssdp::message::{self, MAX_MX_SECONDS};
20use crate::state::app_state::AppState;
21use anyhow::{Context, Result};
22use serde_json::json;
23use std::sync::LazyLock;
24
25pub const DEFAULT_MX_SECONDS: u32 = 3;
31
32pub const SEARCH_TARGET_ALL: &str = "ssdp:all";
34
35fn send_msearch_action() -> ActionDefinition {
41 ActionDefinition {
42 name: "send_msearch".to_string(),
43 description: format!(
44 "Send an SSDP M-SEARCH discovery request and collect every reply for the MX \
45 window. This is a ONE-TO-MANY request: on a real network many devices answer, \
46 each with its own ssdp_search_response event, and a single ssdp_search_complete \
47 event follows saying how many did. Search targets: '{SEARCH_TARGET_ALL}' asks \
48 every device for every service it has (the widest sweep, and the noisiest); \
49 'upnp:rootdevice' asks only for top-level devices, one reply per physical box; \
50 'uuid:<device-uuid>' asks one specific device; a URN such as \
51 'urn:schemas-upnp-org:device:MediaServer:1' or \
52 'urn:schemas-upnp-org:service:AVTransport:1' asks only for that device or \
53 service type. Start wide, read what answers, then search again for the specific \
54 types you saw — that is how a control point maps a network."
55 ),
56 parameters: vec![
57 Parameter {
58 name: "st".to_string(),
59 type_hint: "string".to_string(),
60 description: format!(
61 "Search target. '{SEARCH_TARGET_ALL}', 'upnp:rootdevice', \
62 'uuid:<device-uuid>', or a URN like \
63 'urn:schemas-upnp-org:device:MediaServer:1'. Required: a device only \
64 answers a search whose target it matches."
65 ),
66 required: true,
67 },
68 Parameter {
69 name: "mx".to_string(),
70 type_hint: "number".to_string(),
71 description: format!(
72 "Seconds a device may wait before answering, and therefore how long \
73 replies are collected. UDA 1.1 caps it at {MAX_MX_SECONDS}; larger \
74 values are clamped. Default {DEFAULT_MX_SECONDS}. A larger MX finds \
75 more devices on a busy network and makes every search slower."
76 ),
77 required: false,
78 },
79 Parameter {
80 name: "target".to_string(),
81 type_hint: "string".to_string(),
82 description: "Override the destination for this one search, as 'ip:port'. \
83 Normally omitted: the search goes to the address the client was \
84 opened with, which is the multicast group 239.255.255.250:1900 \
85 for a real network scan. A unicast address here searches exactly \
86 one device — UDA 1.1 §1.3.2 permits that, and it is also the only \
87 form that works over loopback, because loopback carries no \
88 multicast route."
89 .to_string(),
90 required: false,
91 },
92 ],
93 example: json!({
94 "type": "send_msearch",
95 "st": "ssdp:all",
96 "mx": 3
97 }),
98 log_template: Some(
99 LogTemplate::new()
100 .with_info("-> M-SEARCH ST={st}")
101 .with_debug("SSDP M-SEARCH st={st} mx={mx}"),
102 ),
103 }
104}
105
106fn wait_for_more_action() -> ActionDefinition {
107 ActionDefinition {
108 name: "wait_for_more".to_string(),
109 description: "Do nothing and keep listening. The correct answer when a search is \
110 still collecting replies, when one device's answer is not enough to \
111 decide on, or when you simply want to record what you saw and see the \
112 rest."
113 .to_string(),
114 parameters: vec![],
115 example: json!({"type": "wait_for_more"}),
116 log_template: Some(
117 LogTemplate::new()
118 .with_info("-> SSDP wait for more")
119 .with_debug("SSDP client wait_for_more"),
120 ),
121 }
122}
123
124fn disconnect_action() -> ActionDefinition {
125 ActionDefinition {
126 name: "disconnect".to_string(),
127 description: "Stop discovery and close the socket. Discovery is finished; nothing \
128 further will be received."
129 .to_string(),
130 parameters: vec![],
131 example: json!({"type": "disconnect"}),
132 log_template: None,
133 }
134}
135
136fn all_actions() -> Vec<ActionDefinition> {
145 vec![
146 send_msearch_action(),
147 wait_for_more_action(),
148 disconnect_action(),
149 ]
150}
151
152pub static SSDP_CLIENT_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
161 EventType::new(
162 "ssdp_connected",
163 "SSDP discovery socket is bound and ready. Nothing has been searched for yet — \
164 answer with send_msearch to start discovery.",
165 json!({"type": "send_msearch", "st": SEARCH_TARGET_ALL, "mx": DEFAULT_MX_SECONDS}),
166 )
167 .with_parameters(vec![
168 Parameter {
169 name: "local_addr".to_string(),
170 type_hint: "string".to_string(),
171 description: "Local ip:port the search socket is bound to".to_string(),
172 required: true,
173 },
174 Parameter {
175 name: "default_target".to_string(),
176 type_hint: "string".to_string(),
177 description: "Where searches go unless an action overrides it. \
178 '239.255.255.250:1900' is the SSDP multicast group; anything else \
179 is a unicast search of one device."
180 .to_string(),
181 required: true,
182 },
183 Parameter {
184 name: "multicast_joined".to_string(),
185 type_hint: "boolean".to_string(),
186 description: "Whether the 239.255.255.250 group was joined. False means \
187 unsolicited NOTIFY announcements will not be seen; unicast \
188 searches still work."
189 .to_string(),
190 required: true,
191 },
192 ])
193 .with_actions(all_actions())
194});
195
196pub static SSDP_SEARCH_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
198 EventType::new(
199 "ssdp_search_response",
200 "A device answered the M-SEARCH. One of these is raised per responder while the \
201 search window is open, so expect several on a real network.",
202 json!({"type": "wait_for_more"}),
203 )
204 .with_parameters(vec![
205 Parameter {
206 name: "st".to_string(),
207 type_hint: "string".to_string(),
208 description: "The search target this device is answering for — the concrete \
209 device or service type it claims to be."
210 .to_string(),
211 required: true,
212 },
213 Parameter {
214 name: "usn".to_string(),
215 type_hint: "string".to_string(),
216 description: "Unique Service Name: 'uuid:<device-uuid>' optionally followed by \
217 '::<service or device type>'. This is the identity to search for \
218 specifically next."
219 .to_string(),
220 required: true,
221 },
222 Parameter {
223 name: "location".to_string(),
224 type_hint: "string".to_string(),
225 description: "URL of the device description XML. NetGet does NOT fetch it — \
226 that is plain HTTP, not SSDP. Open an HTTP client against this \
227 URL if the description is wanted."
228 .to_string(),
229 required: false,
230 },
231 Parameter {
232 name: "server".to_string(),
233 type_hint: "string".to_string(),
234 description: "SERVER header: 'OS/version UPnP/version product/version'. Often \
235 the most informative field about what the device actually is."
236 .to_string(),
237 required: false,
238 },
239 Parameter {
240 name: "cache_control".to_string(),
241 type_hint: "string".to_string(),
242 description: "CACHE-CONTROL header verbatim, e.g. 'max-age=1800'".to_string(),
243 required: false,
244 },
245 Parameter {
246 name: "cache_control_max_age".to_string(),
247 type_hint: "number".to_string(),
248 description: "The max-age directive parsed out as seconds, so freshness can be \
249 reasoned about without parsing a header"
250 .to_string(),
251 required: false,
252 },
253 Parameter {
254 name: "source_address".to_string(),
255 type_hint: "string".to_string(),
256 description: "ip:port the reply came from — the device's own address".to_string(),
257 required: true,
258 },
259 Parameter {
260 name: "headers".to_string(),
261 type_hint: "object".to_string(),
262 description: "Every header of the reply as a name-to-value map, including \
263 vendor-specific ones the fields above do not cover"
264 .to_string(),
265 required: true,
266 },
267 ])
268 .with_actions(all_actions())
269});
270
271pub static SSDP_NOTIFY_RECEIVED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
273 EventType::new(
274 "ssdp_notify_received",
275 "A device announced itself unprompted: NTS 'ssdp:alive' means it appeared or \
276 refreshed, 'ssdp:byebye' means it is going away, 'ssdp:update' means its \
277 configuration changed. Nothing was searched for — this arrived on its own.",
278 json!({"type": "wait_for_more"}),
279 )
280 .with_parameters(vec![
281 Parameter {
282 name: "nt".to_string(),
283 type_hint: "string".to_string(),
284 description: "Notification Type: the device or service type being announced"
285 .to_string(),
286 required: true,
287 },
288 Parameter {
289 name: "nts".to_string(),
290 type_hint: "string".to_string(),
291 description: "Notification Sub Type: 'ssdp:alive', 'ssdp:byebye' or 'ssdp:update'"
292 .to_string(),
293 required: true,
294 },
295 Parameter {
296 name: "usn".to_string(),
297 type_hint: "string".to_string(),
298 description: "Unique Service Name identifying the announcing device".to_string(),
299 required: true,
300 },
301 Parameter {
302 name: "location".to_string(),
303 type_hint: "string".to_string(),
304 description: "Description URL. Absent on an ssdp:byebye, which carries only \
305 HOST, NT, NTS and USN."
306 .to_string(),
307 required: false,
308 },
309 Parameter {
310 name: "server".to_string(),
311 type_hint: "string".to_string(),
312 description: "SERVER header, when present".to_string(),
313 required: false,
314 },
315 Parameter {
316 name: "cache_control_max_age".to_string(),
317 type_hint: "number".to_string(),
318 description: "max-age in seconds: how long this announcement stays valid".to_string(),
319 required: false,
320 },
321 Parameter {
322 name: "source_address".to_string(),
323 type_hint: "string".to_string(),
324 description: "ip:port the announcement came from".to_string(),
325 required: true,
326 },
327 Parameter {
328 name: "headers".to_string(),
329 type_hint: "object".to_string(),
330 description: "Every header of the announcement as a name-to-value map".to_string(),
331 required: true,
332 },
333 ])
334 .with_actions(all_actions())
335});
336
337pub static SSDP_SEARCH_COMPLETE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
343 EventType::new(
344 "ssdp_search_complete",
345 "The search window closed. This is the summary of one M-SEARCH and the natural \
346 place to decide what to search for next — a second, narrower search aimed at a type \
347 or a uuid seen in the results usually learns more than repeating the first one.",
348 json!({"type": "wait_for_more"}),
349 )
350 .with_parameters(vec![
351 Parameter {
352 name: "st".to_string(),
353 type_hint: "string".to_string(),
354 description: "The search target that was searched for".to_string(),
355 required: true,
356 },
357 Parameter {
358 name: "mx".to_string(),
359 type_hint: "number".to_string(),
360 description: "MX that was sent, i.e. how many seconds replies were collected for"
361 .to_string(),
362 required: true,
363 },
364 Parameter {
365 name: "target".to_string(),
366 type_hint: "string".to_string(),
367 description: "Where the search was actually sent".to_string(),
368 required: true,
369 },
370 Parameter {
371 name: "responder_count".to_string(),
372 type_hint: "number".to_string(),
373 description: "How many distinct devices/services answered. Zero means nothing on \
374 this network matched the target — searching for something narrower \
375 will not help; searching wider might."
376 .to_string(),
377 required: true,
378 },
379 Parameter {
380 name: "responders".to_string(),
381 type_hint: "array".to_string(),
382 description: "One entry per responder: {st, usn, location, server, \
383 source_address}. The same list already delivered one at a time as \
384 ssdp_search_response events, repeated here so a decision can be \
385 made against the whole set."
386 .to_string(),
387 required: true,
388 },
389 Parameter {
390 name: "duplicate_count".to_string(),
391 type_hint: "number".to_string(),
392 description: "Replies suppressed as repeats of a responder already reported \
393 (same source address and USN). Usually a device retransmitting."
394 .to_string(),
395 required: true,
396 },
397 Parameter {
398 name: "search_depth".to_string(),
399 type_hint: "number".to_string(),
400 description: "How many searches deep this chain is. Searches started in reply to \
401 an event are bounded; when this reaches the limit a further \
402 send_msearch is refused and logged."
403 .to_string(),
404 required: true,
405 },
406 Parameter {
407 name: "send_error".to_string(),
408 type_hint: "string".to_string(),
409 description: "Only present when the search could not be SENT at all; then \
414 responder_count is 0 for that reason and not because nothing \
415 answered. The usual cause is sending to 239.255.255.250 from a \
416 loopback-bound socket, which has no multicast route — retry with \
417 'target' set to a specific device's address."
418 .to_string(),
419 required: false,
420 },
421 ])
422 .with_actions(all_actions())
423});
424
425pub struct SsdpClientProtocol;
431
432impl SsdpClientProtocol {
433 pub fn new() -> Self {
434 Self
435 }
436}
437
438impl Default for SsdpClientProtocol {
439 fn default() -> Self {
440 Self::new()
441 }
442}
443
444impl Protocol for SsdpClientProtocol {
445 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
446 all_actions()
447 }
448
449 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
451 Vec::new()
452 }
453
454 fn protocol_name(&self) -> &'static str {
455 "SSDP"
457 }
458
459 fn get_event_types(&self) -> Vec<EventType> {
460 vec![
461 SSDP_CLIENT_CONNECTED_EVENT.clone(),
462 SSDP_SEARCH_RESPONSE_EVENT.clone(),
463 SSDP_NOTIFY_RECEIVED_EVENT.clone(),
464 SSDP_SEARCH_COMPLETE_EVENT.clone(),
465 ]
466 }
467
468 fn stack_name(&self) -> &'static str {
469 "ETH>IP>UDP>SSDP"
470 }
471
472 fn keywords(&self) -> Vec<&'static str> {
473 vec![
476 "ssdp",
477 "upnp",
478 "upnp discovery",
479 "m-search",
480 "ssdp:discover",
481 "discover upnp devices",
482 ]
483 }
484
485 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
486 use crate::protocol::metadata::{
487 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
488 };
489
490 ProtocolMetadataV2::builder()
491 .state(DevelopmentState::Experimental)
492 .privilege_requirement(PrivilegeRequirement::None)
497 .implementation(
498 "Sends M-SEARCH over a tokio UdpSocket and collects every unicast reply for \
499 the MX window rather than returning on the first datagram, because SSDP is \
500 one-to-many: each responder gets its own ssdp_search_response event and the \
501 window ends with one ssdp_search_complete. Parses replies with the server \
502 half's HTTPU codec (src/server/ssdp/message.rs) rather than a second \
503 hand-rolled parser. Also receives unsolicited NOTIFY announcements when \
504 bound to port 1900 with the multicast group joined. Does NOT fetch the \
505 LOCATION device-description XML: that is HTTP, not SSDP, and an automatic \
506 fetch would turn discovery into an outbound request the operator never \
507 asked for. No SOAP control, no SCPD, no GENA eventing.",
508 )
509 .llm_control(
510 "The model chooses every search target and MX, reads each responder's ST, \
511 USN, LOCATION and SERVER, and decides what to search for next — a wide \
512 ssdp:all sweep followed by narrow searches for the types that answered. \
513 Searches started in reply to an event are bounded by a follow-up depth \
514 limit; hitting it is logged, never silent.",
515 )
516 .e2e_testing(
517 "Mocked end-to-end through the real binary against NetGet's OWN SSDP server \
518 over unicast loopback. That is same-project evidence: it shows the two \
519 halves of NetGet agree, NOT that either matches a real UPnP device. Also \
520 driven by raw UDP sockets in-test that emulate two devices answering one \
521 search and one announcing itself, which is an independent reading of UDA \
522 1.1 rather than an independent implementation of it (the dhcp / usbip \
523 class). NOT validated against any real UPnP device or emulator.",
524 )
525 .notes(
526 "EXPERIMENTAL, and the reason is the peer, not the code. Every test peer is \
527 either NetGet's own SSDP server (circular — same project, same codec) or a \
528 device hand-written inside the test from the spec. Beta requires a device \
529 NetGet did not write: a real router/TV/printer on a real LAN, or an \
530 installed UPnP device emulator. Neither exists in this environment. \
531 MULTICAST ON LOOPBACK: measured on macOS 27, joining 239.255.255.250 from \
532 a 127.0.0.1 bind succeeds but SENDING to the group fails with \
533 EADDRNOTAVAIL (49), because loopback carries no multicast route — bind \
534 0.0.0.0 for a real scan, and use send_msearch's 'target' for a unicast \
535 search over loopback. To overhear NOTIFY announcements the socket must bind \
536 port 1900 and join the group (startup params local_port and \
537 join_multicast); with an ephemeral port only unicast replies to our own \
538 searches arrive.",
539 )
540 .build()
541 }
542
543 fn description(&self) -> &'static str {
544 "SSDP / UPnP discovery client: M-SEARCH the network and enumerate the UPnP devices \
545 that answer"
546 }
547
548 fn example_prompt(&self) -> &'static str {
549 "discover the UPnP devices on my network over ssdp: search ssdp:all first, then \
550 search again for each device type that answered, and tell me what you found"
551 }
552
553 fn group_name(&self) -> &'static str {
554 "Application"
555 }
556
557 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
558 vec![
559 ParameterDefinition {
560 name: "bind_address".to_string(),
561 type_hint: "string".to_string(),
562 description: "Local IP the search socket binds to. Default '0.0.0.0', which \
563 is what a real network scan needs: a socket bound to 127.0.0.1 \
564 cannot send to the multicast group at all (EADDRNOTAVAIL on \
565 macOS — loopback carries no multicast route), though it can \
566 still send and receive unicast searches."
567 .to_string(),
568 required: false,
569 example: json!("0.0.0.0"),
570 default: None,
571 },
572 ParameterDefinition {
573 name: "local_port".to_string(),
574 type_hint: "number".to_string(),
575 description: "Local UDP port to bind. Default 0 (ephemeral), which is right \
576 for searching: replies to our own M-SEARCH are unicast back to \
577 whatever port we sent from. Set 1900 to also overhear the \
578 NOTIFY announcements devices multicast to the group, which \
579 only arrive on the well-known port."
580 .to_string(),
581 required: false,
582 example: json!(0),
583 default: None,
584 },
585 ParameterDefinition {
586 name: "join_multicast".to_string(),
587 type_hint: "boolean".to_string(),
588 description: "Whether to join the 239.255.255.250 group so multicast NOTIFY \
589 announcements are received. Default true. Best effort: a \
590 failure is logged and the client keeps running, because \
591 searching does not depend on the join."
592 .to_string(),
593 required: false,
594 example: json!(true),
595 default: None,
596 },
597 ParameterDefinition {
598 name: "multicast_interface".to_string(),
599 type_hint: "string".to_string(),
600 description: "Local IPv4 address of the interface to join the group on, e.g. \
601 '192.168.1.10'. Default '0.0.0.0', letting the kernel choose. \
602 Only meaningful for an IPv4 bind."
603 .to_string(),
604 required: false,
605 example: json!("0.0.0.0"),
606 default: None,
607 },
608 ParameterDefinition {
609 name: "response_window_ms".to_string(),
610 type_hint: "number".to_string(),
611 description: "Override how long replies to a search are collected, in \
612 milliseconds. Default 0, meaning use the search's own MX (MX \
613 seconds), which is what the devices were told to expect. Set \
614 it longer to catch devices that answer late, or shorter to \
615 make a scripted sweep finish quickly."
616 .to_string(),
617 required: false,
618 example: json!(0),
619 default: None,
620 },
621 ParameterDefinition {
622 name: "user_agent".to_string(),
623 type_hint: "string".to_string(),
624 description: "USER-AGENT header sent with every M-SEARCH, in the UDA 1.1 \
625 form 'OS/version UPnP/1.1 product/version'. Default \
626 'NetGet/1.0 UPnP/1.1 NetGet-SSDP-Client/1.0'. Some devices \
627 vary what they advertise by control point."
628 .to_string(),
629 required: false,
630 example: json!("Linux/6.1 UPnP/1.1 NetGet-SSDP-Client/1.0"),
631 default: None,
632 },
633 ]
634 }
635
636 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
637 use crate::llm::actions::StartupExamples;
638
639 StartupExamples::new(
640 json!({
642 "type": "open_client",
643 "remote_addr": "239.255.255.250:1900",
644 "base_stack": "ssdp",
645 "startup_params": {"bind_address": "0.0.0.0", "local_port": 1900},
646 "instruction": "Discover the UPnP devices on this network. Start with an \
647 ssdp:all search, then for each distinct device type that \
648 answers run a narrower search for that exact type. Report \
649 each device's SERVER string, USN and LOCATION."
650 }),
651 json!({
653 "type": "open_client",
654 "remote_addr": "239.255.255.250:1900",
655 "base_stack": "ssdp",
656 "event_handlers": [{
657 "event_pattern": "ssdp_connected",
658 "handler": {
659 "type": "static",
660 "actions": [{"type": "send_msearch", "st": "upnp:rootdevice", "mx": 3}]
661 }
662 }, {
663 "event_pattern": "ssdp_search_response",
664 "handler": {
665 "type": "script",
666 "language": "python",
667 "code": "respond([{'type': 'wait_for_more'}])"
668 }
669 }]
670 }),
671 json!({
673 "type": "open_client",
674 "remote_addr": "239.255.255.250:1900",
675 "base_stack": "ssdp",
676 "event_handlers": [{
677 "event_pattern": "ssdp_connected",
678 "handler": {
679 "type": "static",
680 "actions": [{"type": "send_msearch", "st": "ssdp:all", "mx": 3}]
681 }
682 }, {
683 "event_pattern": "*",
684 "handler": {"type": "static", "actions": [{"type": "wait_for_more"}]}
685 }]
686 }),
687 )
688 }
689}
690
691impl Client for SsdpClientProtocol {
692 fn connect(
693 &self,
694 ctx: crate::protocol::ConnectContext,
695 ) -> std::pin::Pin<
696 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
697 > {
698 Box::pin(async move {
699 crate::client::ssdp::SsdpClient::connect_with_llm_actions(
700 ctx.remote_addr,
701 ctx.llm_client,
702 ctx.state,
703 ctx.status_tx,
704 ctx.client_id,
705 ctx.startup_params,
706 )
707 .await
708 })
709 }
710
711 fn execute_action(&self, action: serde_json::Value) -> Result<ClientActionResult> {
712 let action_type = action
713 .get("type")
714 .and_then(|v| v.as_str())
715 .context("Missing 'type' field in action")?;
716
717 match action_type {
718 "send_msearch" => {
719 let st = action
720 .get("st")
721 .and_then(|v| v.as_str())
722 .context(
723 "Missing 'st' field: an M-SEARCH must name a search target, e.g. \
724 'ssdp:all', 'upnp:rootdevice' or a \
725 'urn:schemas-upnp-org:device:...' URN",
726 )?
727 .trim();
728 if st.is_empty() {
729 return Err(anyhow::anyhow!(
730 "'st' is empty: a device answers only a search whose target it \
731 matches, and an empty target matches nothing"
732 ));
733 }
734 message::validate_header_piece("value", "ST", st)?;
739
740 let mx = match action.get("mx") {
744 None | Some(serde_json::Value::Null) => DEFAULT_MX_SECONDS,
745 Some(v) => {
746 let n = v.as_u64().ok_or_else(|| {
747 anyhow::anyhow!(
748 "'mx' must be a number of seconds (1..={MAX_MX_SECONDS}), got {v}"
749 )
750 })?;
751 n.clamp(1, u64::from(MAX_MX_SECONDS)) as u32
752 }
753 };
754
755 let target = match action.get("target") {
756 None | Some(serde_json::Value::Null) => None,
757 Some(v) => {
758 let s = v.as_str().ok_or_else(|| {
759 anyhow::anyhow!("'target' must be a string 'ip:port', got {v}")
760 })?;
761 let s = s.trim();
762 if s.is_empty() {
763 None
764 } else {
765 let addr: std::net::SocketAddr = s.parse().map_err(|e| {
768 anyhow::anyhow!(
769 "'target' must be 'ip:port' (e.g. '192.168.1.1:1900'): {e}"
770 )
771 })?;
772 Some(addr.to_string())
773 }
774 }
775 };
776
777 Ok(ClientActionResult::Custom {
778 name: "send_msearch".to_string(),
779 data: json!({"st": st, "mx": mx, "target": target}),
780 })
781 }
782 "wait_for_more" => Ok(ClientActionResult::WaitForMore),
783 "disconnect" => Ok(ClientActionResult::Disconnect),
784 other => Err(anyhow::anyhow!(
785 "Unknown SSDP client action: {other}. Valid actions: send_msearch, \
786 wait_for_more, disconnect"
787 )),
788 }
789 }
790}