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