1use 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
23pub 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#[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 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 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()
102 .state(DevelopmentState::Stable)
118 .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 .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 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 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 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 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 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
389fn 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
418fn 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, 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
453fn 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
486fn 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
524fn 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
558fn 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
762pub 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
917pub 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
950pub fn get_coap_event_types() -> Vec<EventType> {
952 vec![COAP_REQUEST_EVENT.clone()]
953}