Skip to main content

netget/client/coap/
mod.rs

1//! CoAP client: NetGet sends CoAP requests over UDP and the model decides what to ask.
2//!
3//! Three tasks per connection, all registered with the client:
4//!
5//! * **transport** owns the UDP socket and every exchange in flight: message ids and tokens,
6//!   Confirmable retransmission (RFC 7252 §4.2, exponential back-off, bounded attempts), empty
7//!   ACKs for separate responses and Confirmable notifications, RST for a token it does not know,
8//!   Block1 uploads and Block2 reassembly (RFC 7959) and Observe registrations (RFC 7641). It
9//!   never waits on the model.
10//! * **turns** asks the model about each response, notification or error, in order, and hands
11//!   its actions back to the transport. The chain request → response → model → request passes
12//!   through that queue, so it needs no recursion.
13//! * **commands** runs injected actions (`[ send ]`, MCP `send_to_client`) down the same path.
14//!
15//! Encoding and decoding are the server's own codec (`src/server/coap/codec.rs`), option
16//! numbers and the Block1/Block2 value layout included.
17
18pub mod actions;
19
20pub use actions::CoapClientProtocol;
21
22use std::collections::{HashMap, VecDeque};
23use std::net::SocketAddr;
24use std::sync::Arc;
25use std::time::Duration;
26
27use anyhow::{anyhow, Context, Result};
28use serde_json::{json, Value};
29use tokio::net::UdpSocket;
30use tokio::sync::{mpsc, oneshot};
31use tokio::time::Instant;
32use tracing::{debug, error, info, warn};
33
34use crate::client::coap::actions::{
35    request_from_action, CoapRequest, ObserveAction, COAP_CONNECTED_EVENT, COAP_ERROR_EVENT,
36    COAP_NOTIFICATION_EVENT, COAP_RESPONSE_EVENT, REQUEST_RESULT,
37};
38use crate::client::llm_budget::call_llm_for_client;
39use crate::llm::actions::client_trait::{Client, ClientActionResult};
40use crate::llm::actions::protocol_trait::Protocol;
41use crate::llm::ollama_client::OllamaClient;
42use crate::protocol::Event;
43use crate::server::coap::codec::{
44    code_class, code_to_string, content_format_name, szx_size, uint_option, BlockValue,
45    CoapMessage, MessageType, CODE_CONTINUE, CODE_DELETE, CODE_EMPTY, CODE_GET, CODE_POST,
46    CODE_PUT, MAX_MESSAGE_LEN, OPT_ACCEPT, OPT_CONTENT_FORMAT, OPT_ETAG, OPT_LOCATION_PATH,
47    OPT_MAX_AGE, OPT_URI_PATH, OPT_URI_QUERY,
48};
49pub use crate::server::coap::codec::{OPT_BLOCK1, OPT_BLOCK2, OPT_OBSERVE, OPT_SIZE1, OPT_SIZE2};
50use crate::state::app_state::AppState;
51use crate::state::client_handles::{ClientCommand, ClientSendOutcome};
52use crate::state::{AccessLogOwner, ClientId, ClientStatus};
53
54/// Largest body a Block2 transfer may reassemble to. A server that keeps saying "more" past
55/// this is reported as `coap_error {kind: body_too_large}` and the exchange is dropped.
56pub const MAX_BODY: usize = 64 * 1024;
57
58/// Exchanges in flight, observations included. The next request is refused.
59pub const MAX_EXCHANGES: usize = 32;
60
61/// How long to wait for a response once a Confirmable request is acknowledged with an empty
62/// ACK (a separate response is coming), or after a Non-confirmable request is sent.
63pub const RESPONSE_TIMEOUT: Duration = Duration::from_secs(30);
64
65/// Server message ids remembered to recognise a retransmitted notification or separate
66/// response (RFC 7252 §4.5 deduplication).
67const RECENT_MIDS: usize = 64;
68
69const TURN_QUEUE_CAPACITY: usize = 256;
70const OUTBOUND_CAPACITY: usize = 64;
71
72/// RFC 7252 §4.8 transmission parameters and the Block1 size, from the startup parameters.
73#[derive(Debug, Clone, Copy)]
74pub struct Reliability {
75    pub ack_timeout: Duration,
76    pub max_retransmit: u32,
77    /// Block1 size exponent: a body over `2^(szx + 4)` bytes is uploaded in blocks.
78    pub block_szx: u8,
79}
80
81enum Outbound {
82    Request {
83        request: CoapRequest,
84        ack: oneshot::Sender<Result<usize, String>>,
85    },
86    Disconnect {
87        ack: oneshot::Sender<()>,
88    },
89}
90
91enum Applied {
92    Sent(usize),
93    Nothing,
94    Disconnect,
95}
96
97/// One request (or one observation) and everything the transport needs to finish it.
98struct Exchange {
99    request: CoapRequest,
100    /// The message currently on the wire for this exchange, for retransmission.
101    message_id: u16,
102    datagram: Vec<u8>,
103    /// Confirmable and not yet acknowledged.
104    awaiting_ack: bool,
105    retransmissions: u32,
106    interval: Duration,
107    /// Next retransmission while `awaiting_ack`; otherwise when to give up waiting for a
108    /// response. `None` for a registered observation, which has no end.
109    deadline: Option<Instant>,
110    /// Block2 reassembly.
111    body: Vec<u8>,
112    blocks: u32,
113    next_block: u32,
114    /// The observation was confirmed by a response carrying Observe.
115    observing: bool,
116    /// A Block1 upload in progress: the block on the wire. `None` once the last block's
117    /// response arrived, or for a body that fits one message.
118    upload: Option<BlockValue>,
119    /// Block1 blocks put on the wire for this request.
120    blocks_sent: u32,
121}
122
123pub struct CoapClient;
124
125impl CoapClient {
126    pub async fn connect_with_llm_actions(
127        remote_addr: String,
128        reliability: Reliability,
129        llm_client: OllamaClient,
130        app_state: Arc<AppState>,
131        status_tx: mpsc::UnboundedSender<String>,
132        client_id: ClientId,
133    ) -> Result<SocketAddr> {
134        if remote_addr.trim().is_empty() {
135            return Err(anyhow!(
136                "CoAP client needs a remote_addr (host:port); refusing to start without one"
137            ));
138        }
139        let remote = tokio::net::lookup_host(&remote_addr)
140            .await
141            .with_context(|| format!("cannot resolve CoAP server {remote_addr}"))?
142            .next()
143            .with_context(|| format!("{remote_addr} resolved to no address"))?;
144        let bind = if remote.is_ipv4() {
145            "0.0.0.0:0"
146        } else {
147            "[::]:0"
148        };
149        let socket = UdpSocket::bind(bind).await?;
150        // Connected: the kernel then delivers only datagrams from the server.
151        socket.connect(remote).await?;
152        let local_addr = socket.local_addr()?;
153        info!("CoAP client {client_id} ready for {remote} (local {local_addr})");
154        app_state
155            .update_client_status(client_id, ClientStatus::Connected)
156            .await;
157        let _ = status_tx.send(format!("[CLIENT] CoAP client {client_id} ready"));
158        let _ = status_tx.send("__UPDATE_UI__".to_string());
159
160        let protocol = Arc::new(CoapClientProtocol::new());
161        let (outbound_tx, outbound_rx) = mpsc::channel::<Outbound>(OUTBOUND_CAPACITY);
162        let (turn_tx, turn_rx) = mpsc::channel::<Event>(TURN_QUEUE_CAPACITY);
163
164        let command_rx =
165            crate::client::command_support::register_command_channel(&app_state, client_id).await;
166        app_state
167            .spawn_client_task(
168                client_id,
169                command_loop(
170                    command_rx,
171                    protocol.clone(),
172                    outbound_tx.clone(),
173                    client_id,
174                    app_state.clone(),
175                    status_tx.clone(),
176                ),
177            )
178            .await;
179        let turn_abort = app_state
180            .spawn_client_task(
181                client_id,
182                run_turns(
183                    turn_rx,
184                    outbound_tx,
185                    protocol,
186                    llm_client,
187                    app_state.clone(),
188                    status_tx.clone(),
189                    client_id,
190                ),
191            )
192            .await;
193        let _ = turn_tx.try_send(Event::new(
194            &COAP_CONNECTED_EVENT,
195            json!({"remote_addr": remote.to_string()}),
196        ));
197        app_state
198            .spawn_client_task(
199                client_id,
200                run_transport(
201                    socket,
202                    reliability,
203                    outbound_rx,
204                    turn_tx,
205                    app_state.clone(),
206                    status_tx,
207                    client_id,
208                    turn_abort,
209                ),
210            )
211            .await;
212        Ok(local_addr)
213    }
214}
215
216/// A Block2 value as `(num, more, szx)` (RFC 7959 §2.2).
217fn parse_block(value: &[u8]) -> Option<(u32, bool, u8)> {
218    BlockValue::parse(value).map(|b| (b.num, b.more, b.szx))
219}
220
221/// Which part of a request a message carries.
222#[derive(Debug, Clone, Copy, PartialEq, Eq)]
223enum Part {
224    /// The whole request in one message.
225    Whole,
226    /// One Block1 block of the body (RFC 7959 §2.3).
227    Block1(BlockValue),
228    /// A Block2 continuation: ask for block `num` at `szx` (RFC 7959 §2.4).
229    Block2(u32, u8),
230}
231
232/// The first Block1 block of `request`, when its body does not fit one block of `szx`.
233fn first_block(request: &CoapRequest, szx: u8) -> Option<BlockValue> {
234    (request.payload.len() > szx_size(szx)).then_some(BlockValue {
235        num: 0,
236        more: true,
237        szx,
238    })
239}
240
241/// The Block1 block after `sent`, at the size the server asked for in its 2.31 (`echo`).
242/// RFC 7959 §2.3: the server may lower SZX, and the client then continues at the smaller size
243/// from where the bytes it has had acknowledged end. `None` when the body is done.
244fn next_block(body_len: usize, sent: BlockValue, echo: Option<BlockValue>) -> Option<BlockValue> {
245    let szx = echo.map_or(sent.szx, |e| e.szx.min(sent.szx));
246    let acknowledged = sent.offset() + sent.size();
247    if acknowledged >= body_len {
248        return None;
249    }
250    let size = szx_size(szx);
251    let num = u32::try_from(acknowledged / size).ok()?;
252    Some(BlockValue {
253        num,
254        more: acknowledged + size < body_len,
255        szx,
256    })
257}
258
259fn method_code(method: &str) -> u8 {
260    match method {
261        "POST" => CODE_POST,
262        "PUT" => CODE_PUT,
263        "DELETE" => CODE_DELETE,
264        _ => CODE_GET,
265    }
266}
267
268/// The message that carries `request`, one Block1 block of it, or its Block2 continuation.
269fn request_message(
270    request: &CoapRequest,
271    message_id: u16,
272    token: &[u8],
273    part: Part,
274) -> CoapMessage {
275    let mut options: Vec<(u16, Vec<u8>)> = Vec::new();
276    // RFC 7959 §2.6: a continuation of an Observe registration does not repeat Observe.
277    if !matches!(part, Part::Block2(..)) {
278        match request.observe {
279            ObserveAction::Register => options.push((OPT_OBSERVE, Vec::new())),
280            ObserveAction::Cancel => options.push((OPT_OBSERVE, vec![1])),
281            ObserveAction::None => {}
282        }
283    }
284    for segment in &request.path {
285        options.push((OPT_URI_PATH, segment.as_bytes().to_vec()));
286    }
287    if let Some(cf) = request.content_format {
288        options.push((OPT_CONTENT_FORMAT, uint_option(u32::from(cf))));
289    }
290    for item in &request.query {
291        options.push((OPT_URI_QUERY, item.as_bytes().to_vec()));
292    }
293    if let Some(accept) = request.accept {
294        options.push((OPT_ACCEPT, uint_option(u32::from(accept))));
295    }
296    let payload = match part {
297        Part::Whole => request.payload.clone(),
298        Part::Block2(num, szx) => {
299            options.push((
300                OPT_BLOCK2,
301                BlockValue {
302                    num,
303                    more: false,
304                    szx,
305                }
306                .encode(),
307            ));
308            Vec::new()
309        }
310        Part::Block1(block) => {
311            options.push((OPT_BLOCK1, block.encode()));
312            if block.num == 0 {
313                // RFC 7959 §4: announce the whole size, so a server can refuse it up front.
314                options.push((OPT_SIZE1, uint_option(request.payload.len() as u32)));
315            }
316            let start = block.offset().min(request.payload.len());
317            let end = (start + block.size()).min(request.payload.len());
318            request.payload[start..end].to_vec()
319        }
320    };
321    CoapMessage {
322        mtype: if request.confirmable {
323            MessageType::Confirmable
324        } else {
325            MessageType::NonConfirmable
326        },
327        code: method_code(request.method),
328        message_id,
329        token: token.to_vec(),
330        options,
331        payload,
332    }
333}
334
335fn status_name(code: u8) -> &'static str {
336    match code_to_string(code).as_str() {
337        "2.01" => "Created",
338        "2.02" => "Deleted",
339        "2.03" => "Valid",
340        "2.04" => "Changed",
341        "2.05" => "Content",
342        "2.31" => "Continue",
343        "4.00" => "Bad Request",
344        "4.01" => "Unauthorized",
345        "4.02" => "Bad Option",
346        "4.03" => "Forbidden",
347        "4.04" => "Not Found",
348        "4.05" => "Method Not Allowed",
349        "4.06" => "Not Acceptable",
350        "4.08" => "Request Entity Incomplete",
351        "4.12" => "Precondition Failed",
352        "4.13" => "Request Entity Too Large",
353        "4.15" => "Unsupported Content-Format",
354        "5.00" => "Internal Server Error",
355        "5.01" => "Not Implemented",
356        "5.02" => "Bad Gateway",
357        "5.03" => "Service Unavailable",
358        "5.04" => "Gateway Timeout",
359        "5.05" => "Proxying Not Supported",
360        _ => "Unknown",
361    }
362}
363
364/// The event data for a response: code, payload as text (and parsed JSON when it is JSON), and
365/// the options a model can act on. A payload that is not UTF-8 is reported by size only.
366fn response_data(request: &CoapRequest, msg: &CoapMessage, payload: &[u8], blocks: u32) -> Value {
367    let content_format = msg.option_uint(OPT_CONTENT_FORMAT);
368    let mut data = json!({
369        "method": request.method,
370        "path": request.path_string(),
371        "code": code_to_string(msg.code),
372        "status": status_name(msg.code),
373        "payload_size": payload.len(),
374        "blocks": blocks.max(1),
375    });
376    if let Some(cf) = content_format {
377        let name = u16::try_from(cf)
378            .ok()
379            .and_then(content_format_name)
380            .map(str::to_string)
381            .unwrap_or_else(|| cf.to_string());
382        data["content_format"] = json!(name);
383    }
384    if let Ok(text) = std::str::from_utf8(payload) {
385        if !text.is_empty() {
386            data["payload"] = json!(text);
387            if content_format == Some(50) {
388                if let Ok(parsed) = serde_json::from_str::<Value>(text) {
389                    data["payload_json"] = parsed;
390                }
391            }
392        }
393    }
394    let mut options = serde_json::Map::new();
395    if let Some(v) = msg.option_uint(OPT_MAX_AGE) {
396        options.insert("max_age".into(), json!(v));
397    }
398    if let Some(v) = msg.option_values(OPT_ETAG).first() {
399        options.insert("etag".into(), json!(hex::encode(v)));
400    }
401    let location: Vec<String> = msg
402        .option_values(OPT_LOCATION_PATH)
403        .iter()
404        .map(|v| String::from_utf8_lossy(v).to_string())
405        .collect();
406    if !location.is_empty() {
407        options.insert(
408            "location_path".into(),
409            json!(format!("/{}", location.join("/"))),
410        );
411    }
412    if let Some(v) = msg.option_uint(OPT_OBSERVE) {
413        options.insert("observe".into(), json!(v));
414    }
415    if let Some(v) = msg.option_uint(OPT_SIZE2) {
416        options.insert("size2".into(), json!(v));
417    }
418    data["options"] = Value::Object(options);
419    data
420}
421
422fn error_event(request: &CoapRequest, kind: &str, message: String) -> Event {
423    Event::new(
424        &COAP_ERROR_EVENT,
425        json!({
426            "kind": kind,
427            "message": message,
428            "method": request.method,
429            "path": request.path_string(),
430        }),
431    )
432}
433
434fn enqueue(turn_tx: &mpsc::Sender<Event>, event: Event, client_id: ClientId) {
435    if turn_tx.try_send(event).is_err() {
436        warn!(
437            "CoAP client {client_id} dropped an event: the model is {TURN_QUEUE_CAPACITY} events \
438             behind decision=turn_queue_full"
439        );
440    }
441}
442
443/// Everything the transport task owns.
444struct Transport {
445    socket: UdpSocket,
446    reliability: Reliability,
447    exchanges: HashMap<Vec<u8>, Exchange>,
448    next_mid: u16,
449    recent: VecDeque<u16>,
450    turn_tx: mpsc::Sender<Event>,
451    client_id: ClientId,
452}
453
454impl Transport {
455    fn fresh_mid(&mut self) -> u16 {
456        let mid = self.next_mid;
457        self.next_mid = self.next_mid.wrapping_add(1);
458        mid
459    }
460
461    fn fresh_token(&self) -> Vec<u8> {
462        loop {
463            let token = rand::random::<u32>().to_be_bytes().to_vec();
464            if !self.exchanges.contains_key(&token) {
465                return token;
466            }
467        }
468    }
469
470    fn first_interval(&self) -> Duration {
471        // RFC 7252 §4.2: a random duration between ACK_TIMEOUT and ACK_TIMEOUT * 1.5.
472        let factor = 1.0 + rand::random::<f64>() * 0.5;
473        self.reliability.ack_timeout.mul_f64(factor)
474    }
475
476    /// Put `msg` on the wire as the current message of the exchange under `token`.
477    async fn transmit(&mut self, token: &[u8], msg: CoapMessage) -> std::io::Result<usize> {
478        let datagram = msg
479            .encode()
480            .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidInput, e.to_string()))?;
481        self.socket.send(&datagram).await?;
482        let confirmable = msg.mtype == MessageType::Confirmable;
483        let interval = self.first_interval();
484        let now = Instant::now();
485        if let Some(ex) = self.exchanges.get_mut(token) {
486            ex.message_id = msg.message_id;
487            ex.datagram = datagram.clone();
488            ex.awaiting_ack = confirmable;
489            ex.retransmissions = 0;
490            ex.interval = interval;
491            ex.deadline = Some(if confirmable {
492                now + interval
493            } else {
494                now + RESPONSE_TIMEOUT
495            });
496        }
497        Ok(datagram.len())
498    }
499
500    async fn start(&mut self, request: CoapRequest) -> Result<usize, String> {
501        let token = match request.observe {
502            ObserveAction::Cancel => {
503                let path = request.path_string();
504                let Some(token) = self
505                    .exchanges
506                    .iter()
507                    .find(|(_, ex)| {
508                        ex.request.observe == ObserveAction::Register
509                            && ex.request.path_string() == path
510                    })
511                    .map(|(t, _)| t.clone())
512                else {
513                    enqueue(
514                        &self.turn_tx,
515                        error_event(
516                            &request,
517                            "not_observing",
518                            format!("there is no observation of {path} to cancel"),
519                        ),
520                        self.client_id,
521                    );
522                    return Ok(0);
523                };
524                // RFC 7641 §3.6: the cancellation is a GET with Observe 1 and the same token.
525                if let Some(ex) = self.exchanges.get_mut(&token) {
526                    ex.request = request.clone();
527                    ex.body.clear();
528                    ex.blocks = 0;
529                    ex.next_block = 0;
530                    ex.upload = None;
531                }
532                token
533            }
534            _ => {
535                if self.exchanges.len() >= MAX_EXCHANGES {
536                    return Err(format!(
537                        "{MAX_EXCHANGES} exchanges (requests and observations) are already open"
538                    ));
539                }
540                let token = self.fresh_token();
541                self.exchanges.insert(
542                    token.clone(),
543                    Exchange {
544                        request: request.clone(),
545                        message_id: 0,
546                        datagram: Vec::new(),
547                        awaiting_ack: false,
548                        retransmissions: 0,
549                        interval: Duration::ZERO,
550                        deadline: None,
551                        body: Vec::new(),
552                        blocks: 0,
553                        next_block: 0,
554                        observing: false,
555                        upload: None,
556                        blocks_sent: 0,
557                    },
558                );
559                token
560            }
561        };
562        let mid = self.fresh_mid();
563        let part = match first_block(&request, self.reliability.block_szx) {
564            Some(block) => {
565                if let Some(ex) = self.exchanges.get_mut(&token) {
566                    ex.upload = Some(block);
567                    ex.blocks_sent = 1;
568                }
569                Part::Block1(block)
570            }
571            None => Part::Whole,
572        };
573        let msg = request_message(&request, mid, &token, part);
574        match self.transmit(&token, msg).await {
575            Ok(n) => {
576                debug!(
577                    "CoAP client {} sent {} {} mid {mid}",
578                    self.client_id,
579                    request.method,
580                    request.path_string()
581                );
582                Ok(n)
583            }
584            Err(e) => {
585                self.exchanges.remove(&token);
586                Err(format!("send failed: {e}"))
587            }
588        }
589    }
590
591    async fn send_empty(&self, mtype: MessageType, message_id: u16) {
592        let msg = CoapMessage {
593            mtype,
594            code: CODE_EMPTY,
595            message_id,
596            token: Vec::new(),
597            options: Vec::new(),
598            payload: Vec::new(),
599        };
600        if let Ok(bytes) = msg.encode() {
601            let _ = self.socket.send(&bytes).await;
602        }
603    }
604
605    /// Retransmit what is due, and give up on what has run out of attempts or time.
606    async fn expire(&mut self) {
607        let now = Instant::now();
608        let due: Vec<Vec<u8>> = self
609            .exchanges
610            .iter()
611            .filter(|(_, ex)| ex.deadline.is_some_and(|d| d <= now))
612            .map(|(t, _)| t.clone())
613            .collect();
614        for token in due {
615            let Some(ex) = self.exchanges.get_mut(&token) else {
616                continue;
617            };
618            if ex.awaiting_ack && ex.retransmissions < self.reliability.max_retransmit {
619                ex.retransmissions += 1;
620                ex.interval *= 2;
621                ex.deadline = Some(now + ex.interval);
622                debug!(
623                    "CoAP client {} retransmission {} of mid {}",
624                    self.client_id, ex.retransmissions, ex.message_id
625                );
626                let datagram = ex.datagram.clone();
627                let _ = self.socket.send(&datagram).await;
628                continue;
629            }
630            let ex = self.exchanges.remove(&token).expect("present above");
631            let message = if ex.awaiting_ack {
632                format!(
633                    "no acknowledgement after {} retransmissions",
634                    ex.retransmissions
635                )
636            } else {
637                format!("no response within {} seconds", RESPONSE_TIMEOUT.as_secs())
638            };
639            warn!(
640                "CoAP client {} {} {} timed out decision=timeout",
641                self.client_id,
642                ex.request.method,
643                ex.request.path_string()
644            );
645            enqueue(
646                &self.turn_tx,
647                error_event(&ex.request, "timeout", message),
648                self.client_id,
649            );
650        }
651    }
652
653    async fn handle_datagram(&mut self, datagram: &[u8]) {
654        let msg = match CoapMessage::decode(datagram) {
655            Ok(m) => m,
656            Err(e) => {
657                warn!(
658                    "CoAP client {} dropped an undecodable datagram: {e} decision=undecodable",
659                    self.client_id
660                );
661                return;
662            }
663        };
664        match msg.mtype {
665            MessageType::Acknowledgement => {
666                let Some(token) = self
667                    .exchanges
668                    .iter()
669                    .find(|(_, ex)| ex.awaiting_ack && ex.message_id == msg.message_id)
670                    .map(|(t, _)| t.clone())
671                else {
672                    return; // a duplicate ACK
673                };
674                if let Some(ex) = self.exchanges.get_mut(&token) {
675                    ex.awaiting_ack = false;
676                    ex.deadline = Some(Instant::now() + RESPONSE_TIMEOUT);
677                }
678                if msg.code != CODE_EMPTY && msg.token == token {
679                    self.handle_response(token, msg).await;
680                }
681            }
682            MessageType::Reset => {
683                let Some(token) = self
684                    .exchanges
685                    .iter()
686                    .find(|(_, ex)| ex.message_id == msg.message_id)
687                    .map(|(t, _)| t.clone())
688                else {
689                    return;
690                };
691                if let Some(ex) = self.exchanges.remove(&token) {
692                    enqueue(
693                        &self.turn_tx,
694                        error_event(
695                            &ex.request,
696                            "reset",
697                            "the server rejected the message with RST".to_string(),
698                        ),
699                        self.client_id,
700                    );
701                }
702            }
703            MessageType::Confirmable | MessageType::NonConfirmable => {
704                let is_response = code_class(msg.code) >= 2;
705                let known = is_response && self.exchanges.contains_key(&msg.token);
706                if !known {
707                    // An unknown token (a notification after cancel, a response to a
708                    // forgotten request), a request, or a CoAP ping: RST (RFC 7252 §4.2,
709                    // RFC 7641 §3.6).
710                    self.send_empty(MessageType::Reset, msg.message_id).await;
711                    return;
712                }
713                if msg.mtype == MessageType::Confirmable {
714                    self.send_empty(MessageType::Acknowledgement, msg.message_id)
715                        .await;
716                }
717                if self.recent.contains(&msg.message_id) {
718                    return; // a retransmission we already handled
719                }
720                self.recent.push_back(msg.message_id);
721                if self.recent.len() > RECENT_MIDS {
722                    self.recent.pop_front();
723                }
724                let token = msg.token.clone();
725                self.handle_response(token, msg).await;
726            }
727        }
728    }
729
730    async fn handle_response(&mut self, token: Vec<u8>, msg: CoapMessage) {
731        let client_id = self.client_id;
732        let Some(ex) = self.exchanges.get_mut(&token) else {
733            return;
734        };
735        let has_observe = msg.option_uint(OPT_OBSERVE).is_some();
736
737        // Block1: a 2.31 Continue for the block on the wire sends the next one. Anything else
738        // — the final block's answer, or a refusal part way (4.08, 4.13) — ends the upload and
739        // is reported like any response.
740        if let Some(sent) = ex.upload {
741            let echo = msg
742                .option_values(OPT_BLOCK1)
743                .first()
744                .and_then(|v| BlockValue::parse(v));
745            if msg.code == CODE_CONTINUE {
746                if echo.is_some_and(|e| e.num != sent.num) {
747                    let ex = self.exchanges.remove(&token).expect("present above");
748                    enqueue(
749                        &self.turn_tx,
750                        error_event(
751                            &ex.request,
752                            "bad_block",
753                            format!(
754                                "the server continued block {} while block {} was sent",
755                                echo.map_or(0, |e| e.num),
756                                sent.num
757                            ),
758                        ),
759                        client_id,
760                    );
761                    return;
762                }
763                let Some(next) = next_block(ex.request.payload.len(), sent, echo) else {
764                    let ex = self.exchanges.remove(&token).expect("present above");
765                    enqueue(
766                        &self.turn_tx,
767                        error_event(
768                            &ex.request,
769                            "bad_block",
770                            "the server asked for more after the last block".into(),
771                        ),
772                        client_id,
773                    );
774                    return;
775                };
776                ex.upload = Some(next);
777                ex.blocks_sent += 1;
778                let request = ex.request.clone();
779                let mid = self.fresh_mid();
780                let msg = request_message(&request, mid, &token, Part::Block1(next));
781                if let Err(e) = self.transmit(&token, msg).await {
782                    if let Some(ex) = self.exchanges.remove(&token) {
783                        enqueue(
784                            &self.turn_tx,
785                            error_event(&ex.request, "timeout", format!("send failed: {e}")),
786                            client_id,
787                        );
788                    }
789                }
790                return;
791            }
792            ex.upload = None;
793        }
794
795        if ex.request.observe == ObserveAction::Cancel {
796            if has_observe {
797                return; // a notification still in flight when the cancel went out
798            }
799            let ex = self.exchanges.remove(&token).expect("present above");
800            let mut data = response_data(&ex.request, &msg, &msg.payload, 1);
801            data["observing"] = json!(false);
802            enqueue(
803                &self.turn_tx,
804                Event::new(&COAP_RESPONSE_EVENT, data),
805                client_id,
806            );
807            return;
808        }
809
810        // A notification on a confirmed observation: report it, keep the observation.
811        if ex.observing {
812            let mut data = response_data(&ex.request, &msg, &msg.payload, 1);
813            data["sequence"] = json!(msg.option_uint(OPT_OBSERVE).unwrap_or(0));
814            if msg
815                .option_values(OPT_BLOCK2)
816                .first()
817                .and_then(|v| parse_block(v))
818                .is_some_and(|(_, more, _)| more)
819            {
820                data["truncated"] = json!(true);
821            }
822            enqueue(
823                &self.turn_tx,
824                Event::new(&COAP_NOTIFICATION_EVENT, data),
825                client_id,
826            );
827            return;
828        }
829
830        // Block2 reassembly.
831        if let Some(value) = msg.option_values(OPT_BLOCK2).first() {
832            let Some((num, more, szx)) = parse_block(value) else {
833                let ex = self.exchanges.remove(&token).expect("present above");
834                enqueue(
835                    &self.turn_tx,
836                    error_event(&ex.request, "bad_block", "unreadable Block2 option".into()),
837                    client_id,
838                );
839                return;
840            };
841            if num != ex.next_block {
842                let expected = ex.next_block;
843                let ex = self.exchanges.remove(&token).expect("present above");
844                enqueue(
845                    &self.turn_tx,
846                    error_event(
847                        &ex.request,
848                        "bad_block",
849                        format!("the server sent block {num}; block {expected} was expected"),
850                    ),
851                    client_id,
852                );
853                return;
854            }
855            if ex.body.len() + msg.payload.len() > MAX_BODY {
856                let ex = self.exchanges.remove(&token).expect("present above");
857                warn!(
858                    "CoAP client {client_id} {} passed {MAX_BODY} bytes decision=body_too_large",
859                    ex.request.path_string()
860                );
861                enqueue(
862                    &self.turn_tx,
863                    error_event(
864                        &ex.request,
865                        "body_too_large",
866                        format!("the Block2 transfer passed {MAX_BODY} bytes"),
867                    ),
868                    client_id,
869                );
870                return;
871            }
872            ex.body.extend_from_slice(&msg.payload);
873            ex.blocks += 1;
874            if has_observe && ex.request.observe == ObserveAction::Register {
875                ex.observing = true;
876            }
877            if more {
878                ex.next_block = num + 1;
879                let request = ex.request.clone();
880                let mid = self.fresh_mid();
881                let next = request_message(&request, mid, &token, Part::Block2(num + 1, szx));
882                if let Err(e) = self.transmit(&token, next).await {
883                    if let Some(ex) = self.exchanges.remove(&token) {
884                        enqueue(
885                            &self.turn_tx,
886                            error_event(&ex.request, "timeout", format!("send failed: {e}")),
887                            client_id,
888                        );
889                    }
890                }
891                return;
892            }
893        } else {
894            ex.body = msg.payload.clone();
895            ex.blocks = 1;
896            if has_observe && ex.request.observe == ObserveAction::Register {
897                ex.observing = true;
898            }
899        }
900
901        // The response is complete.
902        let body = std::mem::take(&mut ex.body);
903        let mut data = response_data(&ex.request, &msg, &body, ex.blocks);
904        if ex.blocks_sent > 0 {
905            data["blocks_sent"] = json!(ex.blocks_sent);
906        }
907        if ex.request.observe == ObserveAction::Register {
908            data["observing"] = json!(ex.observing);
909        }
910        if ex.observing {
911            // The registration stands: no deadline, no retransmission.
912            ex.deadline = None;
913            ex.awaiting_ack = false;
914        } else {
915            self.exchanges.remove(&token);
916        }
917        enqueue(
918            &self.turn_tx,
919            Event::new(&COAP_RESPONSE_EVENT, data),
920            client_id,
921        );
922    }
923}
924
925/// Own the socket for the life of the client.
926#[allow(clippy::too_many_arguments)]
927async fn run_transport(
928    socket: UdpSocket,
929    reliability: Reliability,
930    mut outbound_rx: mpsc::Receiver<Outbound>,
931    turn_tx: mpsc::Sender<Event>,
932    app_state: Arc<AppState>,
933    status_tx: mpsc::UnboundedSender<String>,
934    client_id: ClientId,
935    turn_abort: tokio::task::AbortHandle,
936) {
937    let mut t = Transport {
938        socket,
939        reliability,
940        exchanges: HashMap::new(),
941        next_mid: rand::random::<u16>(),
942        recent: VecDeque::new(),
943        turn_tx,
944        client_id,
945    };
946    // One byte over the bound, so an oversize datagram is seen as oversize rather than
947    // silently truncated to something that decodes.
948    let mut buf = vec![0u8; MAX_MESSAGE_LEN + 1];
949
950    let status = loop {
951        let deadline = t.exchanges.values().filter_map(|ex| ex.deadline).min();
952        tokio::select! {
953            received = t.socket.recv(&mut buf) => match received {
954                Ok(n) if n > MAX_MESSAGE_LEN => warn!(
955                    "CoAP client {client_id} dropped a datagram over {MAX_MESSAGE_LEN} bytes \
956                     decision=oversize"
957                ),
958                Ok(n) => {
959                    let datagram = buf[..n].to_vec();
960                    t.handle_datagram(&datagram).await;
961                }
962                // ICMP port unreachable on a connected socket: the server is not there (yet).
963                // The exchange times out through its own retransmissions.
964                Err(e) if e.kind() == std::io::ErrorKind::ConnectionRefused => {
965                    debug!("CoAP client {client_id}: {e}");
966                }
967                Err(e) => {
968                    error!("CoAP client {client_id} socket error: {e}");
969                    break ClientStatus::Error(e.to_string());
970                }
971            },
972            _ = async {
973                match deadline {
974                    Some(d) => tokio::time::sleep_until(d).await,
975                    None => std::future::pending::<()>().await,
976                }
977            } => t.expire().await,
978            out = outbound_rx.recv() => match out {
979                None => break ClientStatus::Disconnected,
980                Some(Outbound::Disconnect { ack }) => {
981                    let _ = ack.send(());
982                    info!("CoAP client {client_id} stopped on request");
983                    break ClientStatus::Disconnected;
984                }
985                Some(Outbound::Request { request, ack }) => {
986                    let _ = ack.send(t.start(request).await);
987                }
988            },
989        }
990    };
991
992    turn_abort.abort();
993    app_state.remove_client_handle(client_id).await;
994    app_state.update_client_status(client_id, status).await;
995    let _ = status_tx.send(format!("[CLIENT] CoAP client {client_id} stopped"));
996    let _ = status_tx.send("__UPDATE_UI__".to_string());
997}
998
999/// Answer queued events with the model, one at a time, in arrival order.
1000async fn run_turns(
1001    mut turn_rx: mpsc::Receiver<Event>,
1002    outbound_tx: mpsc::Sender<Outbound>,
1003    protocol: Arc<CoapClientProtocol>,
1004    llm_client: OllamaClient,
1005    app_state: Arc<AppState>,
1006    status_tx: mpsc::UnboundedSender<String>,
1007    client_id: ClientId,
1008) {
1009    while let Some(event) = turn_rx.recv().await {
1010        let Some(instruction) = app_state.get_instruction_for_client(client_id).await else {
1011            continue;
1012        };
1013        let memory = app_state
1014            .get_memory_for_client(client_id)
1015            .await
1016            .unwrap_or_default();
1017        match call_llm_for_client(
1018            &llm_client,
1019            &app_state,
1020            client_id.to_string(),
1021            &instruction,
1022            &memory,
1023            Some(&event),
1024            protocol.as_ref(),
1025            &status_tx,
1026        )
1027        .await
1028        {
1029            Ok(result) => {
1030                if let Some(mem) = result.memory_updates {
1031                    app_state.set_memory_for_client(client_id, mem).await;
1032                }
1033                info!(
1034                    "CoAP client {client_id} {} decision={} actions={}",
1035                    event.id(),
1036                    if result.actions.is_empty() {
1037                        "model_silent"
1038                    } else {
1039                        "model_actions"
1040                    },
1041                    result.actions.len()
1042                );
1043                for action in result.actions {
1044                    match apply_action(protocol.as_ref(), &outbound_tx, action).await {
1045                        Ok(Applied::Disconnect) => return,
1046                        Ok(_) => {}
1047                        Err(e) => {
1048                            error!("CoAP client {client_id} action failed: {e}");
1049                            let _ = status_tx.send(format!(
1050                                "[ERROR] CoAP client {client_id} action failed: {e}"
1051                            ));
1052                        }
1053                    }
1054                }
1055            }
1056            Err(e) => error!(
1057                "CoAP client {client_id} {} decision=llm_error: {e}",
1058                event.id()
1059            ),
1060        }
1061    }
1062}
1063
1064/// Execute one action and hand its request to the transport. Shared by the model's turns and
1065/// injected commands.
1066async fn apply_action(
1067    protocol: &CoapClientProtocol,
1068    outbound_tx: &mpsc::Sender<Outbound>,
1069    action: Value,
1070) -> Result<Applied> {
1071    match protocol.execute_action(action)? {
1072        ClientActionResult::Custom { name, data } if name == REQUEST_RESULT => {
1073            let request = request_from_action(&data)?
1074                .ok_or_else(|| anyhow!("a request action produced no request"))?;
1075            let (ack, done) = oneshot::channel();
1076            outbound_tx
1077                .send(Outbound::Request { request, ack })
1078                .await
1079                .map_err(|_| anyhow!("the client is stopped"))?;
1080            let bytes = done
1081                .await
1082                .map_err(|_| anyhow!("the client stopped before the request was sent"))?
1083                .map_err(|e| anyhow!(e))?;
1084            Ok(if bytes == 0 {
1085                Applied::Nothing
1086            } else {
1087                Applied::Sent(bytes)
1088            })
1089        }
1090        ClientActionResult::Disconnect => {
1091            let (ack, done) = oneshot::channel();
1092            if outbound_tx.send(Outbound::Disconnect { ack }).await.is_ok() {
1093                let _ = done.await;
1094            }
1095            Ok(Applied::Disconnect)
1096        }
1097        _ => Ok(Applied::Nothing),
1098    }
1099}
1100
1101async fn command_loop(
1102    mut command_rx: mpsc::Receiver<ClientCommand>,
1103    protocol: Arc<CoapClientProtocol>,
1104    outbound_tx: mpsc::Sender<Outbound>,
1105    client_id: ClientId,
1106    app_state: Arc<AppState>,
1107    status_tx: mpsc::UnboundedSender<String>,
1108) {
1109    while let Some(command) = command_rx.recv().await {
1110        let action = command.action.clone();
1111        let outcome = match apply_action(protocol.as_ref(), &outbound_tx, action.clone()).await {
1112            Ok(Applied::Sent(bytes_sent)) => ClientSendOutcome::Sent { bytes_sent },
1113            Ok(Applied::Nothing) => ClientSendOutcome::Executed {
1114                detail: "executed (nothing to send)".to_string(),
1115            },
1116            Ok(Applied::Disconnect) => ClientSendOutcome::Disconnected,
1117            Err(e) => ClientSendOutcome::Rejected {
1118                error: e.to_string(),
1119            },
1120        };
1121        app_state
1122            .record_access_log(
1123                AccessLogOwner::Client(client_id.as_u32()),
1124                protocol.protocol_name(),
1125                None,
1126                "injected_action",
1127                action,
1128                vec![serde_json::to_value(&outcome).unwrap_or_default()],
1129            )
1130            .await;
1131        let disconnect = matches!(outcome, ClientSendOutcome::Disconnected);
1132        let _ = status_tx.send("__UPDATE_UI__".to_string());
1133        crate::client::command_support::reply(command, Ok(outcome));
1134        if disconnect {
1135            break;
1136        }
1137    }
1138}