refactor: replace postcard/serde with fixed-size LE wire format

Drop postcard and serde dependencies entirely. All integers are now
fixed-width little-endian on the wire — no varints, no zigzag encoding.
A developer sending tx_power_dbm=14 sees 0x0E on the wire. No surprises.

- RadioConfig: 10 bytes fixed (freq:4 LE, bw:1, sf:1, cr:1, sync:2 LE, pwr:1)
- Commands/Responses: tag byte + fixed-size fields
- Vec<u8>: 2-byte LE length prefix + raw bytes
- Option<T>: 1-byte (0=None, 1=Some) + T
- COBS framing preserved (just byte-stuffing, no encoding tricks)
- Manual COBS decode replaces postcard's CobsAccumulator
- Removes ~15KB from firmware binary (no serde monomorphization)
This commit is contained in:
swaits committed 2026-03-29 20:42:58 -06:00
1 parent 584524dace
commit 1646564bc1
4 files changed
+233 -63

No files matched your search

+145 -18
View File
@@ -1,11 +1,21 @@
//! Wire protocol types and fixed-size little-endian serialization.
//!
//! Every integer is fixed-width LE. No varints, no zigzag.
//! See `docs/PROTOCOL.md` for the complete specification.
use heapless::Vec;
use serde::{Deserialize, Serialize};
/// Maximum LoRa payload size in bytes.
pub const MAX_PAYLOAD: usize = 256;
/// RadioConfig wire size (fixed).
pub const RADIO_CONFIG_SIZE: usize = 10;
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
/// LoRa signal bandwidth.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
#[repr(u8)]
pub enum Bandwidth {
Khz7 = 0,
@@ -20,11 +30,31 @@ pub enum Bandwidth {
Khz500 = 9,
}
/// Sentinel value for `tx_power_dbm`: use the board's maximum TX power.
pub const TX_POWER_MAX: i8 = i8::MIN; // -128 on the wire
impl Bandwidth {
fn from_u8(v: u8) -> Option<Self> {
match v {
0 => Some(Self::Khz7),
1 => Some(Self::Khz10),
2 => Some(Self::Khz15),
3 => Some(Self::Khz20),
4 => Some(Self::Khz31),
5 => Some(Self::Khz41),
6 => Some(Self::Khz62),
7 => Some(Self::Khz125),
8 => Some(Self::Khz250),
9 => Some(Self::Khz500),
_ => None,
}
}
}
/// Complete LoRa radio configuration.
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
///
/// Wire layout (10 bytes, all little-endian):
/// ```text
/// [freq_hz:4] [bw:1] [sf:1] [cr:1] [sync_word:2] [tx_power_dbm:1]
/// ```
#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
pub struct RadioConfig {
/// Frequency in Hz (150_000_000 - 960_000_000 for SX1262).
pub freq_hz: u32,
@@ -35,15 +65,12 @@ pub struct RadioConfig {
pub cr: u8,
pub sync_word: u16,
/// Transmit power in dBm. Set to [`TX_POWER_MAX`] (-128) for the
/// board's maximum. Actual range is board-dependent (e.g. -9 to +22
/// for SX1262). Values above the board max are clamped by the radio
/// driver; values below the board min are clamped too.
/// board's maximum.
pub tx_power_dbm: i8,
}
impl RadioConfig {
/// Validate fields against hardware limits. `power_range` is the
/// board's (min, max) TX power in dBm.
/// Validate fields against hardware limits.
pub fn validate(&self, power_range: (i8, i8)) -> Result<(), &'static str> {
if !(150_000_000..=960_000_000).contains(&self.freq_hz) {
return Err("frequency out of range (150-960 MHz)");
@@ -54,7 +81,6 @@ impl RadioConfig {
if !(5..=8).contains(&self.cr) {
return Err("coding rate out of range (5-8)");
}
// TX_POWER_MAX sentinel is always valid (resolved to board max later)
if self.tx_power_dbm != TX_POWER_MAX
&& !(power_range.0..=power_range.1).contains(&self.tx_power_dbm)
{
@@ -70,12 +96,37 @@ impl RadioConfig {
}
self
}
/// Serialize to fixed-size LE bytes. Returns number of bytes written (always 10).
pub fn to_bytes(&self, buf: &mut [u8]) -> usize {
buf[0..4].copy_from_slice(&self.freq_hz.to_le_bytes());
buf[4] = self.bw as u8;
buf[5] = self.sf;
buf[6] = self.cr;
buf[7..9].copy_from_slice(&self.sync_word.to_le_bytes());
buf[9] = self.tx_power_dbm as u8;
RADIO_CONFIG_SIZE
}
/// Deserialize from fixed-size LE bytes.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
if buf.len() < RADIO_CONFIG_SIZE {
return None;
}
Some(Self {
freq_hz: u32::from_le_bytes(buf[0..4].try_into().ok()?),
bw: Bandwidth::from_u8(buf[4])?,
sf: buf[5],
cr: buf[6],
sync_word: u16::from_le_bytes(buf[7..9].try_into().ok()?),
tx_power_dbm: buf[9] as i8,
})
}
}
/// Host → firmware commands.
// Payload variant is intentionally large (inline heapless::Vec) — no allocator available.
#[allow(clippy::large_enum_variant)]
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[derive(Debug, PartialEq)]
pub enum Command {
Ping,
GetConfig,
@@ -90,10 +141,51 @@ pub enum Command {
DisplayOff,
}
impl Command {
/// Deserialize from a COBS-decoded frame.
pub fn from_bytes(buf: &[u8]) -> Option<Self> {
let tag = *buf.first()?;
let rest = &buf[1..];
match tag {
0 => Some(Self::Ping),
1 => Some(Self::GetConfig),
2 => Some(Self::SetConfig(RadioConfig::from_bytes(rest)?)),
3 => Some(Self::StartRx),
4 => Some(Self::StopRx),
5 => {
// Transmit: has_config:u8 + [RadioConfig if 1] + len:u16 LE + payload
if rest.is_empty() {
return None;
}
let (config, pos) = if rest[0] == 0 {
(None, 1)
} else if rest[0] == 1 && rest.len() >= 1 + RADIO_CONFIG_SIZE {
(Some(RadioConfig::from_bytes(&rest[1..])?), 1 + RADIO_CONFIG_SIZE)
} else {
return None;
};
if rest.len() < pos + 2 {
return None;
}
let len = u16::from_le_bytes(rest[pos..pos + 2].try_into().ok()?) as usize;
let data_start = pos + 2;
if rest.len() < data_start + len {
return None;
}
let mut payload = Vec::new();
let _ = payload.extend_from_slice(&rest[data_start..data_start + len]);
Some(Self::Transmit { config, payload })
}
6 => Some(Self::DisplayOn),
7 => Some(Self::DisplayOff),
_ => None,
}
}
}
/// Firmware → host responses.
// Payload variant is intentionally large (inline heapless::Vec) — no allocator available.
#[allow(clippy::large_enum_variant)]
#[derive(Serialize, Deserialize, Debug, PartialEq)]
#[derive(Debug, PartialEq)]
pub enum Response {
Pong,
Config(RadioConfig),
@@ -107,10 +199,45 @@ pub enum Response {
Error(ErrorCode),
}
impl Response {
/// Serialize to fixed-size LE bytes. Returns number of bytes written.
pub fn to_bytes(&self, buf: &mut [u8]) -> usize {
match self {
Self::Pong => {
buf[0] = 0;
1
}
Self::Config(cfg) => {
buf[0] = 1;
1 + cfg.to_bytes(&mut buf[1..])
}
Self::RxPacket { rssi, snr, payload } => {
buf[0] = 2;
buf[1..3].copy_from_slice(&rssi.to_le_bytes());
buf[3..5].copy_from_slice(&snr.to_le_bytes());
buf[5..7].copy_from_slice(&(payload.len() as u16).to_le_bytes());
buf[7..7 + payload.len()].copy_from_slice(payload);
7 + payload.len()
}
Self::TxDone => {
buf[0] = 3;
1
}
Self::Ok => {
buf[0] = 4;
1
}
Self::Error(code) => {
buf[0] = 5;
buf[1] = *code as u8;
2
}
}
}
}
/// Error codes reported to the host.
///
/// Variant indices match postcard wire encoding (0-based).
#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
#[derive(Debug, Clone, Copy, PartialEq, Eq, defmt::Format)]
#[repr(u8)]
pub enum ErrorCode {
InvalidConfig = 0,