1use crate::llm::actions::{
34 protocol_trait::{ActionResult, Protocol, Server},
35 ActionDefinition, Parameter, ParameterDefinition,
36};
37use crate::protocol::EventType;
38use crate::state::app_state::AppState;
39use anyhow::{Context, Result};
40use serde_json::json;
41use std::sync::LazyLock;
42
43use super::message::{self, NotifyMessage, SearchResponse};
44
45pub const DEFAULT_SERVER_HEADER: &str = "NetGet/1.0 UPnP/1.1 NetGet-SSDP/1.0";
48
49pub const DEFAULT_MAX_AGE: u32 = 1800;
52
53#[derive(Clone, Debug)]
64pub struct RequestContext {
65 pub search_target: Option<String>,
67 pub server_header: String,
69 pub notify_host: String,
71}
72
73impl RequestContext {
74 pub fn ipv4_defaults(search_target: Option<String>) -> Self {
76 Self {
77 search_target,
78 server_header: DEFAULT_SERVER_HEADER.to_string(),
79 notify_host: format!("{}:{}", message::SSDP_GROUP_V4, message::SSDP_PORT),
80 }
81 }
82}
83
84pub struct SsdpProtocol {
86 request: Option<RequestContext>,
87}
88
89impl Default for SsdpProtocol {
90 fn default() -> Self {
91 Self::new()
92 }
93}
94
95impl SsdpProtocol {
96 pub fn new() -> Self {
97 Self { request: None }
98 }
99
100 pub fn for_request(ctx: RequestContext) -> Self {
101 Self { request: Some(ctx) }
102 }
103
104 fn server_default(&self) -> &str {
105 match &self.request {
106 Some(ctx) => &ctx.server_header,
107 None => DEFAULT_SERVER_HEADER,
108 }
109 }
110
111 fn notify_host(&self) -> String {
112 match &self.request {
113 Some(ctx) => ctx.notify_host.clone(),
114 None => format!("{}:{}", message::SSDP_GROUP_V4, message::SSDP_PORT),
115 }
116 }
117
118 fn resolve_st(&self, action: &serde_json::Value) -> Result<String> {
128 if let Some(st) = action.get("st").and_then(|v| v.as_str()) {
129 if !st.trim().is_empty() {
130 return Ok(st.trim().to_string());
131 }
132 }
133 let requested = self
134 .request
135 .as_ref()
136 .and_then(|c| c.search_target.as_deref())
137 .map(str::trim)
138 .filter(|s| !s.is_empty());
139
140 match requested {
141 Some("ssdp:all") | Some("upnp:rootdevice") | None => Err(anyhow::anyhow!(
142 "send_ssdp_response needs an 'st'. The search target was {}, which is a \
143 wildcard, so the response must name the concrete device or service type \
144 this answer is for (e.g. \"urn:schemas-upnp-org:device:MediaServer:1\"). \
145 Only a search for a specific type can have its ST echoed automatically.",
146 requested
147 .map(|s| format!("'{s}'"))
148 .unwrap_or_else(|| { "not available in this context".to_string() })
149 )),
150 Some(other) => Ok(other.to_string()),
151 }
152 }
153}
154
155fn required_str(action: &serde_json::Value, key: &str, why: &str) -> Result<String> {
160 let raw = action
161 .get(key)
162 .and_then(|v| v.as_str())
163 .with_context(|| format!("'{key}' is required: {why}"))?;
164 let trimmed = raw.trim();
165 if trimmed.is_empty() {
166 return Err(anyhow::anyhow!("'{key}' must not be empty: {why}"));
167 }
168 message::validate_header_piece("value", key, trimmed)?;
169 Ok(trimmed.to_string())
170}
171
172fn optional_str(action: &serde_json::Value, key: &str) -> Result<Option<String>> {
173 match action.get(key).and_then(|v| v.as_str()) {
174 Some(s) if !s.trim().is_empty() => {
175 let trimmed = s.trim();
176 message::validate_header_piece("value", key, trimmed)?;
177 Ok(Some(trimmed.to_string()))
178 }
179 _ => Ok(None),
180 }
181}
182
183fn parse_location(action: &serde_json::Value, key: &str) -> Result<String> {
190 let raw = required_str(
191 action,
192 key,
193 "it is the absolute http(s) URL of the device description document that this \
194 advertisement points a control point at",
195 )?;
196 let url = url::Url::parse(&raw)
197 .with_context(|| format!("'{key}' is not a valid absolute URL: {raw:?}"))?;
198 match url.scheme() {
199 "http" | "https" => Ok(raw),
200 other => Err(anyhow::anyhow!(
201 "'{key}' has scheme '{other}'; a UPnP LOCATION must be http or https because a \
202 control point fetches the description document over HTTP"
203 )),
204 }
205}
206
207fn parse_max_age(action: &serde_json::Value) -> Result<u32> {
208 match action.get("cache_control_max_age") {
209 None | Some(serde_json::Value::Null) => Ok(DEFAULT_MAX_AGE),
210 Some(v) => {
211 let n = v
212 .as_u64()
213 .context("'cache_control_max_age' must be a non-negative number of seconds")?;
214 u32::try_from(n).context("'cache_control_max_age' is too large for a max-age")
215 }
216 }
217}
218
219fn parse_extra_headers(action: &serde_json::Value) -> Result<Vec<(String, String)>> {
225 let Some(value) = action.get("extra_headers") else {
226 return Ok(Vec::new());
227 };
228 if value.is_null() {
229 return Ok(Vec::new());
230 }
231 let obj = value.as_object().context(
232 "'extra_headers' must be an object of header name to value, e.g. \
233 {\"BOOTID.UPNP.ORG\": \"1\"}",
234 )?;
235
236 const RESERVED: &[&str] = &[
237 "CACHE-CONTROL",
238 "DATE",
239 "EXT",
240 "LOCATION",
241 "SERVER",
242 "ST",
243 "USN",
244 ];
245
246 let mut out = Vec::with_capacity(obj.len());
247 for (name, v) in obj {
248 let name = name.trim();
249 if name.is_empty() {
250 return Err(anyhow::anyhow!("'extra_headers' has an empty header name"));
251 }
252 message::validate_header_piece("name", name, name)?;
253 if name.contains(':') {
254 return Err(anyhow::anyhow!(
255 "extra header name '{name}' contains ':', which would produce a malformed \
256 header line"
257 ));
258 }
259 let upper = name.to_ascii_uppercase();
260 if RESERVED.contains(&upper.as_str()) {
261 return Err(anyhow::anyhow!(
262 "'{name}' is one of the mandatory response headers and is set from this \
263 action's own parameters ({}). Setting it again through extra_headers would \
264 emit it twice.",
265 RESERVED.join(", ")
266 ));
267 }
268 let value = v
269 .as_str()
270 .with_context(|| format!("extra header '{name}' must have a string value; got {v}"))?;
271 message::validate_header_piece("value", name, value)?;
272 out.push((upper, value.trim().to_string()));
273 }
274 Ok(out)
275}
276
277fn parse_nts(action: &serde_json::Value) -> Result<String> {
280 let nts = required_str(
281 action,
282 "nts",
283 "it says which kind of announcement this is: \"ssdp:alive\", \"ssdp:byebye\" or \
284 \"ssdp:update\"",
285 )?;
286 match nts.as_str() {
287 "ssdp:alive" | "ssdp:byebye" | "ssdp:update" => Ok(nts),
288 other => Err(anyhow::anyhow!(
289 "Unknown nts '{other}'. UDA 1.1 defines exactly three: \"ssdp:alive\" (the \
290 device is present), \"ssdp:byebye\" (it is leaving) and \"ssdp:update\" (its \
291 description changed). A control point ignores anything else."
292 )),
293 }
294}
295
296fn p(name: &str, type_hint: &str, description: &str) -> Parameter {
297 Parameter {
298 name: name.to_string(),
299 type_hint: type_hint.to_string(),
300 description: description.to_string(),
301 required: false,
302 }
303}
304
305fn required(name: &str, type_hint: &str, description: &str) -> Parameter {
306 Parameter {
307 name: name.to_string(),
308 type_hint: type_hint.to_string(),
309 description: description.to_string(),
310 required: true,
311 }
312}
313
314fn send_ssdp_response_action() -> ActionDefinition {
319 ActionDefinition {
320 name: "send_ssdp_response".to_string(),
321 description: "Answer an M-SEARCH with a unicast HTTP/1.1 200 OK, asserting that a \
322 device of type 'st' exists here and that its description document is \
323 at 'location'. Send this ONLY when the device you are playing actually \
324 matches the search target — a control point will cache what you say \
325 for cache_control_max_age seconds and then fetch the LOCATION URL. If \
326 it does not match, use no_response."
327 .to_string(),
328 parameters: vec![
329 required(
330 "st",
331 "string",
332 "The device or service type this response is for, e.g. \
333 'upnp:rootdevice', 'urn:schemas-upnp-org:device:MediaServer:1' or a \
334 'uuid:...'. It MUST be the concrete type. When the search target was a \
335 specific type it is echoed automatically if you omit this, but a search \
336 for 'ssdp:all' or 'upnp:rootdevice' has no single answer, so name the type",
337 ),
338 required(
339 "usn",
340 "string",
341 "Unique Service Name: the composite identity of this advertisement, \
342 conventionally 'uuid:<device-uuid>::<st>' (or just 'uuid:<device-uuid>' \
343 when st is that uuid). It is what a control point de-duplicates on",
344 ),
345 required(
346 "location",
347 "string",
348 "Absolute http(s) URL of the device description XML, e.g. \
349 'http://192.168.1.10:8080/description.xml'. Must be reachable; a control \
350 point fetches it immediately",
351 ),
352 p(
353 "server",
354 "string",
355 "SERVER header, in the form 'OS/version UPnP/1.1 product/version'. Defaults \
356 to the server's server_header startup parameter",
357 ),
358 p(
359 "cache_control_max_age",
360 "number",
361 "Seconds a control point may cache this advertisement. UDA 1.1 requires at \
362 least 1800, which is the default",
363 ),
364 p(
365 "extra_headers",
366 "object",
367 "Additional headers as a name-to-value map, e.g. \
368 {\"BOOTID.UPNP.ORG\": \"1\", \"CONFIGID.UPNP.ORG\": \"7\"}. The mandatory \
369 headers (CACHE-CONTROL, DATE, EXT, LOCATION, SERVER, ST, USN) come from the \
370 parameters above and must not be repeated here",
371 ),
372 ],
373 example: json!({
374 "type": "send_ssdp_response",
375 "st": "urn:schemas-upnp-org:device:MediaServer:1",
376 "usn": "uuid:9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33::urn:schemas-upnp-org:device:MediaServer:1",
377 "location": "http://192.168.1.10:8080/description.xml",
378 "server": "Linux/6.1 UPnP/1.1 NetGet-SSDP/1.0",
379 "cache_control_max_age": 1800
380 }),
381 log_template: None,
382 }
383}
384
385fn send_ssdp_notify_action() -> ActionDefinition {
386 ActionDefinition {
387 name: "send_ssdp_notify".to_string(),
388 description: "Announce to the SSDP multicast group with NOTIFY * HTTP/1.1. \
389 'ssdp:alive' asserts the device is present, 'ssdp:byebye' that it is \
390 leaving, 'ssdp:update' that its description changed. This is broadcast \
391 to every listener on the link, so send it only when the announcement \
392 is true of the device you are playing."
393 .to_string(),
394 parameters: vec![
395 required(
396 "nt",
397 "string",
398 "Notification Type: the device or service type being announced, e.g. \
399 'upnp:rootdevice' or 'urn:schemas-upnp-org:device:MediaServer:1'",
400 ),
401 required(
402 "nts",
403 "string",
404 "'ssdp:alive', 'ssdp:byebye' or 'ssdp:update'",
405 ),
406 required(
407 "usn",
408 "string",
409 "Unique Service Name, conventionally 'uuid:<device-uuid>::<nt>'",
410 ),
411 p(
412 "location",
413 "string",
414 "Absolute http(s) URL of the device description XML. Required for \
415 'ssdp:alive' and 'ssdp:update'; omitted from an 'ssdp:byebye', which \
416 carries only HOST, NT, NTS and USN",
417 ),
418 p(
419 "server",
420 "string",
421 "SERVER header. Defaults to the server_header startup parameter. Not sent \
422 on an ssdp:byebye",
423 ),
424 p(
425 "cache_control_max_age",
426 "number",
427 "Seconds a control point may cache this announcement; default 1800. Not \
428 sent on an ssdp:byebye",
429 ),
430 ],
431 example: json!({
432 "type": "send_ssdp_notify",
433 "nt": "urn:schemas-upnp-org:device:MediaServer:1",
434 "nts": "ssdp:alive",
435 "usn": "uuid:9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33::urn:schemas-upnp-org:device:MediaServer:1",
436 "location": "http://192.168.1.10:8080/description.xml"
437 }),
438 log_template: None,
439 }
440}
441
442fn no_response_action() -> ActionDefinition {
443 ActionDefinition {
444 name: "no_response".to_string(),
445 description: "Say nothing, deliberately. This is the CORRECT and most common answer \
446 in SSDP: UDA 1.1 §1.3.3 requires a device whose type does not match \
447 the search target to stay silent, and every control point expects \
448 that. Use it whenever the search target does not describe the device \
449 you are playing. It is a real decision — the server records it as \
450 yours, distinct from you returning nothing at all."
451 .to_string(),
452 parameters: vec![p(
453 "reason",
454 "string",
455 "Why nothing is being sent, for the log. Not put on the wire",
456 )],
457 example: json!({
458 "type": "no_response",
459 "reason": "search target urn:schemas-upnp-org:device:Printer:1 does not match this media server"
460 }),
461 log_template: None,
462 }
463}
464
465pub static SSDP_MSEARCH_EVENT: LazyLock<EventType> = LazyLock::new(|| {
470 EventType::new(
471 "ssdp_msearch",
472 "A control point is searching for UPnP devices. Answer with send_ssdp_response ONLY \
473 if the device you are playing matches the search target; otherwise answer with \
474 no_response, which is what a real device does and is the common case. There is no \
475 error response in SSDP — anything you send is an assertion that a device exists at \
476 the LOCATION you name.",
477 json!({
478 "type": "send_ssdp_response",
479 "st": "urn:schemas-upnp-org:device:MediaServer:1",
480 "usn": "uuid:9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33::urn:schemas-upnp-org:device:MediaServer:1",
481 "location": "http://192.168.1.10:8080/description.xml"
482 }),
483 )
484 .with_parameters(vec![
485 p(
486 "st",
487 "string",
488 "Search Target. Either a concrete type ('urn:schemas-upnp-org:device:...', \
489 'uuid:...'), or the wildcard 'ssdp:all' (every device and service), or \
490 'upnp:rootdevice' (root devices only). For a wildcard you must name the \
491 concrete type in your response's 'st'",
492 ),
493 p(
494 "mx",
495 "number",
496 "Maximum wait, in seconds, the control point will tolerate before an answer \
497 (1-5). The server already applies a random delay up to this bound, capped by \
498 its max_response_delay_ms parameter; you do not need to do anything with it",
499 ),
500 p(
501 "man",
502 "string",
503 "The MAN header verbatim. A conforming M-SEARCH sends \"ssdp:discover\" \
504 including the quotes; anything else means the sender is not doing UPnP \
505 discovery and is usually worth ignoring",
506 ),
507 p(
508 "host",
509 "string",
510 "The HOST header the search was addressed to — '239.255.255.250:1900' for a \
511 multicast search, or this server's own address for a unicast one",
512 ),
513 p("source_address", "string", "ip:port the search came from"),
514 p(
515 "user_agent",
516 "string",
517 "USER-AGENT header, naming the control point's OS and product, or null",
518 ),
519 p(
520 "headers",
521 "object",
522 "Every header in the message as a name-to-value map, including ones not broken \
523 out above",
524 ),
525 ])
526 .with_actions(vec![send_ssdp_response_action(), no_response_action()])
527});
528
529pub static SSDP_NOTIFY_EVENT: LazyLock<EventType> = LazyLock::new(|| {
530 EventType::new(
531 "ssdp_notify",
532 "Another device announced itself on the SSDP multicast group. Nothing is expected in \
533 reply — a NOTIFY is not a request — so no_response is almost always right. You may \
534 answer with send_ssdp_notify to announce this server in turn, which multicasts to \
535 everyone on the link.",
536 json!({ "type": "no_response", "reason": "a NOTIFY needs no answer" }),
537 )
538 .with_parameters(vec![
539 p(
540 "nt",
541 "string",
542 "Notification Type: the device or service type being announced",
543 ),
544 p(
545 "nts",
546 "string",
547 "'ssdp:alive', 'ssdp:byebye' or 'ssdp:update'",
548 ),
549 p("usn", "string", "Unique Service Name of the announcer"),
550 p(
551 "location",
552 "string",
553 "URL of the announcer's description document, or null (a byebye carries none)",
554 ),
555 p("server", "string", "The announcer's SERVER header, or null"),
556 p(
557 "cache_control_max_age",
558 "number",
559 "Seconds the announcement is valid for, from CACHE-CONTROL, or null",
560 ),
561 p("host", "string", "The HOST header of the announcement"),
562 p(
563 "source_address",
564 "string",
565 "ip:port the announcement came from",
566 ),
567 p(
568 "headers",
569 "object",
570 "Every header in the message as a name-to-value map",
571 ),
572 ])
573 .with_actions(vec![send_ssdp_notify_action(), no_response_action()])
574});
575
576impl Protocol for SsdpProtocol {
581 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
582 Vec::new()
587 }
588
589 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
590 vec![
591 send_ssdp_response_action(),
592 send_ssdp_notify_action(),
593 no_response_action(),
594 ]
595 }
596
597 fn protocol_name(&self) -> &'static str {
598 "SSDP"
599 }
600
601 fn get_event_types(&self) -> Vec<EventType> {
602 vec![SSDP_MSEARCH_EVENT.clone(), SSDP_NOTIFY_EVENT.clone()]
603 }
604
605 fn stack_name(&self) -> &'static str {
606 "ETH>IP>UDP>SSDP"
607 }
608
609 fn keywords(&self) -> Vec<&'static str> {
610 vec![
613 "ssdp",
614 "upnp",
615 "upnp discovery",
616 "m-search",
617 "ssdp:discover",
618 ]
619 }
620
621 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
622 use crate::protocol::metadata::{
623 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
624 };
625
626 ProtocolMetadataV2::builder()
627 .connectionless()
631 .deliberately_silent()
635 .state(DevelopmentState::Experimental)
636 .privilege_requirement(PrivilegeRequirement::None)
639 .well_known_udp_port(1900)
640 .implementation(
641 "Hand-rolled HTTPU codec (src/server/ssdp/message.rs) over a tokio \
642 UdpSocket. Parses M-SEARCH and NOTIFY, renders the UDA 1.1 §1.3.3 response \
643 header set and §1.2 announcements, applies the MX response jitter, and \
644 attempts a multicast group join for 239.255.255.250 / [FF02::C] that is \
645 logged but non-fatal when it fails. Does NOT implement the UPnP device \
646 description document, SOAP control, GENA eventing, or any HTTP server for \
647 the LOCATION URL it advertises: the model supplies a LOCATION, and \
648 something else must serve it.",
649 )
650 .llm_control(
651 "The model decides whether this server matches each search target at all, \
652 and invents every device: its type, UUID, USN, description URL, SERVER \
653 string and cache lifetime. Declining is an explicit action (no_response) \
654 rather than an absence.",
655 )
656 .e2e_testing(
657 "Mocked end-to-end through the real binary over a raw UDP socket bound to \
658 127.0.0.1, sending unicast M-SEARCH and NOTIFY. Codec asserted against \
659 literal UDA 1.1 message text. NOT validated against any third-party UPnP \
660 control point: no Rust SSDP client found could be pointed at a unicast \
661 loopback address and an ephemeral port, and the real-world control points \
662 (Windows/macOS/Sonos) only ever multicast to 239.255.255.250:1900, which \
663 needs the privileged well-known port and a real link.",
664 )
665 .notes(
666 "DELIBERATELY SILENT ON FAILURE. SSDP has no error message: both messages a \
667 device can send are positive assertions that a device exists at a URL, and \
668 a control point caches them for max-age and then fetches that URL. So an \
669 LLM error, an unusable answer, or an explicit no_response all put NOTHING \
670 on the wire, and the distinction is carried in the log as \
671 decision=fail_closed_llm_error / model_silent / model_reject. Do not \
672 'fix' the silence with a WireFailure string. The multicast join is \
673 best-effort and logged rather than fatal; a unicast M-SEARCH sent straight \
674 to the port is answered whether or not it succeeded. Measured on macOS: \
675 the join succeeds even from a 127.0.0.1 bind, but SENDING to the group \
676 from one fails with EADDRNOTAVAIL, which is what notify_target is for.",
677 )
678 .max_inbound_bytes(crate::server::ssdp::message::MAX_MESSAGE_LEN)
679 .build()
680 }
681
682 fn description(&self) -> &'static str {
683 "SSDP / UPnP discovery server (HTTPU over UDP): answers M-SEARCH and multicasts \
684 NOTIFY announcements"
685 }
686
687 fn example_prompt(&self) -> &'static str {
688 "run an ssdp server on port 1900 pretending to be a MediaServer at \
689 http://192.168.1.10:8080/description.xml; answer searches for MediaServer and \
690 ssdp:all, and stay silent for anything else"
691 }
692
693 fn group_name(&self) -> &'static str {
694 "Application"
695 }
696
697 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
698 vec![
699 ParameterDefinition {
700 name: "max_response_delay_ms".to_string(),
701 type_hint: "number".to_string(),
702 description: "Ceiling, in milliseconds, on the random delay applied before \
703 answering an M-SEARCH. A real device waits a random interval \
704 between 0 and the request's MX seconds so a whole network's \
705 answers do not arrive at once, and this cap keeps that \
706 realistic without making every exchange take up to 5 seconds. \
707 The actual delay is uniform over 0..min(MX*1000, this). \
708 Default 1000; set 0 to answer immediately."
709 .to_string(),
710 required: false,
711 example: json!(1000),
712 default: None,
713 },
714 ParameterDefinition {
715 name: "server_header".to_string(),
716 type_hint: "string".to_string(),
717 description: "Default SERVER header for responses and announcements, in the \
718 UDA 1.1 form 'OS/version UPnP/1.1 product/version'. An \
719 action's own 'server' parameter overrides it. Default \
720 'NetGet/1.0 UPnP/1.1 NetGet-SSDP/1.0'."
721 .to_string(),
722 required: false,
723 example: json!("Linux/6.1 UPnP/1.1 MiniDLNA/1.3.0"),
724 default: None,
725 },
726 ParameterDefinition {
727 name: "join_multicast".to_string(),
728 type_hint: "boolean".to_string(),
729 description: "Whether to join the SSDP multicast group (239.255.255.250 for \
730 IPv4, FF02::C for IPv6) so multicast M-SEARCHes are received. \
731 Default true. The join is best-effort: if it fails the server \
732 logs a warning and keeps running, and still answers unicast \
733 M-SEARCH sent straight to its port."
734 .to_string(),
735 required: false,
736 example: json!(true),
737 default: None,
738 },
739 ParameterDefinition {
740 name: "multicast_interface".to_string(),
741 type_hint: "string".to_string(),
742 description: "Local IPv4 address of the interface to join the group on, e.g. \
743 '192.168.1.10'. Default '0.0.0.0', letting the kernel choose. \
744 Only used for an IPv4 bind; an IPv6 bind joins on interface \
745 index 0."
746 .to_string(),
747 required: false,
748 example: json!("0.0.0.0"),
749 default: None,
750 },
751 ParameterDefinition {
752 name: "notify_target".to_string(),
753 type_hint: "string".to_string(),
754 description: "ip:port that send_ssdp_notify announcements are actually sent \
755 to. Defaults to the protocol's multicast group — \
756 '239.255.255.250:1900' for an IPv4 bind, '[ff02::c]:1900' for \
757 IPv6. Override it to point announcements at one listener \
758 instead: a socket bound to loopback usually has no route to \
759 the multicast group, so on 127.0.0.1 the default send fails \
760 and this is how announcements are made observable. The HOST \
761 header of the announcement always names the group, whatever \
762 this is set to, because that is what the header means."
763 .to_string(),
764 required: false,
765 example: json!("239.255.255.250:1900"),
766 default: None,
767 },
768 ]
769 }
770
771 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
772 use crate::llm::actions::StartupExamples;
773
774 StartupExamples::new(
775 json!({
777 "type": "open_server",
778 "port": 1900,
779 "base_stack": "ssdp",
780 "startup_params": {
781 "server_header": "Linux/6.1 UPnP/1.1 MiniDLNA/1.3.0",
782 "max_response_delay_ms": 1000
783 },
784 "instruction": "You are a UPnP MediaServer with uuid \
785 9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33, described at \
786 http://192.168.1.10:8080/description.xml. Answer searches \
787 for ssdp:all, upnp:rootdevice and \
788 urn:schemas-upnp-org:device:MediaServer:1. Stay silent for \
789 every other search target."
790 }),
791 json!({
793 "type": "open_server",
794 "port": 1900,
795 "base_stack": "ssdp",
796 "event_handlers": [{
797 "event_pattern": "ssdp_msearch",
798 "handler": {
799 "type": "script",
800 "language": "python",
801 "code": "st = event.get('st', '')\nuuid = 'uuid:9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33'\ntarget = 'urn:schemas-upnp-org:device:MediaServer:1'\nif st in ('ssdp:all', 'upnp:rootdevice', target):\n respond([{'type': 'send_ssdp_response', 'st': target, 'usn': uuid + '::' + target, 'location': 'http://192.168.1.10:8080/description.xml'}])\nelse:\n respond([{'type': 'no_response', 'reason': 'not a media server search'}])"
802 }
803 }]
804 }),
805 json!({
807 "type": "open_server",
808 "port": 1900,
809 "base_stack": "ssdp",
810 "event_handlers": [{
811 "event_pattern": "ssdp_msearch",
812 "handler": {
813 "type": "static",
814 "actions": [{
815 "type": "send_ssdp_response",
816 "st": "upnp:rootdevice",
817 "usn": "uuid:9f8d2b31-4c5e-4a90-8f21-0e6f5a7c1d33::upnp:rootdevice",
818 "location": "http://192.168.1.10:8080/description.xml"
819 }]
820 }
821 }]
822 }),
823 )
824 }
825}
826
827impl Server for SsdpProtocol {
828 fn spawn(
829 &self,
830 ctx: crate::protocol::SpawnContext,
831 ) -> std::pin::Pin<
832 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
833 > {
834 Box::pin(async move { super::SsdpServer::spawn_with_llm_actions(ctx).await })
835 }
836
837 fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
838 let action_type = action
839 .get("type")
840 .and_then(|v| v.as_str())
841 .context("Missing 'type' field in action")?;
842
843 match action_type {
844 "send_ssdp_response" => {
845 let st = self.resolve_st(&action)?;
846 let usn = required_str(
847 &action,
848 "usn",
849 "it is the unique identity a control point de-duplicates advertisements \
850 on, conventionally 'uuid:<device-uuid>::<st>'",
851 )?;
852 let location = parse_location(&action, "location")?;
853 let server = optional_str(&action, "server")?
854 .unwrap_or_else(|| self.server_default().to_string());
855 let max_age = parse_max_age(&action)?;
856 let extra_headers = parse_extra_headers(&action)?;
857
858 let rendered = message::render_search_response(&SearchResponse {
859 st,
860 usn,
861 location,
862 server,
863 max_age,
864 date: message::http_date(chrono::Utc::now()),
865 extra_headers,
866 });
867 Ok(ActionResult::Output(rendered.into_bytes()))
868 }
869
870 "send_ssdp_notify" => {
871 let nt = required_str(
872 &action,
873 "nt",
874 "it is the device or service type being announced",
875 )?;
876 let nts = parse_nts(&action)?;
877 let usn = required_str(
878 &action,
879 "usn",
880 "it is the unique identity of the advertisement, conventionally \
881 'uuid:<device-uuid>::<nt>'",
882 )?;
883
884 let is_byebye = nts == "ssdp:byebye";
890 let (location, server, max_age) = if is_byebye {
891 (None, None, None)
892 } else {
893 (
894 Some(parse_location(&action, "location")?),
895 Some(
896 optional_str(&action, "server")?
897 .unwrap_or_else(|| self.server_default().to_string()),
898 ),
899 Some(parse_max_age(&action)?),
900 )
901 };
902
903 let rendered = message::render_notify(&NotifyMessage {
904 host: self.notify_host(),
905 nt,
906 nts: nts.clone(),
907 usn,
908 location,
909 server,
910 max_age,
911 });
912
913 Ok(ActionResult::Custom {
917 name: "ssdp_notify".to_string(),
918 data: json!({ "message": rendered, "nts": nts }),
919 })
920 }
921
922 "no_response" => {
923 let reason = action
928 .get("reason")
929 .and_then(|v| v.as_str())
930 .unwrap_or("no reason given");
931 Ok(ActionResult::Custom {
932 name: "ssdp_no_response".to_string(),
933 data: json!({ "reason": reason }),
934 })
935 }
936
937 _ => Err(anyhow::anyhow!("Unknown SSDP action: {}", action_type)),
938 }
939 }
940}