Skip to main content

netget/server/coap/
actions.rs

1//! CoAP protocol actions.
2//!
3//! The model decides two things a constrained-device server actually decides: what a
4//! resource *is* (its representation and media type) and which response code the request
5//! deserves. Everything the transport determines — the message type of the reply, the
6//! message id, the token — is reconstructed by `src/server/coap/mod.rs` from the request,
7//! so the model can neither break reliability matching nor be asked to echo an
8//! identifier it has no business handling.
9
10use crate::llm::actions::{
11    protocol_trait::{ActionResult, Protocol, Server},
12    ActionDefinition, Parameter, ParameterDefinition,
13};
14use crate::protocol::log_template::LogTemplate;
15use crate::protocol::EventType;
16use crate::state::app_state::AppState;
17use anyhow::{Context, Result};
18use serde_json::json;
19use std::sync::LazyLock;
20
21use super::codec;
22
23/// Names carried by the [`ActionResult::Custom`] results this protocol produces.
24pub const RESULT_RESPONSE: &str = "coap_response";
25pub const RESULT_RESET: &str = "coap_reset";
26pub const RESULT_IGNORE: &str = "coap_ignore";
27pub const RESULT_NOTIFIED: &str = "coap_notified";
28
29/// CoAP server protocol handler.
30#[derive(Default)]
31pub struct CoapProtocol;
32
33impl CoapProtocol {
34    pub fn new() -> Self {
35        Self
36    }
37}
38
39impl Protocol for CoapProtocol {
40    fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
41        // The one thing a CoAP server says unasked: a notification to the observers of a
42        // resource whose state changed (RFC 7641).
43        vec![coap_notify_action()]
44    }
45
46    fn get_sync_actions(&self) -> Vec<ActionDefinition> {
47        vec![
48            send_coap_response_action(),
49            send_coap_reset_action(),
50            ignore_coap_request_action(),
51            coap_notify_action(),
52        ]
53    }
54
55    fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
56        vec![ParameterDefinition {
57            name: "notification_ack_timeout_ms".to_string(),
58            type_hint: "number".to_string(),
59            description: "Milliseconds to wait for the ACK of a Confirmable notification \
60                 (coap_notify with confirmable: true) before resending it, doubling each \
61                 time. After 4 unanswered resends the observer is presumed gone and its \
62                 observation is removed (RFC 7641 §4.5). RFC 7252's ACK_TIMEOUT; 100-60000."
63                .to_string(),
64            required: false,
65            example: json!(2000),
66            default: Some(json!(super::DEFAULT_NOTIFICATION_ACK_TIMEOUT_MS)),
67        }]
68    }
69
70    fn protocol_name(&self) -> &'static str {
71        "CoAP"
72    }
73
74    fn get_event_types(&self) -> Vec<EventType> {
75        get_coap_event_types()
76    }
77
78    fn stack_name(&self) -> &'static str {
79        "ETH>IP>UDP>COAP"
80    }
81
82    fn keywords(&self) -> Vec<&'static str> {
83        // "coap" and multi-word phrases only; nothing generic like "iot" or "sensor",
84        // which would swallow requests meant for MQTT or Modbus.
85        vec![
86            "coap",
87            "coap server",
88            "constrained application protocol",
89            "rfc 7252",
90        ]
91    }
92
93    fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
94        use crate::protocol::metadata::{
95            DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
96        };
97
98        ProtocolMetadataV2::builder()
99            .connectionless()
100            // Answers on failure: 5.03 Service Unavailable, never an invented 2.05.
101            .answers_on_failure()
102            // STABLE, set 16 September 2026 against the six conditions in the root CLAUDE.md,
103            // each verified in that pass rather than inherited. Read `.e2e_testing` and
104            // `.notes` below before quoting this: the rating is about the evidence for the
105            // surface this server implements, and that surface is a subset of RFC 7252.
106            //
107            // One of the six was FALSE when the pass began and is worth knowing about, because
108            // nothing in CI can catch it: `fuzz/fuzz_targets/coap_message.rs` had not compiled
109            // since `0996d00f` made `encode` fallible, so "a fuzz target exists and has run
110            // clean" was a claim about a file rather than an execution. `fuzz/` is its own
111            // workspace and no job builds it. Rebuild it before trusting this line again.
112            //
113            // The line this replaces read "Not Stable: Stable additionally wants spec
114            // compliance and scripting support reviewed" — a bar nobody had written down,
115            // which is precisely how the three previous Stable ratings in this repository were
116            // set and lost.
117            .state(DevelopmentState::Stable)
118            // 5683 is above 1023; declaring PrivilegedPort here could never fire.
119            .privilege_requirement(PrivilegeRequirement::None)
120            .well_known_udp_port(5683)
121            .implementation(
122                "Hand-rolled RFC 7252 codec (src/server/coap/codec.rs): 4-byte header, \
123                 CON/NON/ACK/RST, tokens, option delta/length with both extension forms, \
124                 payload marker, GET/POST/PUT/DELETE. Observe (RFC 7641, observe.rs): \
125                 registration, deregistration, NON or CON notifications, RST and \
126                 unacknowledged-CON removal. Block-wise (RFC 7959, blockwise.rs): Block2 \
127                 served from one cached answer with ETag and Size2, Block1 reassembled with \
128                 2.31 Continue, 4.08 and 4.13+Size1",
129            )
130            .llm_control(
131                "Resource representations, media type and response code, whether a resource \
132                 is observable, and when its observers are notified. Message type, message id, \
133                 token echo, block slicing and reassembly are server-side",
134            )
135            .e2e_testing(
136                "STABLE rests on all six conditions, each checked against source and by \
137                 running it on 16 September 2026. (1) TWO independent clients, neither \
138                 #[ignore]d and neither able to skip: libcoap 4.3.5's own `coap-client` \
139                 binary -- C, a different project -- in real_client_test.rs, which FAILS \
140                 rather than skips when libcoap is absent, asserting the exact 2.05 body \
141                 libcoap parsed and printed, a POST payload echoed back in a 2.04, and a 4.04 \
142                 reported as an error with no body; and the coap 0.27 UDP client over \
143                 coap-lite 0.13, an unconditional dev-dependency, doing its own CON/ACK \
144                 matching so a wrong message id or token is a timeout rather than a pass. \
145                 (Those two crates are ONE family -- coap is built on coap-lite -- so they \
146                 count as one independent peer, not two.) (2) The pcap oracle runs over every \
147                 datagram in e2e_test::exchange and llm_failure_test::exchange, and hard-fails \
148                 when tshark is missing. (3) fuzz/fuzz_targets/coap_message.rs: 292,391 runs \
149                 in 91s, clean, no artefact. Its corpus has no depth bomb because the decoder \
150                 has no recursion -- the equivalent blind spot is the option delta/length \
151                 extension forms, and option_delta_ext13/ext14 seed those. (4) Every declared \
152                 bound has a test in bounds_test.rs -- MAX_MESSAGE_LEN, MAX_TOKEN_LEN in both \
153                 directions, MAX_OPTION_LEN, MAX_PAYLOAD_LEN -- each with an at-limit half and \
154                 each verified by REMOVING the bound and recording what failed. (5) Both \
155                 CLAUDE.md files were re-read against source in that pass; the corrections are \
156                 in them. (6) No #[ignore] and no skip gate anywhere in tests/server/coap/. \
157                 \
158                 OBSERVE AND BLOCK-WISE, added October 2026, carry the same six conditions on \
159                 their own surface (observe_blockwise_test.rs, observe_blockwise_bounds_test.rs): \
160                 (1) libcoap's coap-client and aiocoap 0.4 (Python, its own codec) each \
161                 register an observation and receive notifications NetGet pushed with \
162                 coap_notify, fetch a 3000-byte representation as Block2 blocks and reassemble \
163                 it byte for byte, and upload a 3000-byte body as Block1 blocks that reaches \
164                 the model whole; both fail rather than skip when absent. (2) The pcap oracle \
165                 reads every datagram of the Observe and block exchanges. (3) The fuzz target \
166                 also reads Block1, Block2 and Observe from every decoded message. (4) \
167                 MAX_OBSERVERS, MAX_OBSERVERS_PER_IP, OBSERVER_MAX_AGE, MAX_UPLOADS, \
168                 MAX_UPLOADS_PER_IP, MAX_TRANSFERS, TRANSFER_SERVE_FACTOR, the Block2 TTL a \
169                 hit does not renew, MAX_REPRESENTATION_LEN (both directions), out-of-order \
170                 Block1, RST removal and the CON notification retransmission limit (including \
171                 past the RECENT_NOTIFICATIONS window) each have a test. \
172                 \
173                 UNPROVEN, because none of it is implemented: DTLS/CoAPS on 5684, separate \
174                 (non-piggybacked) responses, retransmission of Confirmable responses, \
175                 message deduplication (there is no cache, so a retransmitted CON produces a \
176                 second handler call) and multicast.",
177            )
178            .notes(
179                "Stable means the evidence for the implemented surface is complete, NOT that \
180                 RFC 7252 is. What is implemented: the 4-byte header, CON/NON/ACK/RST, tokens, \
181                 option delta/length with both extension forms, the payload marker, \
182                 GET/POST/PUT/DELETE, piggybacked responses, the ping->RST reply, RST for a \
183                 malformed CON, 4.13 for a datagram over MAX_MESSAGE_LEN, Observe \
184                 (RFC 7641) and Block-wise transfer in both directions (RFC 7959). Not \
185                 implemented: separate responses, deduplication, DTLS, multicast. \
186                 Two things a future reader should re-check rather than inherit: the fuzz \
187                 target (fuzz/ is its own workspace, NO CI job builds it, and this target was \
188                 found uncompilable-since-September in the pass that set this rating), and \
189                 that coap-client is still on PATH wherever the suite runs, since its test \
190                 hard-fails by design.",
191            )
192            // The bound on what a stranger can send, not on what this server writes.
193            // `MAX_PAYLOAD_LEN` was declared here and governs the outbound direction only,
194            // so the number named nothing the inbound path checked.
195            .max_inbound_bytes(crate::server::coap::codec::MAX_MESSAGE_LEN)
196            .build()
197    }
198
199    fn description(&self) -> &'static str {
200        "CoAP server impersonating a constrained IoT device (RFC 7252)"
201    }
202
203    fn example_prompt(&self) -> &'static str {
204        "Pretend to be a soil moisture sensor via coap on port 5683; GET /sensors/moisture \
205         returns JSON with a percentage, everything else is 4.04"
206    }
207
208    fn group_name(&self) -> &'static str {
209        "Application"
210    }
211
212    fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
213        use crate::llm::actions::StartupExamples;
214
215        // Deterministic: answer every request with a fixed JSON sensor reading,
216        // no LLM call.
217        let script = r#"import json, sys
218data = json.load(sys.stdin)
219event = data["event"]
220if data["event_type_id"] == "coap_request":
221    actions = [{"type": "send_coap_response", "code": "2.05",
222                "payload": '{"pct": 41.2}', "content_format": "application/json"}]
223else:
224    actions = []
225print(json.dumps({"actions": actions}))"#;
226
227        StartupExamples::new(
228            // LLM mode: the model invents the resource tree and its representations.
229            json!({
230                "type": "open_server",
231                "port": 5683,
232                "base_stack": "coap",
233                "instruction": "Act as a soil moisture sensor. GET /sensors/moisture returns \
234                                2.05 with JSON like {\"pct\": 41.2}. GET /.well-known/core \
235                                returns the link-format listing. Anything else is 4.04."
236            }),
237            // Script mode: deterministic routing, no LLM call per request.
238            json!({
239                "type": "open_server",
240                "port": 5683,
241                "base_stack": "coap",
242                "event_handlers": [{
243                    "event_pattern": "coap_request",
244                    "handler": {
245                        "type": "script",
246                        "language": "python",
247                        "code": script
248                    }
249                }]
250            }),
251            // Static mode: one fixed representation for every request.
252            json!({
253                "type": "open_server",
254                "port": 5683,
255                "base_stack": "coap",
256                "event_handlers": [{
257                    "event_pattern": "coap_request",
258                    "handler": {
259                        "type": "static",
260                        "actions": [{
261                            "type": "send_coap_response",
262                            "code": "2.05",
263                            "payload": "{\"pct\": 41.2}",
264                            "content_format": "application/json"
265                        }]
266                    }
267                }]
268            }),
269        )
270    }
271}
272
273impl Server for CoapProtocol {
274    fn spawn(
275        &self,
276        ctx: crate::protocol::SpawnContext,
277    ) -> std::pin::Pin<
278        Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
279    > {
280        Box::pin(async move {
281            let ack_timeout_ms = ctx
282                .startup_params
283                .as_ref()
284                .map(|p| p.get_optional_u64("notification_ack_timeout_ms"))
285                .transpose()?
286                .flatten()
287                .unwrap_or(super::DEFAULT_NOTIFICATION_ACK_TIMEOUT_MS);
288            if !(100..=60_000).contains(&ack_timeout_ms) {
289                anyhow::bail!(
290                    "notification_ack_timeout_ms must be between 100 and 60000, got \
291                     {ack_timeout_ms}"
292                );
293            }
294            let listen_addr = ctx.legacy_listen_addr();
295            super::CoapServer::spawn_with_llm_actions(
296                listen_addr,
297                ctx.llm_client,
298                ctx.state,
299                ctx.status_tx,
300                ctx.server_id,
301                std::time::Duration::from_millis(ack_timeout_ms),
302            )
303            .await
304        })
305    }
306
307    fn execute_action(&self, action: serde_json::Value) -> Result<ActionResult> {
308        let action_type = action
309            .get("type")
310            .and_then(|v| v.as_str())
311            .context("Missing 'type' field in action")?;
312
313        match action_type {
314            "send_coap_response" => {
315                let code = response_code(&action, "send_coap_response")?;
316                let payload = decode_payload(&action)?;
317                let content_format = content_format(&action, &payload, "send_coap_response")?;
318
319                let observable = match action.get("observable") {
320                    None | Some(serde_json::Value::Null) => true,
321                    Some(serde_json::Value::Bool(b)) => *b,
322                    Some(other) => anyhow::bail!(
323                        "send_coap_response 'observable' must be true or false, got {other}"
324                    ),
325                };
326
327                Ok(ActionResult::Custom {
328                    name: RESULT_RESPONSE.to_string(),
329                    data: json!({
330                        "code": code,
331                        "payload_hex": hex::encode(&payload),
332                        "content_format": content_format,
333                        "observable": observable,
334                    }),
335                })
336            }
337            "coap_notify" => {
338                // Validated here so a malformed one names its real problem, but it can only
339                // run against the live server's observers.
340                notification_from_action(&action)?;
341                anyhow::bail!(
342                    "coap_notify needs the running CoAP server's observers and must be \
343                     dispatched through execute_action_with_state"
344                )
345            }
346            "send_coap_reset" => Ok(ActionResult::Custom {
347                name: RESULT_RESET.to_string(),
348                data: json!({}),
349            }),
350            "ignore_coap_request" => Ok(ActionResult::Custom {
351                name: RESULT_IGNORE.to_string(),
352                data: json!({}),
353            }),
354            _ => Err(anyhow::anyhow!("Unknown CoAP action: {action_type}")),
355        }
356    }
357
358    fn execute_action_with_state<'a>(
359        &'a self,
360        action: serde_json::Value,
361        state: AppState,
362        server_id: Option<crate::state::ServerId>,
363    ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<ActionResult>> + Send + 'a>>
364    {
365        Box::pin(async move {
366            if action.get("type").and_then(|v| v.as_str()) != Some("coap_notify") {
367                return self.execute_action(action);
368            }
369            let (path, rep, confirmable) = notification_from_action(&action)?;
370            let server_id =
371                server_id.context("coap_notify needs a running CoAP server in context")?;
372            let handle = state
373                .server_handle::<super::CoapServerHandle>(server_id)
374                .await
375                .context("coap_notify: this server is not a running CoAP server")?;
376            let report = handle.notify(&state, &path, rep, confirmable).await;
377            Ok(ActionResult::Custom {
378                name: RESULT_NOTIFIED.to_string(),
379                data: json!({
380                    "path": path,
381                    "observers_notified": report.sent,
382                    "observations_ended": report.ended,
383                }),
384            })
385        })
386    }
387}
388
389/// The `code` field: a CoAP response code written `class.detail`, class 2, 4 or 5.
390fn response_code(action: &serde_json::Value, verb: &str) -> Result<u8> {
391    let code_value = action
392        .get("code")
393        .with_context(|| format!("{verb} requires a 'code', e.g. \"2.05\" or \"4.04\""))?;
394
395    let code = if let Some(s) = code_value.as_str() {
396        codec::parse_code_string(s).ok_or_else(|| {
397            anyhow::anyhow!(
398                "{verb} 'code' {s:?} is not a CoAP response code. Write it as class.detail, \
399                 e.g. \"2.05\" (Content), \"2.04\" (Changed), \"4.04\" (Not Found), \"5.03\" \
400                 (Service Unavailable)."
401            )
402        })?
403    } else {
404        anyhow::bail!("{verb} 'code' must be a string such as \"2.05\", got {code_value}");
405    };
406
407    let class = codec::code_class(code);
408    if !(2..=5).contains(&class) || class == 3 {
409        anyhow::bail!(
410            "{verb} 'code' {} is not a response code: class must be 2 (success), 4 (client \
411             error) or 5 (server error). Class 0 is a request method, and 1/3/6/7 are reserved.",
412            codec::code_to_string(code)
413        );
414    }
415    Ok(code)
416}
417
418/// The `content_format` field. Explicit wins; otherwise text/plain when there is a payload at
419/// all, so a client is never handed bytes with no media type. An empty string is "no media
420/// type", which is what a model writes for a 4.04 with no body; it reads as absent rather
421/// than as an unknown media type.
422fn content_format(action: &serde_json::Value, payload: &[u8], verb: &str) -> Result<Option<u16>> {
423    let default = if payload.is_empty() { None } else { Some(0) };
424    Ok(match action.get("content_format") {
425        Some(serde_json::Value::String(s)) if s.trim().is_empty() => default,
426        Some(serde_json::Value::String(s)) => {
427            Some(codec::content_format_id(s).ok_or_else(|| {
428                anyhow::anyhow!(
429                    "{verb} 'content_format' {s:?} is not a CoAP media type. Use \"text/plain\", \
430                 \"application/json\", \"application/cbor\", \"application/xml\", \
431                 \"application/octet-stream\" or \"application/link-format\", or the numeric \
432                 identifier."
433                )
434            })?)
435        }
436        Some(serde_json::Value::Number(n)) => {
437            let id = n.as_u64().unwrap_or(u64::MAX);
438            if id > u16::MAX as u64 {
439                anyhow::bail!(
440                    "{verb} 'content_format' {id} is not a valid Content-Format identifier"
441                );
442            }
443            Some(id as u16)
444        }
445        Some(serde_json::Value::Null) | None => default, // text/plain;charset=utf-8
446        Some(other) => anyhow::bail!(
447            "{verb} 'content_format' must be a media type string or a numeric identifier, got \
448             {other}"
449        ),
450    })
451}
452
453/// A `coap_notify` action, read into the path, the representation and the message type.
454fn notification_from_action(
455    action: &serde_json::Value,
456) -> Result<(String, super::blockwise::Representation, bool)> {
457    let path = action
458        .get("path")
459        .and_then(|v| v.as_str())
460        .context("coap_notify requires a 'path', the observed resource, e.g. \"/temperature\"")?;
461    let path = if path.starts_with('/') {
462        path.to_string()
463    } else {
464        format!("/{path}")
465    };
466    let code = match action.get("code") {
467        None | Some(serde_json::Value::Null) => codec::CODE_CONTENT,
468        Some(_) => response_code(action, "coap_notify")?,
469    };
470    let payload = decode_payload(action)?;
471    let content_format = content_format(action, &payload, "coap_notify")?;
472    let confirmable = match action.get("confirmable") {
473        None | Some(serde_json::Value::Null) => false,
474        Some(serde_json::Value::Bool(b)) => *b,
475        Some(other) => {
476            anyhow::bail!("coap_notify 'confirmable' must be true or false, got {other}")
477        }
478    };
479    Ok((
480        path,
481        super::blockwise::Representation::new(code, payload, content_format),
482        confirmable,
483    ))
484}
485
486/// Turn the action's `payload` into the exact bytes to put on the wire, honouring the
487/// explicit `encoding` field.
488///
489/// There is deliberately no sniffing: `"48656c6c6f"` is both valid text and valid hex,
490/// and only the sender knows which it means. A documented `encoding` that the executor
491/// then ignores is the `send_tcp_data` bug; this function is the reason it cannot recur
492/// here.
493fn decode_payload(action: &serde_json::Value) -> Result<Vec<u8>> {
494    let payload = match action.get("payload") {
495        None | Some(serde_json::Value::Null) => return Ok(Vec::new()),
496        Some(serde_json::Value::String(s)) => s.clone(),
497        Some(other) => anyhow::bail!(
498            "'payload' must be a string (use \"encoding\": \"hex\" for binary), got {other}"
499        ),
500    };
501
502    let encoding = action
503        .get("encoding")
504        .and_then(|v| v.as_str())
505        .unwrap_or("utf8");
506
507    let bytes = decode_with_encoding(&payload, encoding)?;
508
509    if bytes.len() > codec::MAX_REPRESENTATION_LEN {
510        anyhow::bail!(
511            "'payload' is {} bytes, over the {}-byte limit. A representation over {} bytes is \
512             sent in Block-wise transfer blocks (RFC 7959), but every block is served from one \
513             held copy, so the whole has a ceiling. Return a smaller representation - a \
514             summary, fewer records, or a link to the detail.",
515            bytes.len(),
516            codec::MAX_REPRESENTATION_LEN,
517            codec::MAX_PAYLOAD_LEN
518        );
519    }
520
521    Ok(bytes)
522}
523
524/// Turn the `payload` string into bytes according to `encoding`, with no size opinion.
525fn decode_with_encoding(payload: &str, encoding: &str) -> Result<Vec<u8>> {
526    let payload = payload.to_string();
527    match encoding {
528        "utf8" => Ok(payload.into_bytes()),
529        "hex" => {
530            let cleaned: String = payload
531                .chars()
532                .filter(|c| !c.is_ascii_whitespace() && *c != ':')
533                .collect();
534            let cleaned = cleaned.strip_prefix("0x").unwrap_or(&cleaned);
535            if cleaned.len() % 2 != 0 {
536                anyhow::bail!(
537                    "Invalid hex in 'payload': expected an even number of hex digits, got {} \
538                     ({payload:?}). Each byte is two hex digits.",
539                    cleaned.len()
540                );
541            }
542            hex::decode(cleaned).map_err(|e| {
543                anyhow::anyhow!(
544                    "Invalid hex in 'payload' ({payload:?}): {e}. Use only 0-9/a-f, two digits \
545                     per byte. To send this string as literal text, omit 'encoding' or set it \
546                     to \"utf8\"."
547                )
548            })
549        }
550        other => anyhow::bail!(
551            "Invalid 'encoding' value {other:?}. Valid values are \"utf8\" (default, send the \
552             characters of 'payload' as-is) and \"hex\" (decode 'payload' as hex-encoded \
553             bytes)."
554        ),
555    }
556}
557
558// ===========================================================================
559// Action definitions
560// ===========================================================================
561
562fn send_coap_response_action() -> ActionDefinition {
563    ActionDefinition {
564        name: "send_coap_response".to_string(),
565        description: "Answer a CoAP request. You decide the resource's representation and which \
566             response code it deserves: 2.05 Content for a successful GET, 2.01 Created / \
567             2.04 Changed for a POST or PUT, 2.02 Deleted for a DELETE, 4.04 Not Found for a \
568             resource this device does not have, 4.05 Method Not Allowed, 4.00 Bad Request, \
569             5.03 Service Unavailable. A path your instructions do not give this device is \
570             4.04 with no payload - never a representation invented for it. Where the \
571             instructions leave a resource's value open, invent one that is plausible for the \
572             device and keep it consistent across requests with set_memory. The example's \
573             payload is a placeholder for the shape, never data. The message type of the reply \
574             (ACK for a CON request, NON for a NON request), the message id and the token echo \
575             are handled for you, and so is Block-wise transfer: a payload over 1024 bytes \
576             (up to 64 KiB) is sent in blocks without you seeing the continuations. When the \
577             request's 'observe' is \"register\", a 2.xx answer registers the client as an \
578             observer of the path; push later states with coap_notify."
579            .to_string(),
580        parameters: vec![
581            Parameter {
582                name: "code".to_string(),
583                type_hint: "string".to_string(),
584                description:
585                    "CoAP response code written as class.detail, e.g. \"2.05\", \"2.04\", \
586                     \"4.04\", \"5.03\". Class must be 2, 4 or 5."
587                        .to_string(),
588                required: true,
589            },
590            Parameter {
591                name: "payload".to_string(),
592                type_hint: "string".to_string(),
593                description: "Representation to return. Interpreted according to 'encoding': by \
594                     default the characters of this string are sent as UTF-8. Omit for a \
595                     response with no body (a DELETE confirmation, a 4.04)."
596                    .to_string(),
597                required: false,
598            },
599            Parameter {
600                name: "encoding".to_string(),
601                type_hint: "string".to_string(),
602                description:
603                    "How to turn 'payload' into bytes. \"utf8\" (the default when omitted) \
604                     sends the characters unchanged - use it for text, JSON and XML. \"hex\" \
605                     decodes 'payload' as hex-encoded bytes, two hex digits per byte - use it \
606                     for CBOR or any other binary representation, e.g. {\"payload\": \
607                     \"a1636f6e f5\", \"encoding\": \"hex\"}. There is no auto-detection."
608                        .to_string(),
609                required: false,
610            },
611            Parameter {
612                name: "content_format".to_string(),
613                type_hint: "string".to_string(),
614                description: "Media type of 'payload': \"text/plain\", \"application/json\", \
615                     \"application/cbor\", \"application/xml\", \"application/octet-stream\", \
616                     \"application/link-format\", or the numeric CoAP identifier. Defaults to \
617                     text/plain when a payload is present and this is omitted."
618                    .to_string(),
619                required: false,
620            },
621            Parameter {
622                name: "observable".to_string(),
623                type_hint: "boolean".to_string(),
624                description: "Only matters when the request's 'observe' is \"register\". \
625                     Omitted or true, a 2.xx answer registers the client and it will receive \
626                     your coap_notify updates for this path. false serves the representation \
627                     once and tells the client the resource is not observable."
628                    .to_string(),
629                required: false,
630            },
631        ],
632        example: json!({
633            "type": "send_coap_response",
634            "code": "2.05",
635            "payload": "<the resource's representation>",
636            "content_format": "text/plain"
637        }),
638        log_template: Some(
639            LogTemplate::new()
640                .with_info("-> {code}")
641                .with_debug("CoAP response {code} ({output_bytes}B)")
642                .with_trace("CoAP response: {preview(payload,200)}"),
643        ),
644    }
645}
646
647fn send_coap_reset_action() -> ActionDefinition {
648    ActionDefinition {
649        name: "send_coap_reset".to_string(),
650        description:
651            "Reject the message with a CoAP Reset (RST). Use this when the device cannot make \
652             any sense of the request at all - not as a way to say 'not found', which is \
653             4.04. A Reset tells the client to stop retransmitting and that no response is \
654             coming."
655                .to_string(),
656        parameters: vec![],
657        example: json!({ "type": "send_coap_reset" }),
658        log_template: Some(
659            LogTemplate::new()
660                .with_info("-> RST")
661                .with_debug("CoAP reset"),
662        ),
663    }
664}
665
666fn ignore_coap_request_action() -> ActionDefinition {
667    ActionDefinition {
668        name: "ignore_coap_request".to_string(),
669        description:
670            "Send nothing at all. Appropriate for a Non-confirmable request the device would \
671             not answer, or to model a sleeping or unreachable node. A Confirmable client \
672             will retransmit and eventually time out, which is the behaviour you are asking \
673             for when you choose this."
674                .to_string(),
675        parameters: vec![],
676        example: json!({ "type": "ignore_coap_request" }),
677        log_template: Some(LogTemplate::new().with_debug("CoAP request deliberately unanswered")),
678    }
679}
680
681fn coap_notify_action() -> ActionDefinition {
682    ActionDefinition {
683        name: "coap_notify".to_string(),
684        description: "Push a resource's new state to every client observing it (RFC 7641). Use \
685             it when the state your instructions describe changes - a reading moves, a PUT \
686             changes a value others observe - including while answering another request. \
687             Clients register by GETting the path with Observe; a path nobody observes \
688             notifies nobody, which is not an error. A 2.xx code (the default 2.05) keeps \
689             the observations; any 4.xx or 5.xx ends them, which is how you say the resource \
690             is gone. A payload over 1024 bytes goes out as its first block and the client \
691             fetches the rest."
692            .to_string(),
693        parameters: vec![
694            Parameter {
695                name: "path".to_string(),
696                type_hint: "string".to_string(),
697                description: "The observed resource, e.g. \"/temperature\".".to_string(),
698                required: true,
699            },
700            Parameter {
701                name: "payload".to_string(),
702                type_hint: "string".to_string(),
703                description: "The resource's new representation, read according to \
704                     'encoding' exactly as in send_coap_response."
705                    .to_string(),
706                required: false,
707            },
708            Parameter {
709                name: "encoding".to_string(),
710                type_hint: "string".to_string(),
711                description: "\"utf8\" (default) or \"hex\", as in send_coap_response.".to_string(),
712                required: false,
713            },
714            Parameter {
715                name: "content_format".to_string(),
716                type_hint: "string".to_string(),
717                description: "Media type of 'payload', as in send_coap_response.".to_string(),
718                required: false,
719            },
720            Parameter {
721                name: "code".to_string(),
722                type_hint: "string".to_string(),
723                description: "Response code of the notification. Default \"2.05\"; a 4.xx \
724                     or 5.xx ends every observation of the path."
725                    .to_string(),
726                required: false,
727            },
728            Parameter {
729                name: "confirmable".to_string(),
730                type_hint: "boolean".to_string(),
731                description: "true sends a Confirmable notification the client must \
732                     acknowledge; one that is never acknowledged ends the observation. Default \
733                     false (Non-confirmable)."
734                    .to_string(),
735                required: false,
736            },
737        ],
738        example: json!({
739            "type": "coap_notify",
740            "path": "/temperature",
741            "payload": "<the resource's new representation>",
742            "content_format": "text/plain"
743        }),
744        log_template: Some(
745            LogTemplate::new()
746                .with_info("-> notify {path}")
747                .with_debug("CoAP notify {path} ({output_bytes}B)")
748                .with_trace("CoAP notify {path}: {preview(payload,200)}"),
749        ),
750    }
751}
752
753pub static COAP_NOTIFY_ACTION: LazyLock<ActionDefinition> = LazyLock::new(coap_notify_action);
754
755pub static SEND_COAP_RESPONSE_ACTION: LazyLock<ActionDefinition> =
756    LazyLock::new(send_coap_response_action);
757pub static SEND_COAP_RESET_ACTION: LazyLock<ActionDefinition> =
758    LazyLock::new(send_coap_reset_action);
759pub static IGNORE_COAP_REQUEST_ACTION: LazyLock<ActionDefinition> =
760    LazyLock::new(ignore_coap_request_action);
761
762// ===========================================================================
763// Event types
764// ===========================================================================
765
766/// A GET/POST/PUT/DELETE arrived. This is the only event this protocol declares, and
767/// `src/server/coap/mod.rs` emits it for every well-formed request.
768pub static COAP_REQUEST_EVENT: LazyLock<EventType> = LazyLock::new(|| {
769    EventType::new(
770        "coap_request",
771        "A CoAP client is requesting a resource. Decide whether this device has a resource \
772         at that path - only the resources your instructions give it exist - and answer with \
773         its representation and a response code, or 4.04 when there is none. answer_with \
774         says which, for this request.",
775        json!({
776            "type": "send_coap_response",
777            "code": "2.05",
778            "payload": "<the resource's representation>",
779            "content_format": "text/plain"
780        }),
781    )
782    .with_parameters(vec![
783        Parameter {
784            name: "method".to_string(),
785            type_hint: "string".to_string(),
786            description: "GET, POST, PUT or DELETE".to_string(),
787            required: true,
788        },
789        Parameter {
790            name: "path".to_string(),
791            type_hint: "string".to_string(),
792            description: "Requested path assembled from the Uri-Path options, e.g. \
793                 \"/sensors/moisture\". \"/\" when no Uri-Path option was sent."
794                .to_string(),
795            required: true,
796        },
797        Parameter {
798            name: "path_segments".to_string(),
799            type_hint: "array of strings".to_string(),
800            description: "The Uri-Path options as individual segments".to_string(),
801            required: true,
802        },
803        Parameter {
804            name: "query".to_string(),
805            type_hint: "string".to_string(),
806            description:
807                "Uri-Query options joined with '&', e.g. \"unit=C&since=60\". Absent when the \
808                 request carried no query."
809                    .to_string(),
810            required: false,
811        },
812        Parameter {
813            name: "message_type".to_string(),
814            type_hint: "string".to_string(),
815            description:
816                "\"CON\" for a Confirmable request (the client expects an acknowledgement and \
817                 will retransmit without one) or \"NON\" for a Non-confirmable one. The reply \
818                 is matched to it automatically; you do not need to act on this."
819                    .to_string(),
820            required: true,
821        },
822        Parameter {
823            name: "message_id".to_string(),
824            type_hint: "integer".to_string(),
825            description: "The client's message id. Echoed back automatically - informational only."
826                .to_string(),
827            required: true,
828        },
829        Parameter {
830            name: "content_format".to_string(),
831            type_hint: "string".to_string(),
832            description: "Media type of the request payload, when the client declared one"
833                .to_string(),
834            required: false,
835        },
836        Parameter {
837            name: "accept".to_string(),
838            type_hint: "string".to_string(),
839            description: "Media type the client asked for in the Accept option, when it sent one. \
840                 Prefer it when you can represent the resource that way."
841                .to_string(),
842            required: false,
843        },
844        Parameter {
845            name: "payload".to_string(),
846            type_hint: "string".to_string(),
847            description:
848                "Request body, for POST and PUT. Read it according to 'payload_encoding'. \
849                 Absent when the request carried no body. A body the client sent in Block1 \
850                 blocks arrives here whole."
851                    .to_string(),
852            required: false,
853        },
854        Parameter {
855            name: "payload_size".to_string(),
856            type_hint: "integer".to_string(),
857            description: "Length of the request body in bytes, when there is one.".to_string(),
858            required: false,
859        },
860        Parameter {
861            name: "blocks".to_string(),
862            type_hint: "integer".to_string(),
863            description: "How many Block1 blocks the body arrived in. Absent when it came in \
864                 one message. Informational: the reassembly is done."
865                .to_string(),
866            required: false,
867        },
868        Parameter {
869            name: "observe".to_string(),
870            type_hint: "string".to_string(),
871            description: "\"register\" when the client asks to observe this GET's resource: a \
872                 2.xx answer registers it, and it will receive every coap_notify for the \
873                 path. \"deregister\" when it asks to stop: it is already removed, answer the \
874                 GET as usual. Absent for an ordinary request."
875                .to_string(),
876            required: false,
877        },
878        Parameter {
879            name: "answer_with".to_string(),
880            type_hint: "string".to_string(),
881            description: "Which answer this request takes, by method and path: the success \
882                 code when your instructions give this device the resource, 4.04 when they \
883                 do not."
884                .to_string(),
885            required: true,
886        },
887        Parameter {
888            name: "payload_encoding".to_string(),
889            type_hint: "string".to_string(),
890            description:
891                "How to read 'payload': \"utf8\" means it is the received bytes as literal \
892                 text, \"hex\" means it is the received bytes hex-encoded (used whenever they \
893                 are not all printable)."
894                    .to_string(),
895            required: false,
896        },
897    ])
898    .with_actions(vec![
899        SEND_COAP_RESPONSE_ACTION.clone(),
900        SEND_COAP_RESET_ACTION.clone(),
901        IGNORE_COAP_REQUEST_ACTION.clone(),
902        COAP_NOTIFY_ACTION.clone(),
903    ])
904    .with_alternative_example(json!({
905        "type": "send_coap_response",
906        "code": "4.04"
907    }))
908    .with_alternative_example(json!({ "type": "send_coap_reset" }))
909    .with_log_template(
910        LogTemplate::new()
911            .with_info("{client_ip}:{client_port} {method} {path}")
912            .with_debug("CoAP {message_type} {method} {path} mid={message_id}")
913            .with_trace("CoAP request: {json_pretty(.)}"),
914    )
915});
916
917/// The `answer_with` event field: the answer this request takes, by method and path.
918///
919/// The code table in `send_coap_response`'s description was not enough on its own: told "you
920/// are a greenhouse sensor whose only resource is /temperature", llama3.1:8b answered a GET of
921/// /humidity with an invented 2.05 `{"humidity": 41.2}` - the value lifted from the action's
922/// example - five times in five. A small model reads the request in front of it, not a table,
923/// so the request names both outcomes and the one that applies when the instructions are
924/// silent about the path. It leads with the lookup and gives the 4.04 as the literal action:
925/// a first version that named the success code first was still answered with an invented 2.05
926/// in 2 runs of 5.
927pub fn answer_with_for_request(method: &str, path: &str) -> String {
928    let found = match method {
929        "POST" => "send_coap_response with code \"2.04\" (\"2.01\" when the POST creates a \
930                   resource)"
931            .to_string(),
932        "PUT" => "send_coap_response with code \"2.04\" (\"2.01\" when the PUT creates the \
933                  resource)"
934            .to_string(),
935        "DELETE" => "send_coap_response with code \"2.02\" and no payload".to_string(),
936        _ => format!(
937            "send_coap_response with code \"2.05\" whose payload is exactly what they say \
938             {path} returns, character for character - text stays text, never rewritten as \
939             JSON"
940        ),
941    };
942    format!(
943        "first look in your instructions for a resource at {path}. If they give this device \
944         none there - including when they say it has only other resources - answer exactly \
945         {{\"type\": \"send_coap_response\", \"code\": \"4.04\"}} and nothing else; never \
946         invent a resource they do not describe. If they do give one, {found}"
947    )
948}
949
950/// All CoAP event types. The single entry is emitted for every well-formed request.
951pub fn get_coap_event_types() -> Vec<EventType> {
952    vec![COAP_REQUEST_EVENT.clone()]
953}