Skip to main content

telepath_server/
lib.rs

1//! Target-side Telepath library.
2//!
3//! Runs on the MCU in `no_std` mode. Provides:
4//! - [`TelepathServer`]: receive loop, COBS decode, dispatch, rzCOBS encode
5//! - [`transport::Transport`]: non-blocking byte-stream I/O trait
6//! - Re-export of `#[command]` attribute macro
7//!
8//! # Architecture
9//!
10//! ```text
11//! Transport → FrameAccumulator → cobs_decode    → postcard::from_bytes → Dispatcher
12//!                                                                       → postcard::to_slice → rzcobs_encode → Transport
13//! ```
14//!
15//! # Usage
16//!
17//! ```rust,ignore
18//! use telepath_server::{TelepathServer, command};
19//!
20//! #[command]
21//! fn set_led(id: u8, brightness: u16) -> Result<(), ()> {
22//!     Ok(())
23//! }
24//!
25//! let mut server = TelepathServer::<_, 512>::new(transport, telepath_server::commands());
26//! loop { server.poll(); }
27//! ```
28#![no_std]
29
30pub mod transport;
31
32mod resource;
33pub use resource::ResourceRegistry;
34
35#[cfg(feature = "profile")]
36pub mod profile;
37#[cfg(feature = "profile")]
38pub use profile::init_dwt;
39
40pub use telepath_macros::command;
41use telepath_wire::{
42    framing::{cobs_decode, rzcobs_encode, FrameAccumulator},
43    Request, Response,
44};
45pub use telepath_wire::{
46    PacketType, ResponseStatus, WireError, CMD_ID_DISCOVERY, MAX_PAYLOAD_SIZE,
47};
48
49// Re-exported for use in code generated by the #[command] macro so that callers
50// only need `telepath-firmware` and `postcard` as direct dependencies.
51#[doc(hidden)]
52pub use linkme as __linkme;
53#[doc(hidden)]
54pub use postcard_schema as __postcard_schema;
55#[doc(hidden)]
56pub use telepath_wire::cmd_id::derive_cmd_id as __derive_cmd_id;
57
58// ---------------------------------------------------------------------------
59// CommandMetadata
60// ---------------------------------------------------------------------------
61
62/// Type-erased shim function signature.
63///
64/// Receives a postcard-serialized argument slice, writes a postcard-serialized
65/// result into `output`, and returns the number of bytes written. The
66/// `resources` parameter provides access to injected `#[resource]` values.
67pub type ShimFn = fn(
68    input: &[u8],
69    output: &mut [u8],
70    resources: &ResourceRegistry,
71) -> Result<usize, DispatchError>;
72
73/// Type alias for schema-writer function pointers.
74///
75/// Writes a postcard-serialized `postcard_schema::schema::NamedType` into `out`
76/// and returns the number of bytes written.
77pub type SchemaFn = fn(out: &mut [u8]) -> Result<usize, ()>;
78
79/// Static metadata for a single registered RPC command.
80///
81/// Populated by the `#[command]` macro at compile time and collected into
82/// [`TELEPATH_COMMANDS`] via `linkme` distributed slices.
83#[derive(Clone, Copy)]
84pub struct CommandMetadata {
85    /// Human-readable function name (used for discovery).
86    pub name: &'static str,
87    /// Command ID: hash of (name + input schema + output schema).
88    /// Computed at firmware build time for deterministic matching.
89    pub id: u16,
90    /// Type-erased shim that deserializes args, calls the function, and
91    /// serializes the result.
92    pub invoke: ShimFn,
93    /// Writes the postcard-encoded args-tuple `NamedType` schema into the
94    /// provided buffer. Returns the byte count written.
95    pub args_schema: SchemaFn,
96    /// Writes the postcard-encoded return-type `NamedType` schema into the
97    /// provided buffer. Returns the byte count written.
98    pub ret_schema: SchemaFn,
99    /// Comma-separated argument names, e.g. `"a,b"` for `fn foo(a: i32, b: i32)`.
100    /// Empty string for zero-argument commands.
101    pub arg_names: &'static str,
102}
103
104/// All commands registered via `#[command]`, collected at link time.
105///
106/// Use [`commands()`] to access this as a `&'static [CommandMetadata]`.
107#[linkme::distributed_slice]
108pub static TELEPATH_COMMANDS: [CommandMetadata] = [..];
109
110/// Returns the complete set of commands registered by `#[command]`.
111pub fn commands() -> &'static [CommandMetadata] {
112    &TELEPATH_COMMANDS
113}
114
115// ---------------------------------------------------------------------------
116// DispatchError
117// ---------------------------------------------------------------------------
118
119/// Errors that can occur during command dispatch.
120#[derive(Debug, Clone, Copy, PartialEq, Eq)]
121pub enum DispatchError {
122    /// No command with the given ID was found in the registry.
123    UnknownCommand,
124    /// Argument deserialization failed (malformed or truncated payload).
125    DeserializeError,
126    /// Result serialization failed (output buffer too small).
127    SerializeError,
128    /// The request payload exceeded [`MAX_PAYLOAD_SIZE`].
129    PayloadTooLarge,
130    /// A `#[resource]`-annotated argument could not be resolved from the
131    /// server's [`ResourceRegistry`].
132    ResourceUnavailable,
133}
134
135// ---------------------------------------------------------------------------
136// TelepathServer
137// ---------------------------------------------------------------------------
138
139/// RPC server that runs on the target MCU.
140///
141/// `T` is the transport type implementing [`transport::Transport`].
142/// `N` is the size of the internal receive accumulator and transmit buffers.
143///
144/// # Type parameter guidance
145///
146/// Choose `N` ≥ 512 to accommodate a max-payload frame with COBS overhead.
147pub struct TelepathServer<T, const N: usize> {
148    transport: T,
149    rx_accum: FrameAccumulator<N>,
150    tx_buf: [u8; N],
151    /// Command registry slice. Pass `telepath_server::commands()` for the
152    /// full linkme-populated registry, or a manual slice for testing.
153    commands: &'static [CommandMetadata],
154    /// Type-keyed resource registry for `#[resource]` injection.
155    resources: ResourceRegistry,
156}
157
158impl<T, const N: usize> TelepathServer<T, N> {
159    /// Create a new server with the given transport and command registry.
160    pub fn new(transport: T, commands: &'static [CommandMetadata]) -> Self {
161        #[cfg(feature = "profile")]
162        profile::init_dwt();
163        Self {
164            transport,
165            rx_accum: FrameAccumulator::new(),
166            tx_buf: [0u8; N],
167            commands,
168            resources: ResourceRegistry::new(),
169        }
170    }
171
172    /// Register a resource for `#[resource]` injection.
173    ///
174    /// The value is moved into the server's internal registry and made
175    /// available to command shims that declare a matching `#[resource]`
176    /// parameter.
177    pub fn resource<R: 'static>(mut self, val: R) -> Self {
178        self.resources.insert(val);
179        self
180    }
181
182    /// Look up a command by its ID using linear scan.
183    ///
184    /// Linear scan is intentional: embedded command counts are typically ≤ 64,
185    /// making hash-map overhead unjustified.
186    pub fn find_command(&self, id: u16) -> Option<&CommandMetadata> {
187        self.commands.iter().find(|cmd| cmd.id == id)
188    }
189
190    /// Dispatch a pre-decoded payload slice to the matching command handler.
191    ///
192    /// Returns the number of bytes written to `output` on success.
193    pub fn dispatch(
194        &mut self,
195        cmd_id: u16,
196        input: &[u8],
197        output: &mut [u8],
198    ) -> Result<usize, DispatchError> {
199        if cmd_id == telepath_wire::CMD_ID_DISCOVERY {
200            return self.handle_discovery(input, output);
201        }
202        let cmd = self
203            .find_command(cmd_id)
204            .ok_or(DispatchError::UnknownCommand)?;
205        (cmd.invoke)(input, output, &self.resources)
206    }
207
208    /// Handle a Discovery request (CmdID 0x0000) with offset-based pagination.
209    ///
210    /// Builds a [`telepath_wire::DiscoveryPage`] containing entries starting at
211    /// `request.offset`, limited by `MAX_PAYLOAD_SIZE`. Each entry includes
212    /// postcard-serialized schema bytes for the argument tuple and return type.
213    ///
214    /// Empty `input` is treated as `offset=0` for backward compatibility with
215    /// hosts that send raw discovery requests without a `DiscoveryRequest` payload.
216    fn handle_discovery(&self, input: &[u8], output: &mut [u8]) -> Result<usize, DispatchError> {
217        use telepath_wire::{DiscoveryPage, DiscoveryRequest};
218
219        let offset = if input.is_empty() {
220            0u16
221        } else {
222            postcard::from_bytes::<DiscoveryRequest>(input)
223                .map_err(|_| DispatchError::DeserializeError)?
224                .offset
225        };
226
227        let total = self
228            .commands
229            .iter()
230            .filter(|c| c.id != telepath_wire::CMD_ID_DISCOVERY)
231            .count() as u16;
232
233        // DiscoveryPage header overhead: ≤3 B for total (u16 varint) +
234        // ≤3 B for offset (u16 varint) + ≤5 B for the entries-slice length
235        // varint. 16 B is a conservative bound; derived from MAX_PAYLOAD_SIZE
236        // so the entries budget updates automatically if the limit changes.
237        const PAGE_HEADER_BUDGET: usize = 16;
238        const ENTRIES_RAW_MAX: usize = MAX_PAYLOAD_SIZE - PAGE_HEADER_BUDGET;
239
240        let mut raw_entries = [0u8; ENTRIES_RAW_MAX];
241        let mut raw_cursor = 0usize;
242        let mut page_count = 0u32;
243
244        // Upper bound on postcard_schema::schema::NamedType bytes for a single
245        // command schema. Measured empirically: typical primitive schemas are
246        // 20–60 bytes. 128 bytes gives ~2× headroom for deeply nested types.
247        // Exceeding this limit returns SerializeError at discovery time.
248        const SCHEMA_SCRATCH_LEN: usize = 128;
249
250        // Per-entry scratch buffers; reused each iteration.
251        let mut args_scratch = [0u8; SCHEMA_SCRATCH_LEN];
252        let mut ret_scratch = [0u8; SCHEMA_SCRATCH_LEN];
253
254        let iter = self
255            .commands
256            .iter()
257            .filter(|c| c.id != telepath_wire::CMD_ID_DISCOVERY)
258            .skip(offset as usize);
259
260        for cmd in iter {
261            let n_args =
262                (cmd.args_schema)(&mut args_scratch).map_err(|_| DispatchError::SerializeError)?;
263            let n_ret =
264                (cmd.ret_schema)(&mut ret_scratch).map_err(|_| DispatchError::SerializeError)?;
265            let entry = telepath_wire::DiscoveryEntry {
266                id: cmd.id,
267                name: cmd.name,
268                args_schema: &args_scratch[..n_args],
269                ret_schema: &ret_scratch[..n_ret],
270                arg_names: cmd.arg_names,
271            };
272            // Pre-measure the entry by serializing into a temp scratch.
273            let mut entry_tmp = [0u8; 300];
274            let entry_bytes = postcard::to_slice(&entry, &mut entry_tmp)
275                .map_err(|_| DispatchError::SerializeError)?;
276            let entry_size = entry_bytes.len();
277
278            if raw_cursor + entry_size > ENTRIES_RAW_MAX {
279                if raw_cursor == 0 {
280                    // This entry alone exceeds the page budget; it can never
281                    // fit regardless of paging. Signal a hard error so the host
282                    // receives a SystemError instead of an infinite stall.
283                    return Err(DispatchError::SerializeError);
284                }
285                break; // page is full — more entries on next page
286            }
287            raw_entries[raw_cursor..raw_cursor + entry_size].copy_from_slice(entry_bytes);
288            raw_cursor += entry_size;
289            page_count += 1;
290        }
291
292        // Build the entries field: varint(count) ++ raw_entries[..raw_cursor].
293        let mut entries_combined = [0u8; ENTRIES_RAW_MAX + 5];
294        let cnt_bytes = postcard::to_slice(&page_count, &mut entries_combined)
295            .map_err(|_| DispatchError::SerializeError)?;
296        let cnt_len = cnt_bytes.len();
297        entries_combined[cnt_len..cnt_len + raw_cursor].copy_from_slice(&raw_entries[..raw_cursor]);
298        let entries_len = cnt_len + raw_cursor;
299
300        let page = DiscoveryPage {
301            total,
302            offset,
303            entries: &entries_combined[..entries_len],
304        };
305        let written =
306            postcard::to_slice(&page, output).map_err(|_| DispatchError::SerializeError)?;
307        Ok(written.len())
308    }
309}
310
311impl<T: transport::Transport, const N: usize> TelepathServer<T, N> {
312    /// Process any pending bytes from the transport.
313    ///
314    /// Call this in a tight loop. Reads all available bytes, accumulates them
315    /// into COBS frames, and sends a response for each complete request.
316    pub fn poll(&mut self) {
317        let mut byte = [0u8; 1];
318        loop {
319            let n = self.transport.read(&mut byte);
320            if n == 0 {
321                break;
322            }
323            if self.rx_accum.feed(byte[0]) {
324                self.process_frame();
325                self.rx_accum.reset();
326            }
327        }
328    }
329
330    /// Decode and dispatch a complete COBS frame, then encode and send the response.
331    fn process_frame(&mut self) {
332        let frame = match self.rx_accum.frame() {
333            Some(f) => f,
334            None => return,
335        };
336
337        // COBS decode into a stack buffer.
338        let mut decoded = [0u8; N];
339        #[cfg(feature = "profile")]
340        let t0 = profile::cycles_now();
341        let decoded_len = match cobs_decode(frame, &mut decoded) {
342            Ok(n) => n,
343            Err(_) => return,
344        };
345        #[cfg(feature = "profile")]
346        {
347            use core::sync::atomic::Ordering;
348            let dt = profile::cycles_now().wrapping_sub(t0) as u64;
349            profile::DECODE_CYCLES.fetch_add(dt, Ordering::Relaxed);
350            profile::DECODED_BYTES.fetch_add(decoded_len as u32, Ordering::Relaxed);
351        }
352
353        // Deserialize Request (args borrows from decoded[]).
354        let req: Request<'_> = match postcard::from_bytes(&decoded[..decoded_len]) {
355            Ok(r) => r,
356            Err(_) => return,
357        };
358
359        // Reject packets that are not properly typed as Request.
360        if req.kind != PacketType::Request {
361            return;
362        }
363
364        // Reject oversized argument payloads before dispatch.
365        if req.args.len() > MAX_PAYLOAD_SIZE {
366            return;
367        }
368
369        let seq_no = req.seq_no;
370        let cmd_id = req.cmd_id;
371        let args = req.args;
372
373        // Dispatch; clamp oversized return payloads to SystemError.
374        let mut payload_buf = [0u8; N];
375        let (status, payload_len) = match self.dispatch(cmd_id, args, &mut payload_buf) {
376            Ok(n) if n > MAX_PAYLOAD_SIZE => (ResponseStatus::SystemError, 0),
377            Ok(n) => (ResponseStatus::Ok, n),
378            Err(_) => (ResponseStatus::SystemError, 0),
379        };
380
381        // Build and serialize Response.
382        let resp = Response {
383            kind: PacketType::Response,
384            seq_no,
385            status,
386            payload: &payload_buf[..payload_len],
387        };
388        let mut serialized = [0u8; N];
389        let serialized_len = match postcard::to_slice(&resp, &mut serialized) {
390            Ok(s) => s.len(),
391            Err(_) => return,
392        };
393
394        // rzCOBS encode into tx_buf and write (upstream framing).
395        #[cfg(feature = "profile")]
396        let t1 = profile::cycles_now();
397        let n = match rzcobs_encode(&serialized[..serialized_len], &mut self.tx_buf) {
398            Ok(n) => n,
399            Err(_) => return,
400        };
401        #[cfg(feature = "profile")]
402        {
403            use core::sync::atomic::Ordering;
404            let dt = profile::cycles_now().wrapping_sub(t1) as u64;
405            profile::ENCODE_CYCLES.fetch_add(dt, Ordering::Relaxed);
406            profile::ENCODED_BYTES.fetch_add(serialized_len as u32, Ordering::Relaxed);
407            profile::SAMPLE_COUNT.fetch_add(1, Ordering::Relaxed);
408        }
409        self.transport.write(&self.tx_buf[..n]);
410    }
411}
412
413// ---------------------------------------------------------------------------
414// Tests
415// ---------------------------------------------------------------------------
416
417#[cfg(test)]
418mod tests {
419    extern crate std;
420    use super::*;
421
422    fn noop_shim(
423        _input: &[u8],
424        _output: &mut [u8],
425        _resources: &ResourceRegistry,
426    ) -> Result<usize, DispatchError> {
427        Ok(0)
428    }
429
430    fn noop_schema(_out: &mut [u8]) -> Result<usize, ()> {
431        Ok(0)
432    }
433
434    static TEST_COMMANDS: [CommandMetadata; 1] = [CommandMetadata {
435        name: "ping",
436        id: 0x0001,
437        invoke: noop_shim,
438        args_schema: noop_schema,
439        ret_schema: noop_schema,
440        arg_names: "",
441    }];
442
443    struct FakeTransport;
444
445    #[test]
446    fn find_known_command() {
447        let server = TelepathServer::<FakeTransport, 256>::new(FakeTransport, &TEST_COMMANDS);
448        assert!(server.find_command(0x0001).is_some());
449    }
450
451    #[test]
452    fn find_unknown_command_returns_none() {
453        let server = TelepathServer::<FakeTransport, 256>::new(FakeTransport, &TEST_COMMANDS);
454        assert!(server.find_command(0xFFFF).is_none());
455    }
456
457    #[test]
458    fn dispatch_unknown_returns_error() {
459        let mut server = TelepathServer::<FakeTransport, 256>::new(FakeTransport, &TEST_COMMANDS);
460        let mut out = [0u8; 256];
461        assert_eq!(
462            server.dispatch(0xFFFF, &[], &mut out),
463            Err(DispatchError::UnknownCommand)
464        );
465    }
466
467    // ---------------------------------------------------------------------------
468    // poll() integration test using a loopback transport
469    // ---------------------------------------------------------------------------
470
471    use telepath_wire::framing::{cobs_encode, rzcobs_decode};
472    use telepath_wire::{PacketType, Request, Response, ResponseStatus};
473
474    /// A ping shim that writes `0xDEADBEEFu32` as postcard to output.
475    fn ping_shim(
476        _input: &[u8],
477        output: &mut [u8],
478        _resources: &ResourceRegistry,
479    ) -> Result<usize, DispatchError> {
480        let val: u32 = 0xDEAD_BEEF;
481        let s = postcard::to_slice(&val, output).map_err(|_| DispatchError::SerializeError)?;
482        Ok(s.len())
483    }
484
485    static PING_COMMANDS: [CommandMetadata; 1] = [CommandMetadata {
486        name: "ping",
487        id: 0x0001,
488        invoke: ping_shim,
489        args_schema: noop_schema,
490        ret_schema: noop_schema,
491        arg_names: "",
492    }];
493
494    /// Loopback transport: bytes written are available for reading.
495    struct LoopbackTransport {
496        rx: std::vec::Vec<u8>,
497        tx: std::vec::Vec<u8>,
498    }
499
500    impl LoopbackTransport {
501        fn new(rx: std::vec::Vec<u8>) -> Self {
502            Self {
503                rx,
504                tx: std::vec::Vec::new(),
505            }
506        }
507    }
508
509    impl transport::Transport for LoopbackTransport {
510        fn read(&mut self, buf: &mut [u8]) -> usize {
511            if self.rx.is_empty() {
512                return 0;
513            }
514            let n = buf.len().min(self.rx.len());
515            buf[..n].copy_from_slice(&self.rx[..n]);
516            self.rx.drain(..n);
517            n
518        }
519
520        fn write(&mut self, buf: &[u8]) -> usize {
521            self.tx.extend_from_slice(buf);
522            buf.len()
523        }
524    }
525
526    #[test]
527    fn poll_ping_roundtrip() {
528        // Build a COBS-framed ping request.
529        let req = Request {
530            kind: PacketType::Request,
531            seq_no: 42,
532            cmd_id: 0x0001,
533            args: &[],
534        };
535        let mut ser_buf = [0u8; 64];
536        let serialized = postcard::to_slice(&req, &mut ser_buf).unwrap();
537        let mut framed = [0u8; 64];
538        let n = cobs_encode(serialized, &mut framed).unwrap();
539
540        let transport = LoopbackTransport::new(framed[..n].to_vec());
541        let mut server = TelepathServer::<LoopbackTransport, 512>::new(transport, &PING_COMMANDS);
542        server.poll();
543
544        // Decode the response from tx buffer.
545        let tx = &server.transport.tx;
546        assert!(!tx.is_empty(), "server must have written a response");
547
548        // Find the 0x00 delimiter.
549        let delim = tx
550            .iter()
551            .position(|&b| b == 0x00)
552            .expect("no frame delimiter");
553        let mut decoded = [0u8; 512];
554        let m = rzcobs_decode(&tx[..delim], &mut decoded).unwrap();
555
556        let resp: Response<'_> = postcard::from_bytes(&decoded[..m]).unwrap();
557        assert_eq!(resp.seq_no, 42);
558        assert_eq!(resp.status, ResponseStatus::Ok);
559
560        let val: u32 = postcard::from_bytes(resp.payload).unwrap();
561        assert_eq!(val, 0xDEAD_BEEF);
562    }
563}