1use crate::llm::actions::{
10 client_trait::{Client, ClientActionResult},
11 protocol_trait::Protocol,
12 ActionDefinition, Parameter, ParameterDefinition,
13};
14use crate::protocol::log_template::LogTemplate;
15use crate::protocol::EventType;
16use crate::server::coap::codec::{content_format_id, MAX_REPRESENTATION_LEN};
17use crate::state::app_state::AppState;
18use anyhow::{anyhow, Context, Result};
19use serde_json::{json, Value};
20use std::sync::LazyLock;
21
22pub const REQUEST_RESULT: &str = "coap_request";
24
25pub const DEFAULT_ACK_TIMEOUT_MS: u64 = 2000;
28
29pub const DEFAULT_MAX_RETRANSMIT: u64 = 4;
31
32pub const DEFAULT_BLOCK_SIZE: u64 = 1024;
35
36pub const MAX_URI_OPTION_LEN: usize = 255;
38
39fn param(name: &str, type_hint: &str, description: &str, required: bool) -> Parameter {
40 Parameter {
41 name: name.to_string(),
42 type_hint: type_hint.to_string(),
43 description: description.to_string(),
44 required,
45 }
46}
47
48fn response_params() -> Vec<Parameter> {
49 vec![
50 param(
51 "method",
52 "string",
53 "GET, POST, PUT or DELETE — the request answered",
54 true,
55 ),
56 param(
57 "path",
58 "string",
59 "The request's path, e.g. /sensors/temp",
60 true,
61 ),
62 param(
63 "code",
64 "string",
65 "The response code in c.dd form, e.g. 2.05",
66 true,
67 ),
68 param(
69 "status",
70 "string",
71 "The code's name, e.g. Content, Changed, Created, Not Found",
72 true,
73 ),
74 param(
75 "content_format",
76 "string",
77 "The payload's media type (text/plain, application/json, …), if the server named \
78 one",
79 false,
80 ),
81 param(
82 "payload",
83 "string",
84 "The payload as text. Absent when empty or not UTF-8 (payload_size still says how \
85 long it was)",
86 false,
87 ),
88 param(
89 "payload_json",
90 "any",
91 "The payload parsed, when the content format is application/json and it parses",
92 false,
93 ),
94 param("payload_size", "number", "Payload length in bytes", true),
95 param(
96 "options",
97 "object",
98 "Other response options: max_age, etag (hex), location_path, observe, size2",
99 true,
100 ),
101 ]
102}
103
104pub static COAP_CONNECTED_EVENT: LazyLock<EventType> = LazyLock::new(|| {
105 EventType::new(
106 "coap_connected",
107 "Ready to send CoAP requests to the server (UDP has no handshake; nothing was sent)",
108 json!({"type": "coap_get", "path": "/.well-known/core"}),
109 )
110 .with_parameters(vec![param(
111 "remote_addr",
112 "string",
113 "The CoAP server requests go to",
114 true,
115 )])
116});
117
118pub static COAP_RESPONSE_EVENT: LazyLock<EventType> = LazyLock::new(|| {
119 let mut params = response_params();
120 params.push(param(
121 "observing",
122 "boolean",
123 "Present on responses to coap_observe / coap_observe_cancel: true while the \
124 observation is registered, false once it is cancelled",
125 false,
126 ));
127 params.push(param(
128 "blocks",
129 "number",
130 "How many Block2 blocks the payload was reassembled from (1 when it fit in one)",
131 true,
132 ));
133 EventType::new(
134 "coap_response",
135 "The server answered a request (after reassembling any Block2 transfer)",
136 json!({"type": "coap_put", "path": "/example_data", "payload": "hello"}),
137 )
138 .with_parameters(params)
139});
140
141pub static COAP_NOTIFICATION_EVENT: LazyLock<EventType> = LazyLock::new(|| {
142 let mut params = response_params();
143 params.push(param(
144 "sequence",
145 "number",
146 "The notification's Observe sequence number",
147 true,
148 ));
149 EventType::new(
150 "coap_notification",
151 "An observed resource changed and the server sent its new state (RFC 7641)",
152 json!({"type": "coap_observe_cancel", "path": "/time"}),
153 )
154 .with_parameters(params)
155});
156
157pub static COAP_ERROR_EVENT: LazyLock<EventType> = LazyLock::new(|| {
158 EventType::new(
159 "coap_error",
160 "A request got no usable answer",
161 json!({"type": "coap_get", "path": "/"}),
162 )
163 .with_parameters(vec![
164 param(
165 "kind",
166 "string",
167 "timeout (no answer after every retransmission), reset (the server answered RST), \
168 body_too_large (a Block2 transfer passed the reassembly limit), bad_block (a \
169 Block2 block out of order), or not_observing (cancel for a path with no \
170 observation)",
171 true,
172 ),
173 param("message", "string", "What went wrong", true),
174 param("method", "string", "The request's method", true),
175 param("path", "string", "The request's path", true),
176 ])
177});
178
179pub struct CoapClientProtocol;
181
182impl CoapClientProtocol {
183 pub fn new() -> Self {
184 Self
185 }
186}
187
188impl Default for CoapClientProtocol {
189 fn default() -> Self {
190 Self::new()
191 }
192}
193
194fn common_params(with_payload: bool) -> Vec<Parameter> {
195 let mut params = vec![
196 param("path", "string", "Resource path, e.g. /sensors/temp", true),
197 param(
198 "query",
199 "string",
200 "Query string without '?', items separated by '&', e.g. unit=c&n=3",
201 false,
202 ),
203 param(
204 "confirmable",
205 "boolean",
206 "Send as Confirmable (retransmitted until acknowledged; default true) or \
207 Non-confirmable",
208 false,
209 ),
210 ];
211 if with_payload {
212 params.push(param(
213 "payload",
214 "any",
215 "The body: a string is sent as text, an object or array as JSON. Up to 64 KiB; \
216 over one block (block_size, default 1024 bytes) it is sent in Block1 blocks",
217 true,
218 ));
219 params.push(param(
220 "content_format",
221 "string",
222 "text/plain, application/json, application/link-format, … (default: \
223 application/json for an object or array, text/plain for a string)",
224 false,
225 ));
226 } else {
227 params.push(param(
228 "accept",
229 "string",
230 "The media type wanted back, e.g. application/json",
231 false,
232 ));
233 }
234 params
235}
236
237fn all_actions() -> Vec<ActionDefinition> {
238 let def = |name: &str, description: &str, params: Vec<Parameter>, example: Value| {
239 let info = match name {
240 "coap_get" => "-> CoAP GET {path} {query}",
241 "coap_post" => "-> CoAP POST {path} {query} {preview(payload,60)}",
242 "coap_put" => "-> CoAP PUT {path} {query} {preview(payload,60)}",
243 "coap_delete" => "-> CoAP DELETE {path} {query}",
244 "coap_observe" => "-> CoAP observe {path}",
245 "coap_observe_cancel" => "-> CoAP cancel observation of {path}",
246 _ => "-> CoAP stop",
247 };
248 ActionDefinition {
249 name: name.to_string(),
250 description: description.to_string(),
251 parameters: params,
252 example,
253 log_template: Some(LogTemplate::new().with_info(info)),
254 }
255 };
256 vec![
257 def(
258 "coap_get",
259 "Read a resource",
260 common_params(false),
261 json!({"type": "coap_get", "path": "/sensors/temp"}),
262 ),
263 def(
264 "coap_post",
265 "Send data to a resource (often creates one)",
266 common_params(true),
267 json!({"type": "coap_post", "path": "/logs", "payload": "door opened"}),
268 ),
269 def(
270 "coap_put",
271 "Replace a resource's state",
272 common_params(true),
273 json!({"type": "coap_put", "path": "/example_data", "payload": "hello"}),
274 ),
275 def(
276 "coap_delete",
277 "Delete a resource",
278 common_params(false),
279 json!({"type": "coap_delete", "path": "/example_data"}),
280 ),
281 def(
282 "coap_observe",
283 "Register to be notified whenever the resource changes (RFC 7641). The first \
284 answer arrives as coap_response with observing true; every later change as \
285 coap_notification",
286 common_params(false),
287 json!({"type": "coap_observe", "path": "/time"}),
288 ),
289 def(
290 "coap_observe_cancel",
291 "Cancel an observation made with coap_observe",
292 vec![param("path", "string", "The observed path", true)],
293 json!({"type": "coap_observe_cancel", "path": "/time"}),
294 ),
295 def(
296 "disconnect",
297 "Stop: forget every exchange and observation and close the socket",
298 vec![],
299 json!({"type": "disconnect"}),
300 ),
301 ]
302}
303
304#[derive(Debug, Clone, PartialEq, Eq)]
306pub struct CoapRequest {
307 pub method: &'static str,
309 pub path: Vec<String>,
311 pub query: Vec<String>,
313 pub confirmable: bool,
314 pub content_format: Option<u16>,
315 pub accept: Option<u16>,
316 pub payload: Vec<u8>,
317 pub observe: ObserveAction,
318}
319
320impl CoapRequest {
321 pub fn path_string(&self) -> String {
323 format!("/{}", self.path.join("/"))
324 }
325}
326
327#[derive(Debug, Clone, Copy, PartialEq, Eq)]
329pub enum ObserveAction {
330 None,
331 Register,
332 Cancel,
333}
334
335fn split_path(path: &str) -> Result<Vec<String>> {
336 let segments: Vec<String> = path
337 .split('/')
338 .filter(|s| !s.is_empty())
339 .map(str::to_string)
340 .collect();
341 if let Some(long) = segments.iter().find(|s| s.len() > MAX_URI_OPTION_LEN) {
342 return Err(anyhow!(
343 "path segment of {} bytes; a Uri-Path option carries at most {MAX_URI_OPTION_LEN}",
344 long.len()
345 ));
346 }
347 Ok(segments)
348}
349
350fn media_type(action: &Value, field: &str) -> Result<Option<u16>> {
351 match action.get(field) {
352 None | Some(Value::Null) => Ok(None),
353 Some(Value::String(name)) => content_format_id(name).map(Some).with_context(|| {
354 format!("unknown {field} {name:?}; use e.g. text/plain or application/json")
355 }),
356 Some(v) => v
357 .as_u64()
358 .and_then(|n| u16::try_from(n).ok())
359 .map(Some)
360 .with_context(|| format!("{field} must be a media type name or a number 0-65535")),
361 }
362}
363
364pub fn request_from_action(action: &Value) -> Result<Option<CoapRequest>> {
366 let action_type = action
367 .get("type")
368 .and_then(Value::as_str)
369 .context("missing 'type'")?;
370 let (method, observe, with_payload) = match action_type {
371 "coap_get" => ("GET", ObserveAction::None, false),
372 "coap_post" => ("POST", ObserveAction::None, true),
373 "coap_put" => ("PUT", ObserveAction::None, true),
374 "coap_delete" => ("DELETE", ObserveAction::None, false),
375 "coap_observe" => ("GET", ObserveAction::Register, false),
376 "coap_observe_cancel" => ("GET", ObserveAction::Cancel, false),
377 "disconnect" => return Ok(None),
378 other => return Err(anyhow!("Unknown CoAP client action: {other}")),
379 };
380 let path = split_path(
381 action
382 .get("path")
383 .and_then(Value::as_str)
384 .context("missing string field 'path'")?,
385 )?;
386 let query: Vec<String> = match action.get("query") {
387 None | Some(Value::Null) => Vec::new(),
388 Some(Value::String(q)) => q
389 .trim_start_matches('?')
390 .split('&')
391 .filter(|s| !s.is_empty())
392 .map(str::to_string)
393 .collect(),
394 Some(v) => return Err(anyhow!("'query' must be a string, got {v}")),
395 };
396 if let Some(long) = query.iter().find(|s| s.len() > MAX_URI_OPTION_LEN) {
397 return Err(anyhow!(
398 "query item of {} bytes; a Uri-Query option carries at most {MAX_URI_OPTION_LEN}",
399 long.len()
400 ));
401 }
402 let confirmable = match action.get("confirmable") {
403 None | Some(Value::Null) => true,
404 Some(v) => v.as_bool().context("'confirmable' must be true or false")?,
405 };
406 let (payload, content_format) = if with_payload {
407 let explicit = media_type(action, "content_format")?;
408 match action.get("payload") {
409 Some(Value::String(text)) => (text.as_bytes().to_vec(), explicit.or(Some(0))),
410 Some(v @ (Value::Object(_) | Value::Array(_))) => {
411 (serde_json::to_vec(v)?, explicit.or(Some(50)))
412 }
413 Some(Value::Null) | None => {
414 return Err(anyhow!("{action_type} needs a 'payload' (text or JSON)"))
415 }
416 Some(other) => (other.to_string().into_bytes(), explicit.or(Some(50))),
417 }
418 } else {
419 (Vec::new(), None)
420 };
421 if payload.len() > MAX_REPRESENTATION_LEN {
422 return Err(anyhow!(
423 "payload is {} bytes; this client uploads at most {MAX_REPRESENTATION_LEN} (a body \
424 over one block is sent in Block1 blocks, RFC 7959)",
425 payload.len()
426 ));
427 }
428 Ok(Some(CoapRequest {
429 method,
430 path,
431 query,
432 confirmable,
433 content_format,
434 accept: if with_payload {
435 None
436 } else {
437 media_type(action, "accept")?
438 },
439 payload,
440 observe,
441 }))
442}
443
444impl Protocol for CoapClientProtocol {
445 fn get_startup_parameters(&self) -> Vec<ParameterDefinition> {
446 vec![
447 ParameterDefinition {
448 name: "ack_timeout_ms".to_string(),
449 type_hint: "integer".to_string(),
450 description: "RFC 7252 ACK_TIMEOUT: milliseconds before the first \
451 retransmission of a Confirmable request (randomised up to 1.5x, \
452 doubling each time). Default 2000"
453 .to_string(),
454 required: false,
455 example: json!(2000),
456 default: Some(json!(DEFAULT_ACK_TIMEOUT_MS)),
457 },
458 ParameterDefinition {
459 name: "max_retransmit".to_string(),
460 type_hint: "integer".to_string(),
461 description: "RFC 7252 MAX_RETRANSMIT: how many times a Confirmable request is \
462 re-sent before it is reported as a timeout. Default 4, at most 8"
463 .to_string(),
464 required: false,
465 example: json!(4),
466 default: Some(json!(DEFAULT_MAX_RETRANSMIT)),
467 },
468 ParameterDefinition {
469 name: "block_size".to_string(),
470 type_hint: "integer".to_string(),
471 description: "Block1 block size in bytes: a request body larger than this is \
472 uploaded in blocks of this size (RFC 7959). A power of two from \
473 16 to 1024. A server that asks for smaller blocks gets them"
474 .to_string(),
475 required: false,
476 example: json!(1024),
477 default: Some(json!(DEFAULT_BLOCK_SIZE)),
478 },
479 ]
480 }
481
482 fn get_async_actions(&self, _state: &AppState) -> Vec<ActionDefinition> {
483 all_actions()
484 }
485
486 fn get_sync_actions(&self) -> Vec<ActionDefinition> {
487 all_actions()
488 }
489
490 fn protocol_name(&self) -> &'static str {
491 "CoAP"
492 }
493
494 fn get_event_types(&self) -> Vec<EventType> {
495 vec![
496 COAP_CONNECTED_EVENT.clone(),
497 COAP_RESPONSE_EVENT.clone(),
498 COAP_NOTIFICATION_EVENT.clone(),
499 COAP_ERROR_EVENT.clone(),
500 ]
501 }
502
503 fn stack_name(&self) -> &'static str {
504 "ETH>IP>UDP>CoAP"
505 }
506
507 fn keywords(&self) -> Vec<&'static str> {
508 vec![
509 "coap",
510 "coap client",
511 "constrained application protocol",
512 "iot client",
513 ]
514 }
515
516 fn metadata(&self) -> crate::protocol::metadata::ProtocolMetadataV2 {
517 use crate::protocol::metadata::{
518 DevelopmentState, PrivilegeRequirement, ProtocolMetadataV2,
519 };
520
521 ProtocolMetadataV2::builder()
522 .state(DevelopmentState::Beta)
523 .privilege_requirement(PrivilegeRequirement::None)
524 .implementation(
525 "CoAP (RFC 7252) client over a connected UDP socket, encoding and decoding with \
526 the server's own codec (src/server/coap/codec.rs). A transport task owns the \
527 socket: message ids and tokens, Confirmable retransmission with RFC 7252 \
528 back-off and a bounded attempt count, empty ACKs for separate responses and \
529 notifications, RST for tokens it does not know, Block2 reassembly (RFC 7959) \
530 up to 64 KiB, and Observe registration and cancellation (RFC 7641). The model \
531 is asked from a separate turn task.",
532 )
533 .llm_control(
534 "Which requests to send: GET/POST/PUT/DELETE with path, query, content format \
535 and a text or JSON payload (Block1 over block_size), Confirmable or not, and \
536 Observe register/cancel. \
537 Every answer arrives as coap_response, coap_notification or coap_error.",
538 )
539 .e2e_testing(
540 "tests/client/coap/real_server_test.rs, 7+ LLM calls, against libcoap's \
541 coap-server (C), written and read back with libcoap's coap-client. coap-client \
542 stores 3000 bytes; the model GETs them and is shown one coap_response \
543 reassembled from three Block2 blocks, PUTs a sentence built from that body \
544 (checked byte for byte), which coap-client reads back, then observes /time: \
545 registration, notifications, cancellation on the same token. A second test \
546 injects a PUT through the command channel that coap-client reads back, and a \
547 third PUTs 3000 bytes the transport uploads as Block1 blocks, read back whole \
548 by coap-client; blockwise_upload_test.rs does the same against aiocoap's \
549 server and against a hand-written server that lowers the block size. Not \
550 #[ignore]d; a missing coap-server or coap-client fails the test. \
551 transport_test.rs drives retransmission, the Block2 cap and order, the \
552 oversize drop, deduplication, RST and the exchange cap against hand-written \
553 servers.",
554 )
555 .notes(
556 "No DTLS (coaps), no CoAP over TCP/WebSocket, no multicast. A request body is \
557 at most 64 KiB, uploaded in Block1 blocks over block_size. A notification \
558 over one block is reported truncated rather than fetched. Payloads reach the \
559 model as text or parsed JSON; a non-UTF-8 payload is reported by size only, \
560 never re-encoded.",
561 )
562 .build()
563 }
564
565 fn description(&self) -> &'static str {
566 "CoAP client for reading, writing and observing resources on constrained devices"
567 }
568
569 fn example_prompt(&self) -> &'static str {
570 "Connect to the CoAP server at localhost:5683, list its resources and observe /time"
571 }
572
573 fn group_name(&self) -> &'static str {
574 "Application"
575 }
576
577 fn get_startup_examples(&self) -> crate::llm::actions::StartupExamples {
578 use crate::llm::actions::StartupExamples;
579
580 StartupExamples::new(
581 json!({
582 "type": "open_client",
583 "remote_addr": "localhost:5683",
584 "base_stack": "coap",
585 "instruction": "List the server's resources and read each one"
586 }),
587 json!({
588 "type": "open_client",
589 "remote_addr": "localhost:5683",
590 "base_stack": "coap",
591 "event_handlers": [{
592 "event_pattern": "coap_notification",
593 "handler": {
594 "type": "script",
595 "language": "python",
596 "code": "<coap_client_handler>"
597 }
598 }]
599 }),
600 json!({
601 "type": "open_client",
602 "remote_addr": "localhost:5683",
603 "base_stack": "coap",
604 "event_handlers": [
605 {
606 "event_pattern": "coap_connected",
607 "handler": {
608 "type": "static",
609 "actions": [{"type": "coap_get", "path": "/.well-known/core"}]
610 }
611 },
612 {
613 "event_pattern": "coap_response",
614 "handler": {"type": "static", "actions": [{"type": "disconnect"}]}
615 }
616 ]
617 }),
618 )
619 }
620}
621
622impl Client for CoapClientProtocol {
623 fn connect(
624 &self,
625 ctx: crate::protocol::ConnectContext,
626 ) -> std::pin::Pin<
627 Box<dyn std::future::Future<Output = anyhow::Result<std::net::SocketAddr>> + Send>,
628 > {
629 Box::pin(async move {
630 let (ack_timeout_ms, max_retransmit, block_size) = match ctx.startup_params.as_ref() {
631 Some(p) => (
632 p.get_optional_u64("ack_timeout_ms")?
633 .unwrap_or(DEFAULT_ACK_TIMEOUT_MS),
634 p.get_optional_u64("max_retransmit")?
635 .unwrap_or(DEFAULT_MAX_RETRANSMIT),
636 p.get_optional_u64("block_size")?
637 .unwrap_or(DEFAULT_BLOCK_SIZE),
638 ),
639 None => (
640 DEFAULT_ACK_TIMEOUT_MS,
641 DEFAULT_MAX_RETRANSMIT,
642 DEFAULT_BLOCK_SIZE,
643 ),
644 };
645 let block_szx = (0..=crate::server::coap::codec::MAX_SZX)
646 .find(|szx| crate::server::coap::codec::szx_size(*szx) as u64 == block_size)
647 .ok_or_else(|| {
648 anyhow!("block_size {block_size} is not a power of two from 16 to 1024")
649 })?;
650 if !(10..=60_000).contains(&ack_timeout_ms) {
651 return Err(anyhow!(
652 "ack_timeout_ms {ack_timeout_ms} is outside 10-60000"
653 ));
654 }
655 if max_retransmit > 8 {
656 return Err(anyhow!("max_retransmit {max_retransmit} is over 8"));
657 }
658 crate::client::coap::CoapClient::connect_with_llm_actions(
659 ctx.remote_addr,
660 crate::client::coap::Reliability {
661 ack_timeout: std::time::Duration::from_millis(ack_timeout_ms),
662 max_retransmit: u32::try_from(max_retransmit).unwrap_or(4),
664 block_szx,
665 },
666 ctx.llm_client,
667 ctx.state,
668 ctx.status_tx,
669 ctx.client_id,
670 )
671 .await
672 })
673 }
674
675 fn execute_action(&self, action: Value) -> Result<ClientActionResult> {
676 match request_from_action(&action)? {
677 None => Ok(ClientActionResult::Disconnect),
678 Some(_) => Ok(ClientActionResult::Custom {
679 name: REQUEST_RESULT.to_string(),
680 data: action,
681 }),
682 }
683 }
684}