Skip to main content

netget/client/ssdp/
mod.rs

1//! SSDP / UPnP discovery client — socket loop.
2//!
3//! A control point: it sends `M-SEARCH * HTTP/1.1` and reads what answers. Pointed at a real
4//! LAN this enumerates actual hardware — routers, TVs, printers, media servers — and the
5//! model decides from what it finds what to search for next.
6//!
7//! # The shape that makes this different from every other client here
8//!
9//! **SSDP is one-to-many.** Every other client in this tree issues a request and reads *the*
10//! response; SSDP issues one datagram and an unknown number of devices answer over the next
11//! `MX` seconds, each from its own address. Returning on the first datagram would report one
12//! device on a network of thirty. So a search opens a *collection window*: each responder
13//! raises its own `ssdp_search_response` event as it arrives, and when the window closes a
14//! single `ssdp_search_complete` says how many answered and who they were.
15//!
16//! # One task owns the search
17//!
18//! Three things need to interleave — datagrams arriving, the collection window expiring, and
19//! injected commands from the dashboard — and all three mutate the same search state. Rather
20//! than share it behind a mutex, one task ([`Session::run`]) owns it outright and selects
21//! over all three sources. A separate, minimal receive task does nothing but `recv_from` and
22//! forward, so the socket is always ready even while the session is inside an LLM call and no
23//! datagram is dropped for want of a reader.
24//!
25//! The cost is that an injected `[ send ]` waits behind an in-flight LLM call rather than
26//! running concurrently (`udp` gives commands their own task for exactly that reason). That
27//! is the right trade here: a command that started a second search from another task would
28//! race the window bookkeeping, and the command channel is bounded, so the command queues
29//! rather than being lost.
30//!
31//! # Iterative discovery is bounded, and the bound is loud
32//!
33//! Discovery is inherently recursive: search, see what answers, search again more
34//! specifically. Nothing in that shape converges on its own, so a search started in reply to
35//! an event carries a depth, and [`MAX_FOLLOWUP_DEPTH`] refuses to go deeper — with an ERROR
36//! on both log channels naming the limit, never silently.
37//!
38//! No `Box::pin` is needed for it, and that is worth saying because the usual fix here is a
39//! boxed recursive call. The cycle already passes through a queue: `send_msearch` only
40//! *enqueues* a search, the session loop starts it, the socket delivers the answers later,
41//! and the events those raise are handled on a subsequent turn of the same loop. There is no
42//! `async fn` that awaits itself, so there is no infinitely-sized future to box — the same
43//! situation the root `CLAUDE.md` describes for `datalink`'s pcap loop.
44
45pub mod actions;
46
47pub use actions::SsdpClientProtocol;
48
49use anyhow::{Context, Result};
50use std::collections::{HashSet, VecDeque};
51use std::net::{Ipv4Addr, SocketAddr};
52use std::sync::Arc;
53use std::time::Duration;
54use tokio::net::UdpSocket;
55use tokio::sync::mpsc;
56use tokio::time::Instant;
57use tracing::{debug, error, info, trace, warn};
58
59use crate::client::llm_budget::call_llm_for_client;
60use crate::client::ssdp::actions::{
61    SSDP_CLIENT_CONNECTED_EVENT, SSDP_NOTIFY_RECEIVED_EVENT, SSDP_SEARCH_COMPLETE_EVENT,
62    SSDP_SEARCH_RESPONSE_EVENT,
63};
64use crate::llm::actions::client_trait::{Client, ClientActionResult};
65use crate::llm::ollama_client::OllamaClient;
66use crate::llm::ClientLlmResult;
67use crate::protocol::{Event, StartupParams};
68use crate::server::ssdp::message::{self, HttpuMessage, SSDP_GROUP_V4, SSDP_PORT};
69use crate::state::app_state::AppState;
70use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
71use crate::state::{AccessLogOwner, ClientId, ClientStatus};
72
73/// How many searches deep a chain started by an event may go.
74///
75/// Chain: connected event → search (depth 0) → its responses/completion (depth 1) → a
76/// narrower search (depth 1) → … A model that answers every `ssdp_search_complete` with
77/// another search would otherwise sweep the network forever, and the per-client LLM budget
78/// (100 calls) is a blunt backstop rather than a bound on *this*.
79///
80/// Six allows the search-narrow-narrow pattern a real enumeration needs with room to spare,
81/// and stops well short of the budget so exhaustion is reported as what it is.
82pub const MAX_FOLLOWUP_DEPTH: usize = 6;
83
84/// Default `USER-AGENT` when the operator does not set one.
85const DEFAULT_USER_AGENT: &str = "NetGet/1.0 UPnP/1.1 NetGet-SSDP-Client/1.0";
86
87/// A datagram handed from the receive task to the session task.
88struct Inbound {
89    data: Vec<u8>,
90    source: SocketAddr,
91}
92
93/// A search that has been asked for but not yet put on the wire.
94#[derive(Clone, Debug)]
95struct PendingSearch {
96    st: String,
97    mx: u32,
98    target: SocketAddr,
99    /// Depth of the *event* that asked for this search; see [`MAX_FOLLOWUP_DEPTH`].
100    depth: usize,
101}
102
103/// A search whose collection window is open.
104struct ActiveSearch {
105    st: String,
106    mx: u32,
107    target: SocketAddr,
108    depth: usize,
109    /// When the window closes and `ssdp_search_complete` fires.
110    deadline: Instant,
111    /// One entry per distinct responder, in arrival order.
112    responders: Vec<serde_json::Value>,
113    /// `(source address, USN)` of everything already reported. A device that retransmits
114    /// its answer is one device, not two — but a device answering `ssdp:all` with several
115    /// *different* USNs is genuinely several services, and the pair keeps those apart.
116    seen: HashSet<(String, String)>,
117    duplicates: usize,
118}
119
120/// SSDP discovery client.
121pub struct SsdpClient;
122
123impl SsdpClient {
124    /// Bind the search socket and run discovery under LLM control.
125    ///
126    /// Returns as soon as the socket is bound and the loops are spawned: the
127    /// `ssdp_connected` LLM call happens inside the session task, so a `manual` routing rule
128    /// that parks it for a human does not hold up client creation.
129    pub async fn connect_with_llm_actions(
130        remote_addr: String,
131        llm_client: OllamaClient,
132        app_state: Arc<AppState>,
133        status_tx: mpsc::UnboundedSender<String>,
134        client_id: ClientId,
135        startup_params: Option<StartupParams>,
136    ) -> Result<SocketAddr> {
137        let params = startup_params.as_ref();
138
139        let bind_address = match params {
140            Some(p) => p
141                .get_optional_string("bind_address")?
142                .unwrap_or_else(|| "0.0.0.0".to_string()),
143            None => "0.0.0.0".to_string(),
144        };
145        let local_port = match params {
146            Some(p) => p.get_optional_u64("local_port")?.unwrap_or(0),
147            None => 0,
148        };
149        let join_multicast = match params {
150            Some(p) => p.get_optional_bool("join_multicast")?.unwrap_or(true),
151            None => true,
152        };
153        let multicast_interface = match params {
154            Some(p) => p
155                .get_optional_string("multicast_interface")?
156                .unwrap_or_else(|| "0.0.0.0".to_string()),
157            None => "0.0.0.0".to_string(),
158        };
159        let response_window_ms = match params {
160            Some(p) => p.get_optional_u64("response_window_ms")?.unwrap_or(0),
161            None => 0,
162        };
163        let user_agent = match params {
164            Some(p) => p
165                .get_optional_string("user_agent")?
166                .unwrap_or_else(|| DEFAULT_USER_AGENT.to_string()),
167            None => DEFAULT_USER_AGENT.to_string(),
168        };
169
170        let local_port = u16::try_from(local_port)
171            .map_err(|_| anyhow::anyhow!("local_port must be 0..=65535, got {local_port}"))?;
172
173        // Where searches go unless an action overrides it. The multicast group is the
174        // default a real scan wants; a unicast address searches exactly one device.
175        let default_target = resolve_target(&remote_addr).await?;
176
177        // Bind first and propagate the error: a client reported Connected on a socket that
178        // never bound is the ARP/DataLink defect wearing a different hat.
179        let socket = UdpSocket::bind(format!("{bind_address}:{local_port}"))
180            .await
181            .with_context(|| {
182                format!(
183                    "Failed to bind SSDP search socket to {bind_address}:{local_port}. \
184                     Port 1900 is often already held by a system UPnP service; use \
185                     local_port 0 to search from an ephemeral port instead (NOTIFY \
186                     announcements will then not be seen)."
187                )
188            })?;
189        let local_addr = socket.local_addr()?;
190
191        // Best effort, exactly like the server half. Searching does not depend on the join —
192        // replies to our own M-SEARCH come back unicast — so refusing to start here would
193        // make the client unusable for the local testing it is most used for. Logged rather
194        // than passed over, because a silent failure presents as "no device ever announces
195        // itself", which is indistinguishable from a broken client.
196        let mut multicast_joined = false;
197        if join_multicast {
198            match multicast_interface.parse::<Ipv4Addr>() {
199                Ok(iface) => match socket.join_multicast_v4(SSDP_GROUP_V4, iface) {
200                    Ok(()) => {
201                        multicast_joined = true;
202                        info!(
203                            "SSDP client {} joined {} on interface {}",
204                            client_id, SSDP_GROUP_V4, iface
205                        );
206                        if local_port != SSDP_PORT {
207                            // Announcements are multicast to the group on the well-known
208                            // port; a socket on an ephemeral port is in the group but will
209                            // never be delivered any.
210                            warn!(
211                                "SSDP client {} joined the group but is bound to port {} \
212                                 rather than {}, so NOTIFY announcements will not arrive. \
213                                 Set startup param local_port to {} to overhear them.",
214                                client_id, local_port, SSDP_PORT, SSDP_PORT
215                            );
216                        }
217                    }
218                    Err(e) => {
219                        warn!(
220                            "SSDP client {} could not join {}: {}. Unicast searches still \
221                             work; unsolicited NOTIFY announcements will not be seen.",
222                            client_id, SSDP_GROUP_V4, e
223                        );
224                        let _ = status_tx.send(format!(
225                            "[CLIENT] ⚠ SSDP client {} could not join the multicast group: {}",
226                            client_id, e
227                        ));
228                    }
229                },
230                Err(e) => {
231                    warn!(
232                        "SSDP client {} ignoring multicast_interface '{}': not an IPv4 \
233                         address ({})",
234                        client_id, multicast_interface, e
235                    );
236                }
237            }
238        }
239
240        info!(
241            "SSDP client {} bound to {} (default search target: {}, multicast joined: {})",
242            client_id, local_addr, default_target, multicast_joined
243        );
244
245        app_state
246            .update_client_status(client_id, ClientStatus::Connected)
247            .await;
248        // `ClientInstance::connection` (and its byte counters) is never constructed anywhere
249        // in this tree, so there is no per-client stats plumbing to feed. `protocol_data` is
250        // the mechanism that does exist and that the dashboard reads.
251        app_state
252            .with_client_mut(client_id, |client| {
253                client.set_protocol_field(
254                    "ssdp_local_addr".to_string(),
255                    serde_json::json!(local_addr.to_string()),
256                );
257                client.set_protocol_field(
258                    "ssdp_default_target".to_string(),
259                    serde_json::json!(default_target.to_string()),
260                );
261                client.set_protocol_field(
262                    "ssdp_multicast_joined".to_string(),
263                    serde_json::json!(multicast_joined),
264                );
265            })
266            .await;
267        let _ = status_tx.send(format!(
268            "[CLIENT] SSDP client {} ready on {} (searching {})",
269            client_id, local_addr, default_target
270        ));
271        let _ = status_tx.send("__UPDATE_UI__".to_string());
272
273        // Registered BEFORE anything that can park: a dashboard-created client defaults to a
274        // `*` -> manual rule, so the connected event can wait on a human for minutes, and
275        // `[ send ]` must work for the whole park rather than reading "no command channel".
276        let command_rx =
277            crate::client::command_support::register_command_channel(&app_state, client_id).await;
278
279        let socket = Arc::new(socket);
280        let (inbound_tx, inbound_rx) = mpsc::unbounded_channel::<Inbound>();
281
282        // Receive task: nothing but recv_from and forward. Kept free of LLM calls on purpose
283        // — the session task is inside a model round-trip for most of a busy window, and a
284        // reader that waited on it would leave datagrams in the kernel buffer to be dropped.
285        let recv_socket = socket.clone();
286        let recv_state = app_state.clone();
287        let recv_status = status_tx.clone();
288        let recv_task = tokio::spawn(async move {
289            // MAX_MESSAGE_LEN is the largest message the codec will parse; one byte more
290            // lets an over-long datagram be *detected* rather than silently truncated into
291            // plausible-looking headers.
292            let mut buffer = vec![0u8; message::MAX_MESSAGE_LEN + 1];
293            loop {
294                match recv_socket.recv_from(&mut buffer).await {
295                    Ok((n, source)) => {
296                        trace!(
297                            "SSDP client {} received {} bytes from {}",
298                            client_id,
299                            n,
300                            source
301                        );
302                        if inbound_tx
303                            .send(Inbound {
304                                data: buffer[..n].to_vec(),
305                                source,
306                            })
307                            .is_err()
308                        {
309                            // The session task ended; nothing left to hand datagrams to.
310                            break;
311                        }
312                    }
313                    Err(e) => {
314                        error!("SSDP client {} receive error: {}", client_id, e);
315                        recv_state
316                            .update_client_status(client_id, ClientStatus::Error(e.to_string()))
317                            .await;
318                        let _ = recv_status.send(format!(
319                            "[CLIENT] ✖ SSDP client {} receive error: {}",
320                            client_id, e
321                        ));
322                        let _ = recv_status.send("__UPDATE_UI__".to_string());
323                        break;
324                    }
325                }
326            }
327        });
328        app_state.register_client_task(client_id, recv_task).await;
329
330        let session = Session {
331            client_id,
332            socket: socket.clone(),
333            app_state: app_state.clone(),
334            llm_client,
335            status_tx: status_tx.clone(),
336            protocol: SsdpClientProtocol::new(),
337            default_target,
338            local_addr,
339            multicast_joined,
340            response_window_ms,
341            user_agent,
342            memory: String::new(),
343            active: None,
344            queue: VecDeque::new(),
345            done: false,
346        };
347
348        let session_task = tokio::spawn(async move {
349            session.run(inbound_rx, command_rx).await;
350        });
351        app_state
352            .register_client_task(client_id, session_task)
353            .await;
354
355        Ok(local_addr)
356    }
357}
358
359/// Resolve the client's `remote_addr` into the default search destination.
360///
361/// A literal `ip:port` is used as-is; anything else goes through the resolver, so
362/// `router.local:1900` works. The default for a real scan is the multicast group
363/// `239.255.255.250:1900`.
364async fn resolve_target(remote_addr: &str) -> Result<SocketAddr> {
365    if let Ok(addr) = remote_addr.parse::<SocketAddr>() {
366        return Ok(addr);
367    }
368    tokio::net::lookup_host(remote_addr)
369        .await
370        .with_context(|| format!("Failed to resolve SSDP search target '{remote_addr}'"))?
371        .next()
372        .ok_or_else(|| anyhow::anyhow!("'{remote_addr}' resolved to no addresses"))
373}
374
375/// What applying one action did. Shared by the LLM path and the command channel so an
376/// injected `[ send ]` and a model-chosen action cannot diverge.
377enum Applied {
378    /// A search was queued. It reaches the wire on the next turn of the session loop, so no
379    /// byte count exists yet and reporting one would be inventing it.
380    Queued {
381        st: String,
382    },
383    Waited,
384    Disconnect,
385    Refused(String),
386}
387
388/// Everything one discovery session owns. Single-task by construction: no mutex, because
389/// nothing else touches it.
390struct Session {
391    client_id: ClientId,
392    socket: Arc<UdpSocket>,
393    app_state: Arc<AppState>,
394    llm_client: OllamaClient,
395    status_tx: mpsc::UnboundedSender<String>,
396    protocol: SsdpClientProtocol,
397    default_target: SocketAddr,
398    local_addr: SocketAddr,
399    multicast_joined: bool,
400    response_window_ms: u64,
401    user_agent: String,
402    memory: String,
403    active: Option<ActiveSearch>,
404    queue: VecDeque<PendingSearch>,
405    done: bool,
406}
407
408impl Session {
409    async fn run(
410        mut self,
411        mut inbound_rx: mpsc::UnboundedReceiver<Inbound>,
412        mut command_rx: mpsc::Receiver<ClientCommand>,
413    ) {
414        // The session opens with the connected event: nothing is on the wire yet, and the
415        // model's answer is what decides the first search.
416        let event = Event::new(
417            &SSDP_CLIENT_CONNECTED_EVENT,
418            serde_json::json!({
419                "local_addr": self.local_addr.to_string(),
420                "default_target": self.default_target.to_string(),
421                "multicast_joined": self.multicast_joined,
422            }),
423        );
424        // Bound before the `if let`, deliberately: a temporary in an `if let` scrutinee lives
425        // for the whole block, so `if let Some(a) = self.call_model(..).await` keeps the
426        // `&mut self` borrow alive across `self.apply_actions(..)` inside it and does not
427        // compile. Every call site here has the same shape for the same reason.
428        let actions = self.call_model(&event).await;
429        if let Some(actions) = actions {
430            self.apply_actions(actions, 0).await;
431        }
432
433        while !self.done {
434            // Start whatever the last turn asked for. A search is only ever *enqueued* by an
435            // action; it is started here, which is what keeps the search → event → search
436            // cycle from being a recursive call.
437            self.drain_queue().await;
438
439            let deadline = self.active.as_ref().map(|s| s.deadline);
440
441            tokio::select! {
442                biased;
443
444                inbound = inbound_rx.recv() => {
445                    match inbound {
446                        Some(inbound) => self.handle_inbound(inbound).await,
447                        None => {
448                            debug!(
449                                "SSDP client {} receive loop ended; session closing",
450                                self.client_id
451                            );
452                            break;
453                        }
454                    }
455                }
456
457                command = command_rx.recv() => {
458                    match command {
459                        Some(command) => self.handle_command(command).await,
460                        None => break,
461                    }
462                }
463
464                _ = tokio::time::sleep_until(deadline.unwrap_or_else(Instant::now)),
465                    if deadline.is_some() =>
466                {
467                    self.finish_search().await;
468                }
469            }
470        }
471
472        self.app_state
473            .update_client_status(self.client_id, ClientStatus::Disconnected)
474            .await;
475        // Every exit lands here: drop the command handle so the dashboard stops offering
476        // [ send ] on a dead client and a late send fails fast instead of timing out.
477        self.app_state.remove_client_handle(self.client_id).await;
478        let _ = self.status_tx.send(format!(
479            "[CLIENT] SSDP client {} discovery finished",
480            self.client_id
481        ));
482        let _ = self.status_tx.send("__UPDATE_UI__".to_string());
483    }
484
485    // -----------------------------------------------------------------------
486    // Searching
487    // -----------------------------------------------------------------------
488
489    /// Start queued searches while nothing is in flight.
490    ///
491    /// A send failure is reported to the **model**, not only to the log: it raises
492    /// `ssdp_search_complete` with `send_error` set and no responders, so the model can react
493    /// — which matters most for the one failure that actually happens here, sending to the
494    /// multicast group from a loopback-bound socket (`EADDRNOTAVAIL`, because loopback
495    /// carries no multicast route). The answer to that is a unicast `target`, and only the
496    /// model can choose one.
497    async fn drain_queue(&mut self) {
498        while self.active.is_none() {
499            let Some(pending) = self.queue.pop_front() else {
500                break;
501            };
502            match self.send_search(&pending).await {
503                Ok(bytes) => {
504                    info!(
505                        "SSDP client {} sent M-SEARCH ST={} MX={} to {} ({} bytes)",
506                        self.client_id, pending.st, pending.mx, pending.target, bytes
507                    );
508                    let _ = self.status_tx.send(format!(
509                        "[CLIENT] SSDP client {} searching for {} (MX {})",
510                        self.client_id, pending.st, pending.mx
511                    ));
512                    let _ = self.status_tx.send("__UPDATE_UI__".to_string());
513                }
514                Err(e) => {
515                    let detail = send_failure_hint(&e, pending.target, self.local_addr);
516                    error!(
517                        "SSDP client {} could not send M-SEARCH ST={} to {}: {}",
518                        self.client_id, pending.st, pending.target, detail
519                    );
520                    let _ = self.status_tx.send(format!(
521                        "[CLIENT] ✖ SSDP client {} M-SEARCH to {} failed: {}",
522                        self.client_id, pending.target, detail
523                    ));
524                    self.complete_search(&pending, Vec::new(), 0, Some(detail))
525                        .await;
526                }
527            }
528        }
529    }
530
531    /// Render and send one M-SEARCH, then open its collection window.
532    async fn send_search(&mut self, pending: &PendingSearch) -> Result<usize> {
533        let datagram = render_msearch(&pending.st, pending.mx, pending.target, &self.user_agent);
534        let bytes = self
535            .socket
536            .send_to(datagram.as_bytes(), pending.target)
537            .await?;
538
539        // The window is what the devices were told to expect: MX seconds, unless the
540        // operator overrode it. A window shorter than MX drops the answers of every device
541        // whose jitter landed late, which is exactly the devices a real network has.
542        let window_ms = if self.response_window_ms > 0 {
543            self.response_window_ms
544        } else {
545            u64::from(pending.mx) * 1000
546        };
547
548        self.active = Some(ActiveSearch {
549            st: pending.st.clone(),
550            mx: pending.mx,
551            target: pending.target,
552            depth: pending.depth,
553            deadline: Instant::now() + Duration::from_millis(window_ms),
554            responders: Vec::new(),
555            seen: HashSet::new(),
556            duplicates: 0,
557        });
558
559        Ok(bytes)
560    }
561
562    /// Close the open window and tell the model what it found.
563    async fn finish_search(&mut self) {
564        let Some(search) = self.active.take() else {
565            return;
566        };
567        let pending = PendingSearch {
568            st: search.st,
569            mx: search.mx,
570            target: search.target,
571            depth: search.depth,
572        };
573        self.complete_search(&pending, search.responders, search.duplicates, None)
574            .await;
575    }
576
577    /// Raise `ssdp_search_complete` and act on the answer.
578    async fn complete_search(
579        &mut self,
580        search: &PendingSearch,
581        responders: Vec<serde_json::Value>,
582        duplicates: usize,
583        send_error: Option<String>,
584    ) {
585        let depth = search.depth + 1;
586        info!(
587            "SSDP client {} search for {} complete: {} responder(s), {} duplicate(s)",
588            self.client_id,
589            search.st,
590            responders.len(),
591            duplicates
592        );
593        let _ = self.status_tx.send(format!(
594            "[CLIENT] SSDP client {} finished searching {}: {} device(s) answered",
595            self.client_id,
596            search.st,
597            responders.len()
598        ));
599        let _ = self.status_tx.send("__UPDATE_UI__".to_string());
600
601        let mut data = serde_json::json!({
602            "st": search.st,
603            "mx": search.mx,
604            "target": search.target.to_string(),
605            "responder_count": responders.len(),
606            "responders": responders,
607            "duplicate_count": duplicates,
608            "search_depth": depth,
609        });
610        if let Some(err) = send_error {
611            data["send_error"] = serde_json::Value::String(err);
612        }
613
614        let event = Event::new(&SSDP_SEARCH_COMPLETE_EVENT, data);
615        let actions = self.call_model(&event).await;
616        if let Some(actions) = actions {
617            self.apply_actions(actions, depth).await;
618        }
619    }
620
621    // -----------------------------------------------------------------------
622    // Inbound datagrams
623    // -----------------------------------------------------------------------
624
625    async fn handle_inbound(&mut self, inbound: Inbound) {
626        let msg = match message::parse(&inbound.data) {
627            Ok(msg) => msg,
628            Err(e) => {
629                // Dropped, not answered: a control point that reacted to malformed input
630                // would be reacting to whatever is on the group, which on a real network is
631                // plenty. Logged at DEBUG so it is visible when looked for.
632                debug!(
633                    "SSDP client {} dropping {} bytes from {}: {}",
634                    self.client_id,
635                    inbound.data.len(),
636                    inbound.source,
637                    e
638                );
639                return;
640            }
641        };
642
643        match msg.method.as_deref() {
644            // A status line is a device answering a search — ours, or somebody else's if we
645            // are sitting on the group.
646            None => self.handle_search_response(msg, inbound.source).await,
647            Some("NOTIFY") => self.handle_notify(msg, inbound.source).await,
648            Some("M-SEARCH") => {
649                // Another control point searching. We are not a device; answering would be
650                // a lie, and answering our own group traffic would be a loop.
651                debug!(
652                    "SSDP client {} ignoring an M-SEARCH from {} (we are a control point, \
653                     not a device)",
654                    self.client_id, inbound.source
655                );
656            }
657            Some(other) => {
658                warn!(
659                    "SSDP client {} ignoring unexpected SSDP method '{}' from {}",
660                    self.client_id, other, inbound.source
661                );
662            }
663        }
664    }
665
666    async fn handle_search_response(&mut self, msg: HttpuMessage, source: SocketAddr) {
667        // UDA 1.1 §1.3.3: the answer to an M-SEARCH is a 200. Anything else is a device
668        // reporting a problem with a search, and treating it as a discovery would put a
669        // device that refused us into the results.
670        if !msg.start_line.contains(" 200") {
671            warn!(
672                "SSDP client {} ignoring non-200 response from {}: {:?}",
673                self.client_id, source, msg.start_line
674            );
675            return;
676        }
677
678        let st = msg.header("ST").unwrap_or_default().to_string();
679        let usn = msg.header("USN").unwrap_or_default().to_string();
680        let source_address = source.to_string();
681
682        // Dedupe within the open window only, keyed on address *and* USN: a device
683        // retransmitting is one device, but a device answering `ssdp:all` with several
684        // services is several genuinely distinct results.
685        let depth = match self.active.as_mut() {
686            Some(search) => {
687                let key = (source_address.clone(), usn.clone());
688                if !search.seen.insert(key) {
689                    search.duplicates += 1;
690                    debug!(
691                        "SSDP client {} suppressing a repeat answer from {} (USN {})",
692                        self.client_id, source_address, usn
693                    );
694                    return;
695                }
696                search.responders.push(serde_json::json!({
697                    "st": st,
698                    "usn": usn,
699                    "location": msg.header("LOCATION"),
700                    "server": msg.header("SERVER"),
701                    "source_address": source_address,
702                }));
703                search.depth + 1
704            }
705            // No window open: a response overheard on the group, or one that arrived after
706            // ours closed. Still worth telling the model about — it is a real device.
707            None => 0,
708        };
709
710        info!(
711            "SSDP client {} discovered {} at {} (ST {})",
712            self.client_id,
713            if usn.is_empty() {
714                "a device"
715            } else {
716                usn.as_str()
717            },
718            msg.header("LOCATION").unwrap_or("<no LOCATION>"),
719            if st.is_empty() { "<none>" } else { st.as_str() }
720        );
721
722        let event = Event::new(
723            &SSDP_SEARCH_RESPONSE_EVENT,
724            serde_json::json!({
725                "st": st,
726                "usn": usn,
727                "location": msg.header("LOCATION"),
728                "server": msg.header("SERVER"),
729                "cache_control": msg.header("CACHE-CONTROL"),
730                "cache_control_max_age": max_age_of(&msg),
731                "source_address": source_address,
732                "headers": msg.headers_json(),
733            }),
734        );
735        let actions = self.call_model(&event).await;
736        if let Some(actions) = actions {
737            self.apply_actions(actions, depth).await;
738        }
739    }
740
741    async fn handle_notify(&mut self, msg: HttpuMessage, source: SocketAddr) {
742        let nt = msg.header("NT").unwrap_or_default().to_string();
743        let nts = msg.header("NTS").unwrap_or_default().to_string();
744        let usn = msg.header("USN").unwrap_or_default().to_string();
745
746        info!(
747            "SSDP client {} heard {} from {} (NT {}, USN {})",
748            self.client_id,
749            if nts.is_empty() {
750                "an announcement"
751            } else {
752                nts.as_str()
753            },
754            source,
755            if nt.is_empty() { "<none>" } else { nt.as_str() },
756            usn
757        );
758
759        let event = Event::new(
760            &SSDP_NOTIFY_RECEIVED_EVENT,
761            serde_json::json!({
762                "nt": nt,
763                "nts": nts,
764                "usn": usn,
765                "location": msg.header("LOCATION"),
766                "server": msg.header("SERVER"),
767                "cache_control_max_age": max_age_of(&msg),
768                "source_address": source.to_string(),
769                "headers": msg.headers_json(),
770            }),
771        );
772        // Announcements are unsolicited, so they start their own chain at depth 0.
773        let actions = self.call_model(&event).await;
774        if let Some(actions) = actions {
775            self.apply_actions(actions, 0).await;
776        }
777    }
778
779    // -----------------------------------------------------------------------
780    // Model plumbing
781    // -----------------------------------------------------------------------
782
783    /// Ask the model what to do about `event`.
784    ///
785    /// `None` means there is nothing to act on — no instruction configured, or the call
786    /// failed. It never means "the answer was discarded": every `Some` is executed by the
787    /// caller, which is the one thing this repo's clients get wrong most often.
788    async fn call_model(&mut self, event: &Event) -> Option<Vec<serde_json::Value>> {
789        let instruction = self
790            .app_state
791            .get_instruction_for_client(self.client_id)
792            .await?;
793
794        // Bound rather than used directly as a `match` scrutinee: temporaries in a scrutinee
795        // live for the whole match, so the `&self.protocol` / `&self.memory` borrows would
796        // still be alive in the arm that assigns `self.memory`.
797        let result = call_llm_for_client(
798            &self.llm_client,
799            &self.app_state,
800            self.client_id.to_string(),
801            &instruction,
802            &self.memory,
803            Some(event),
804            &self.protocol,
805            &self.status_tx,
806        )
807        .await;
808
809        match result {
810            Ok(ClientLlmResult {
811                actions,
812                memory_updates,
813            }) => {
814                if let Some(memory) = memory_updates {
815                    self.memory = memory.clone();
816                    self.app_state
817                        .set_memory_for_client(self.client_id, memory)
818                        .await;
819                }
820                Some(actions)
821            }
822            Err(e) => {
823                error!(
824                    "SSDP client {} LLM error on '{}': {}",
825                    self.client_id,
826                    event.id(),
827                    e
828                );
829                let _ = self.status_tx.send(format!(
830                    "[CLIENT] ✖ SSDP client {} could not get an answer for '{}': {}",
831                    self.client_id,
832                    event.id(),
833                    e
834                ));
835                None
836            }
837        }
838    }
839
840    /// Execute everything the model returned, in order.
841    async fn apply_actions(&mut self, actions: Vec<serde_json::Value>, depth: usize) {
842        for action in actions {
843            match self.apply_action(&action, depth).await {
844                Ok(Applied::Disconnect) => {
845                    info!("SSDP client {} disconnecting on request", self.client_id);
846                    self.done = true;
847                    return;
848                }
849                Ok(Applied::Refused(reason)) => {
850                    error!(
851                        "SSDP client {} refused an action: {}",
852                        self.client_id, reason
853                    );
854                    let _ = self.status_tx.send(format!(
855                        "[CLIENT] ⚠ SSDP client {}: {}",
856                        self.client_id, reason
857                    ));
858                }
859                Ok(Applied::Queued { .. }) | Ok(Applied::Waited) => {}
860                Err(e) => {
861                    error!(
862                        "SSDP client {} could not execute action {}: {}",
863                        self.client_id, action, e
864                    );
865                }
866            }
867        }
868    }
869
870    /// Execute one action. The single place an action becomes an effect, shared by the LLM
871    /// path and the command channel.
872    async fn apply_action(&mut self, action: &serde_json::Value, depth: usize) -> Result<Applied> {
873        match self.protocol.execute_action(action.clone())? {
874            ClientActionResult::Custom { name, data } if name == "send_msearch" => {
875                if depth > MAX_FOLLOWUP_DEPTH {
876                    return Ok(Applied::Refused(format!(
877                        "refusing a {}-deep follow-up search (limit {}). Discovery is \
878                         iterative and does not converge on its own; report what has been \
879                         found instead of searching again.",
880                        depth, MAX_FOLLOWUP_DEPTH
881                    )));
882                }
883                let st = data["st"].as_str().unwrap_or_default().to_string();
884                let mx = data["mx"].as_u64().unwrap_or(0) as u32;
885                let target = match data["target"].as_str() {
886                    // Already validated as a SocketAddr by the executor, so this cannot fail
887                    // on model input; the fallback keeps a future refactor honest.
888                    Some(s) => s.parse().unwrap_or(self.default_target),
889                    None => self.default_target,
890                };
891                self.queue.push_back(PendingSearch {
892                    st: st.clone(),
893                    mx,
894                    target,
895                    depth,
896                });
897                Ok(Applied::Queued { st })
898            }
899            ClientActionResult::Custom { name, .. } => Ok(Applied::Refused(format!(
900                "'{name}' is not an SSDP client verb"
901            ))),
902            ClientActionResult::WaitForMore => {
903                trace!("SSDP client {} waiting for more", self.client_id);
904                Ok(Applied::Waited)
905            }
906            ClientActionResult::Disconnect => Ok(Applied::Disconnect),
907            other => Ok(Applied::Refused(format!("no wire effect: {other:?}"))),
908        }
909    }
910
911    // -----------------------------------------------------------------------
912    // Injected commands (the dashboard's [ send ])
913    // -----------------------------------------------------------------------
914
915    /// Bespoke rather than `command_support::handle_stream_client_command`: that helper
916    /// writes `SendData` to a write half, and every SSDP verb is a `Custom` over a shared
917    /// `UdpSocket` with no write half at all. The logging and reply are what the helper does.
918    async fn handle_command(&mut self, command: ClientCommand) {
919        use crate::llm::actions::protocol_trait::Protocol;
920
921        let action = command.action.clone();
922        // Depth 0: an operator pressing [ send ] is starting a chain, not continuing one.
923        //
924        // Every arm is `Ok(..)`: a bad action is a `Rejected` outcome the caller can read,
925        // not a transport failure. The annotation is what tells the `Err` arm of the
926        // access-log conversion below what error type it is looking at.
927        let outcome: anyhow::Result<ClientSendOutcome> = match self.apply_action(&action, 0).await {
928            Ok(Applied::Queued { st }) => Ok(ClientSendOutcome::Executed {
929                detail: format!("M-SEARCH for '{st}' queued"),
930            }),
931            Ok(Applied::Waited) => Ok(ClientSendOutcome::Executed {
932                detail: "wait_for_more".to_string(),
933            }),
934            Ok(Applied::Disconnect) => {
935                self.done = true;
936                Ok(ClientSendOutcome::Disconnected)
937            }
938            Ok(Applied::Refused(reason)) => Ok(ClientSendOutcome::Rejected { error: reason }),
939            Err(e) => Ok(ClientSendOutcome::Rejected {
940                error: e.to_string(),
941            }),
942        };
943
944        let outcome_json = match &outcome {
945            Ok(outcome) => serde_json::to_value(outcome).unwrap_or(serde_json::Value::Null),
946            Err(e) => serde_json::json!({"error": e.to_string()}),
947        };
948        self.app_state
949            .record_access_log(
950                AccessLogOwner::Client(self.client_id.as_u32()),
951                self.protocol.protocol_name(),
952                None,
953                "injected_action",
954                action,
955                vec![outcome_json],
956            )
957            .await;
958        let _ = self.status_tx.send("__UPDATE_UI__".to_string());
959
960        crate::client::command_support::reply(command, outcome);
961    }
962}
963
964// ---------------------------------------------------------------------------
965// Pure helpers
966// ---------------------------------------------------------------------------
967
968/// Render an `M-SEARCH * HTTP/1.1` request (UDA 1.1 §1.3.2).
969///
970/// `HOST` names the address the search is actually sent to. For the multicast form that is
971/// the group, which is what the specification prints; for a unicast search it is the device,
972/// which is what a device expects to see and what NetGet's own server test sends.
973///
974/// `MAN` keeps its quotes — `"ssdp:discover"` including them is what conforming control
975/// points send, and devices have been observed to ignore searches without them.
976///
977/// Not in `server/ssdp/message.rs` because that file is the device's half and is not ours to
978/// edit; the parsing side, which is the part with real grammar in it, *is* shared from there.
979/// `pub` so `tests/client/ssdp/e2e_test.rs` can assert the exact bytes against the codec
980/// without a socket: all tests live in `tests/`, so anything a test needs to see has to be
981/// public here.
982pub fn render_msearch(st: &str, mx: u32, target: SocketAddr, user_agent: &str) -> String {
983    format!(
984        "M-SEARCH * HTTP/1.1\r\n\
985         HOST: {target}\r\n\
986         MAN: \"ssdp:discover\"\r\n\
987         MX: {mx}\r\n\
988         ST: {st}\r\n\
989         USER-AGENT: {user_agent}\r\n\
990         \r\n"
991    )
992}
993
994/// The `max-age` directive of `CACHE-CONTROL`, as a number.
995///
996/// Given to the model already parsed: asked "is this device still fresh?", a model should not
997/// first have to pull an integer out of a header directive.
998pub fn max_age_of(msg: &HttpuMessage) -> Option<u64> {
999    let value = msg.header("CACHE-CONTROL")?;
1000    value.split(',').find_map(|part| {
1001        let part = part.trim();
1002        let rest = part.strip_prefix("max-age")?.trim_start();
1003        let rest = rest.strip_prefix('=')?.trim();
1004        rest.parse::<u64>().ok()
1005    })
1006}
1007
1008/// Turn a send error into something an operator can act on.
1009///
1010/// One failure dominates here and is deeply unobvious: sending to `239.255.255.250` from a
1011/// socket bound to loopback fails with `EADDRNOTAVAIL` because loopback carries no multicast
1012/// route — measured on macOS 27, where the *join* succeeds and only the send fails, which is
1013/// the opposite of the usual expectation. Left as the bare OS message it reads as a bug in
1014/// NetGet.
1015fn send_failure_hint(error: &anyhow::Error, target: SocketAddr, local: SocketAddr) -> String {
1016    let base = error.to_string();
1017    let target_is_multicast = match target.ip() {
1018        std::net::IpAddr::V4(ip) => ip.is_multicast(),
1019        std::net::IpAddr::V6(ip) => ip.is_multicast(),
1020    };
1021    if target_is_multicast && local.ip().is_loopback() {
1022        return format!(
1023            "{base} — the socket is bound to loopback ({local}) and loopback carries no \
1024             multicast route, so nothing can be sent to {target}. Either set startup param \
1025             bind_address to 0.0.0.0, or give send_msearch a unicast 'target'."
1026        );
1027    }
1028    base
1029}