telepath_wire/lib.rs
1//! Telepath wire protocol types.
2//!
3//! This crate is `no_std` and `no alloc`. It defines the shared types used by
4//! both the firmware-side (`telepath-firmware`) and host-side (`telepath-host`)
5//! libraries. All types must remain free of heap allocation.
6//!
7//! # Protocol overview
8//!
9//! - Framing: COBS downstream (Host→Target), rzCOBS upstream (Target→Host); 0x00 delimiter both directions
10//! - Serialization: postcard (little-endian, varint-compressed)
11//! - Packet type discriminant: 2-valued (Request / Response), per ONC RPC CALL/REPLY model
12//! - Errors: expressed via `ResponseStatus`, not as a separate packet type
13//! - Discovery: reserved CmdID 0x0000 (CoAP Empty / ONC RPC convention)
14#![no_std]
15
16pub mod cmd_id;
17pub mod framing;
18#[cfg(feature = "profile")]
19pub mod metrics;
20#[cfg(feature = "profile")]
21pub use metrics::MetricsSnapshot;
22
23use serde::{Deserialize, Serialize};
24
25// ---------------------------------------------------------------------------
26// Constants
27// ---------------------------------------------------------------------------
28
29/// Maximum payload size in bytes. Both sides MUST enforce this limit.
30pub const MAX_PAYLOAD_SIZE: usize = 256;
31
32/// Reserved command ID for the Command Discovery Protocol (CDP).
33///
34/// Sending a `Request` with this ID causes the target to reply with its full
35/// command registry. Modeled after RFC 7252 CoAP Code 0.00 (Empty) and ONC RPC
36/// NULL procedure convention.
37pub const CMD_ID_DISCOVERY: u16 = 0x0000;
38
39/// Reserved command ID for the framing/throughput metrics snapshot.
40///
41/// Sending a `Request` with this ID causes the target to reply with the current
42/// [`MetricsSnapshot`] and atomically reset all counters. Only present when the
43/// target is built with the `profile` feature; without it the command simply
44/// does not exist and the host receives `SystemError`.
45pub const CMD_ID_METRICS: u16 = 0xFFFE;
46
47// ---------------------------------------------------------------------------
48// PacketType
49// ---------------------------------------------------------------------------
50
51/// Wire-level packet type discriminant.
52///
53/// Only two variants exist (Request / Response), following the ONC RPC
54/// RFC 5531 CALL/REPLY model. Error information is carried inside a
55/// `Response` via [`ResponseStatus`], not as a distinct packet type.
56#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
57#[repr(u8)]
58pub enum PacketType {
59 /// A call from host to target (CALL in ONC RPC terminology).
60 Request = 0x01,
61 /// A reply from target to host (REPLY in ONC RPC terminology).
62 Response = 0x02,
63}
64
65// ---------------------------------------------------------------------------
66// ResponseStatus
67// ---------------------------------------------------------------------------
68
69/// Status code carried inside a [`Response`] packet.
70///
71/// Using a dedicated status field (rather than a separate packet type) keeps
72/// the framing layer simple and mirrors HTTP status code semantics.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
74#[repr(u8)]
75pub enum ResponseStatus {
76 /// The command executed successfully.
77 Ok = 0x00,
78 /// The command returned a user-defined application error.
79 AppError = 0x01,
80 /// A system-level error occurred (e.g., unknown CmdID, deserialize failure).
81 SystemError = 0x02,
82}
83
84// ---------------------------------------------------------------------------
85// Request / Response packets
86// ---------------------------------------------------------------------------
87
88/// An RPC call from host to target.
89///
90/// The `args` field is a postcard-serialized argument struct. Its schema is
91/// identified by `cmd_id`, which encodes the function name and argument types
92/// as a hash (computed at firmware build time).
93#[derive(Debug, Clone, Serialize, Deserialize)]
94pub struct Request<'a> {
95 /// Packet kind — always [`PacketType::Request`] on the wire.
96 pub kind: PacketType,
97 /// Monotonically increasing sequence number for matching responses to calls.
98 pub seq_no: u16,
99 /// Command identifier: hash of (function name + input schema + output schema).
100 pub cmd_id: u16,
101 /// Postcard-serialized argument bytes. Lifetime tied to the receive buffer.
102 #[serde(borrow)]
103 pub args: &'a [u8],
104}
105
106/// An RPC reply from target to host.
107///
108/// On success (`status == Ok`) `payload` contains the postcard-serialized
109/// return value. On error it contains a postcard-serialized error description.
110#[derive(Debug, Clone, Serialize, Deserialize)]
111pub struct Response<'a> {
112 /// Packet kind — always [`PacketType::Response`] on the wire.
113 pub kind: PacketType,
114 /// Matches the `seq_no` of the originating [`Request`].
115 pub seq_no: u16,
116 /// Execution outcome.
117 pub status: ResponseStatus,
118 /// Postcard-serialized return value or error payload.
119 #[serde(borrow)]
120 pub payload: &'a [u8],
121}
122
123// ---------------------------------------------------------------------------
124// DiscoveryEntry
125// ---------------------------------------------------------------------------
126
127/// A single entry returned by the Command Discovery Protocol (CmdID 0x0000).
128///
129/// The firmware serializes all registered commands as a postcard sequence of
130/// these entries in response to a CDP request.
131#[derive(Debug, Clone, Serialize, Deserialize)]
132pub struct DiscoveryEntry<'a> {
133 /// 16-bit command ID derived by [`telepath_wire::cmd_id::derive_cmd_id`].
134 pub id: u16,
135 /// Rust function name of the registered command.
136 #[serde(borrow)]
137 pub name: &'a str,
138 /// Postcard-serialized `postcard_schema::schema::NamedType` for the
139 /// argument tuple. Opaque bytes; decode with `postcard_schema` on the host.
140 #[serde(borrow)]
141 pub args_schema: &'a [u8],
142 /// Postcard-serialized `postcard_schema::schema::NamedType` for the
143 /// return type.
144 #[serde(borrow)]
145 pub ret_schema: &'a [u8],
146 /// Comma-separated argument names, e.g. `"a,b"` for `fn foo(a: i32, b: i32)`.
147 /// Empty string for zero-argument commands.
148 #[serde(borrow)]
149 pub arg_names: &'a str,
150}
151
152// ---------------------------------------------------------------------------
153// Discovery paging types (CmdID 0x0000 with offset-based pagination)
154// ---------------------------------------------------------------------------
155
156/// Request payload for the Command Discovery Protocol when pagination is needed.
157///
158/// Empty `args` (legacy) is treated as `DiscoveryRequest { offset: 0 }` by
159/// the firmware for backward compatibility. Non-empty args must deserialize
160/// to this type.
161#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
162pub struct DiscoveryRequest {
163 /// Index of the first entry to include in this response page.
164 pub offset: u16,
165}
166
167/// Response payload for a paged Command Discovery Protocol response.
168///
169/// `entries` carries a raw postcard sequence: `varint(count) ++ DiscoveryEntry × count`.
170/// The host iterates pages until `offset + count_this_page >= total`.
171#[derive(Debug, Clone, Serialize, Deserialize)]
172pub struct DiscoveryPage<'a> {
173 /// Total number of registered commands (across all pages).
174 pub total: u16,
175 /// Offset this page starts at (echoes the request's `offset` field).
176 pub offset: u16,
177 /// Serialized `varint(count) ++ DiscoveryEntry × count` for entries in
178 /// this page. Opaque to this crate; parse with `postcard::take_from_bytes`.
179 #[serde(borrow)]
180 pub entries: &'a [u8],
181}
182
183// ---------------------------------------------------------------------------
184// AppError payload
185// ---------------------------------------------------------------------------
186
187/// Payload carried inside a [`Response`] when `status == ResponseStatus::AppError`.
188///
189/// Encoded as postcard `(varint(code), varint(len), len-bytes UTF-8 message)`.
190/// Borrows the message slice from the receive buffer for zero-copy decode on
191/// targets that cannot allocate.
192///
193/// # Wire layout
194///
195/// | Field | Type | Encoding |
196/// |-------|------|----------|
197/// | `code` | `u16` | postcard varint (1–3 bytes) |
198/// | `message` | `&str` | postcard varint(len) + UTF-8 bytes |
199///
200/// The `code` namespace is application-defined. Reserve `0` as a catch-all
201/// "unspecified application error" when no finer classification is needed.
202#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
203pub struct AppErrorPayload<'a> {
204 /// Application-defined error code.
205 pub code: u16,
206 /// Human-readable error message, borrowed from the receive buffer.
207 #[serde(borrow)]
208 pub message: &'a str,
209}
210
211/// Encode an [`AppErrorPayload`] into `out`, returning the number of bytes written.
212///
213/// # Errors
214///
215/// Returns [`WireError::SerdeError`] if the payload does not fit in `out`.
216pub fn encode_app_error(payload: &AppErrorPayload<'_>, out: &mut [u8]) -> Result<usize, WireError> {
217 let written = postcard::to_slice(payload, out)?;
218 Ok(written.len())
219}
220
221/// Decode an [`AppErrorPayload`] from `bytes`, borrowing the message slice.
222///
223/// # Errors
224///
225/// Returns [`WireError::SerdeError`] if `bytes` is malformed.
226pub fn decode_app_error(bytes: &[u8]) -> Result<AppErrorPayload<'_>, WireError> {
227 Ok(postcard::from_bytes(bytes)?)
228}
229
230// ---------------------------------------------------------------------------
231// WireError
232// ---------------------------------------------------------------------------
233
234/// Errors that can arise during wire-level encoding or decoding.
235#[derive(Debug, Clone, PartialEq, Eq)]
236pub enum WireError {
237 /// Payload exceeded [`MAX_PAYLOAD_SIZE`].
238 PayloadTooLarge,
239 /// postcard serialization / deserialization failed; carries the underlying cause.
240 SerdeError(postcard::Error),
241 /// A reserved or unknown packet type discriminant was received.
242 UnknownPacketType,
243 /// The framing delimiter was encountered in an unexpected position.
244 FramingError,
245}
246
247impl From<postcard::Error> for WireError {
248 fn from(e: postcard::Error) -> Self {
249 WireError::SerdeError(e)
250 }
251}
252
253// ---------------------------------------------------------------------------
254// Tests
255// ---------------------------------------------------------------------------
256
257#[cfg(test)]
258mod tests {
259 use super::*;
260
261 #[test]
262 fn packet_type_discriminants() {
263 assert_eq!(PacketType::Request as u8, 0x01);
264 assert_eq!(PacketType::Response as u8, 0x02);
265 }
266
267 #[test]
268 fn response_status_discriminants() {
269 assert_eq!(ResponseStatus::Ok as u8, 0x00);
270 assert_eq!(ResponseStatus::AppError as u8, 0x01);
271 assert_eq!(ResponseStatus::SystemError as u8, 0x02);
272 }
273
274 #[test]
275 fn cmd_id_discovery_is_zero() {
276 assert_eq!(CMD_ID_DISCOVERY, 0x0000);
277 }
278
279 #[test]
280 fn max_payload_size() {
281 assert_eq!(MAX_PAYLOAD_SIZE, 256);
282 }
283
284 #[test]
285 fn wire_error_from_postcard_error() {
286 let pe = postcard::from_bytes::<u32>(&[]).unwrap_err();
287 let we: WireError = pe.clone().into();
288 assert_eq!(we, WireError::SerdeError(pe));
289 }
290
291 #[test]
292 fn app_error_payload_roundtrip() {
293 let original = AppErrorPayload {
294 code: 42,
295 message: "sensor not ready",
296 };
297 let mut buf = [0u8; 64];
298 let n = encode_app_error(&original, &mut buf).expect("encode failed");
299 let decoded = decode_app_error(&buf[..n]).expect("decode failed");
300 assert_eq!(decoded.code, original.code);
301 assert_eq!(decoded.message, original.message);
302 }
303
304 #[test]
305 fn app_error_payload_wire_layout() {
306 // code=42 (0x2A, 1 varint byte), message="hi" (len=2, bytes 0x68 0x69)
307 let payload = AppErrorPayload {
308 code: 42,
309 message: "hi",
310 };
311 let mut buf = [0u8; 16];
312 let n = encode_app_error(&payload, &mut buf).expect("encode failed");
313 assert_eq!(&buf[..n], &[0x2A, 0x02, b'h', b'i']);
314 }
315
316 #[test]
317 fn app_error_payload_buffer_too_small() {
318 let payload = AppErrorPayload {
319 code: 42,
320 message: "hi",
321 };
322 let mut buf = [0u8; 2]; // too small (needs 4 bytes)
323 let err = encode_app_error(&payload, &mut buf).unwrap_err();
324 assert!(matches!(err, WireError::SerdeError(_)));
325 }
326}