Skip to main content

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}