Skip to main content

netget/client/ssdp/
actions.rs

1//! SSDP / UPnP discovery **client** — LLM vocabulary and action executor.
2//!
3//! This is the control-point half of SSDP: it sends `M-SEARCH` and reads what comes back.
4//! The device half lives in `src/server/ssdp/`, and the two share one thing — the HTTPU
5//! codec in [`crate::server::ssdp::message`]. Sharing it is deliberate: SSDP's grammar is a
6//! start line plus `NAME: value` lines, and a second hand-rolled parser would drift from the
7//! first the first time either was touched.
8//!
9//! Everything here is pure. No socket, no state, no LLM: `execute_action` turns a model's
10//! JSON into a [`ClientActionResult`] and `mod.rs` decides what that means on the wire.
11
12use 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
25/// Default `MX` when the model does not name one.
26///
27/// UDA 1.1 §1.3.2 requires `1 <= MX <= 5` and says a control point SHOULD keep it small.
28/// Three is the value real control points use: long enough that a busy device's jitter fits
29/// inside it, short enough that discovery feels immediate.
30pub const DEFAULT_MX_SECONDS: u32 = 3;
31
32/// The search target that asks every device for everything it has (UDA 1.1 §1.3.2).
33pub const SEARCH_TARGET_ALL: &str = "ssdp:all";
34
35// ---------------------------------------------------------------------------
36// Actions
37// ---------------------------------------------------------------------------
38
39/// `send_msearch` — the only action that puts bytes on the wire.
40fn 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
136/// Every action, in the order the model should see them.
137///
138/// One list, used by `get_async_actions`, by every event's `with_actions(...)`, and by
139/// nothing else. `get_sync_actions()` is deliberately empty: a client has a single LLM entry
140/// point (`call_llm_for_client` serves both the initial instruction and every network event),
141/// so a client cannot express a narrowing and the async/sync split is vestigial there. The
142/// ~40 clients that duplicate their whole list into both methods are working around a bug in
143/// `call_llm_for_client` that no longer exists — see `client_llm_action_set`.
144fn all_actions() -> Vec<ActionDefinition> {
145    vec![
146        send_msearch_action(),
147        wait_for_more_action(),
148        disconnect_action(),
149    ]
150}
151
152// ---------------------------------------------------------------------------
153// Events
154// ---------------------------------------------------------------------------
155
156/// Raised once, as soon as the search socket is bound.
157///
158/// This is where a discovery session starts: nothing is on the wire yet, and the model's
159/// answer decides what the first search looks for.
160pub 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
196/// Raised once per device that answers a search, while the MX window is open.
197pub 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
271/// Raised for an unsolicited `NOTIFY` announcement overheard on the multicast group.
272pub 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
337/// Raised when a search's collection window closes.
338///
339/// This is the event that makes iterative discovery possible: it is the first moment the
340/// model knows *how many* devices answered and what they were, so it is where "now search
341/// again, specifically" belongs.
342pub 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            // Present only on the failure path, and undeclared until now — the payload
410            // carried it while the model's schema for this event did not mention it, so the
411            // one field that explains a zero-responder search was invisible to the reader
412            // most in need of it.
413            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
425// ---------------------------------------------------------------------------
426// Protocol
427// ---------------------------------------------------------------------------
428
429/// SSDP discovery client: the LLM's action vocabulary and executor.
430pub 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    /// Deliberately empty — see [`all_actions`].
450    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
451        Vec::new()
452    }
453
454    fn protocol_name(&self) -> &'static str {
455        // Must match the client registry's ("SSDP", "ssdp") entry.
456        "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        // Narrow on purpose: "discovery" alone would hijack keyword resolution from mdns,
474        // llmnr and netbios-ns, which are discovery protocols too.
475        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            // Nothing here needs a privileged port: the search socket binds an ephemeral
493            // port by default, and 1900 (needed only to overhear NOTIFY announcements) is
494            // above 1023. Declaring PrivilegedPort(1900) would be the svn/3690 mistake —
495            // a check that can never fire, read by the next maintainer as protection.
496            .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            // LLM mode: the model runs the sweep and decides what to narrow to.
641            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            // Script mode: deterministic, no model in the loop.
652            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            // Static mode: one fixed search, everything after it ignored.
672            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                // Refused, not sanitised: a CR or LF here would end the ST line early and
735                // append attacker-chosen headers to our own request — the HTTP
736                // response-splitting shape, applied to a search. The model is told what it
737                // did rather than having it quietly rewritten.
738                message::validate_header_piece("value", "ST", st)?;
739
740                // Clamped rather than rejected: UDA 1.1 §1.3.2 requires a *device* to treat
741                // an MX above 5 as 5, so sending a larger one is pointless rather than
742                // wrong, and refusing would fail a search over a detail the device ignores.
743                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                            // Parsed here rather than in mod.rs so a typo is reported to the
766                            // model as a rejected action instead of a silent non-send.
767                            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}