feat: add display UI with splash screen, dashboard, and RSSI sparkline

Implement full SSD1306 OLED display support:
- Splash screen on boot (1.5s) with project name, version, board
- Dashboard showing radio state, config, counters, RSSI/SNR
- RSSI history sparkline (64-sample ring buffer, 2px-wide bars)
- DisplayOn/DisplayOff host commands routed via dedicated channel
- Pure render functions against generic DrawTarget (no hardware coupling)
- Display on/off control bypasses radio task entirely

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
swaitsandClaude Opus 4.6 committed 2026-03-29 20:42:54 -06:00
1 parent 7064110293
commit 3102fc747a
17624 files changed
+4564928 -38

No files matched your search

+59
View File
@@ -0,0 +1,59 @@
use postcard::accumulator::{CobsAccumulator, FeedResult};
use serde::{Deserialize, Serialize};
use std::io::Read;
// Read a "huge" serialized struct in 32 byte chunks into a 256 byte buffer and deserialize it.
#[test]
fn reader() {
let mut raw_buf = [0u8; 32];
let mut input_buf = [0u8; 256];
let mut cobs_buf: CobsAccumulator<256> = CobsAccumulator::new();
#[derive(Serialize, Deserialize, Debug, PartialEq, Eq)]
struct Huge {
a: u32,
b: [u32; 32],
d: u64,
}
let expected = Huge {
a: 0xabcdef00,
b: [0x01234567; 32],
d: u64::MAX,
};
let input = postcard::to_slice_cobs(&expected, &mut input_buf).unwrap();
// TODO(https://github.com/rust-lang/rust-clippy/issues/12751): Remove once fixed.
#[allow(clippy::redundant_slicing)]
let mut input = &input[..];
// Magic number from serializing struct and printing length
assert_eq!(input.len(), 145);
let mut output = None;
while let Ok(ct) = input.read(&mut raw_buf) {
// Finished reading input
if ct == 0 {
break;
}
let buf = &raw_buf[..ct];
let mut window = buf;
'cobs: while !window.is_empty() {
window = match cobs_buf.feed::<Huge>(window) {
FeedResult::Consumed => break 'cobs,
FeedResult::OverFull(new_wind) => new_wind,
FeedResult::DeserError(new_wind) => new_wind,
FeedResult::Success { data, remaining } => {
output = Some(data);
remaining
}
};
}
}
assert_eq!(output.unwrap(), expected);
}
+85
View File
@@ -0,0 +1,85 @@
#[test]
#[cfg(feature = "use-crc")]
fn test_crc() {
use crc::{Crc, CRC_32_ISCSI};
let data: &[u8] = &[0x01, 0x00, 0x20, 0x30];
let buffer = &mut [0u8; 32];
let crc = Crc::<u32>::new(&CRC_32_ISCSI);
let digest = crc.digest();
let res = postcard::to_slice_crc32(data, buffer, digest).unwrap();
assert_eq!(res, &[0x04, 0x01, 0x00, 0x20, 0x30, 0x8E, 0xC8, 0x1A, 0x37]);
let digest = crc.digest();
let res = postcard::take_from_bytes_crc32::<[u8; 5]>(res, digest).unwrap();
let expected_bytes = [0x04, 0x01, 0x00, 0x20, 0x30];
let remaining_bytes = [];
assert_eq!(res, (expected_bytes, remaining_bytes.as_slice()));
}
#[test]
#[cfg(feature = "use-crc")]
fn test_crc_8() {
use crc::{Crc, CRC_8_SMBUS};
let data: &[u8] = &[0x01, 0x00, 0x20, 0x30];
let buffer = &mut [0u8; 32];
let crc = Crc::<u8>::new(&CRC_8_SMBUS);
let digest = crc.digest();
let res = postcard::ser_flavors::crc::to_slice_u8(data, buffer, digest).unwrap();
assert_eq!(res, &[0x04, 0x01, 0x00, 0x20, 0x30, 167]);
let digest = crc.digest();
let res = postcard::de_flavors::crc::take_from_bytes_u8::<[u8; 5]>(res, digest).unwrap();
let expected_bytes = [0x04, 0x01, 0x00, 0x20, 0x30];
let remaining_bytes = [];
assert_eq!(res, (expected_bytes, remaining_bytes.as_slice()));
}
#[test]
#[cfg(feature = "use-crc")]
fn test_crc_error() {
use crc::{Crc, CRC_32_ISCSI};
let data: &[u8] = &[0x01, 0x00, 0x20, 0x30];
let buffer = &mut [0u8; 32];
let crc = Crc::<u32>::new(&CRC_32_ISCSI);
let digest = crc.digest();
let res = postcard::to_slice_crc32(data, buffer, digest).unwrap();
// intentionally corrupt the crc
let last = res.len() - 1;
res[last] = 0;
let digest = crc.digest();
let res = postcard::take_from_bytes_crc32::<[u8; 5]>(res, digest);
assert_eq!(res, Err(postcard::Error::DeserializeBadCrc));
}
#[test]
#[cfg(feature = "use-crc")]
fn test_crc_in_method() {
use crc::{Crc, CRC_32_ISCSI};
use postcard::{to_slice_crc32, Result};
use serde::Serialize;
#[derive(Debug, Serialize)]
pub struct Thing {
value: u32,
}
impl Thing {
pub fn to_bytes<'a>(&self, buf: &'a mut [u8]) -> Result<&'a mut [u8]> {
let crc = Crc::<u32>::new(&CRC_32_ISCSI);
to_slice_crc32(self, buf, crc.digest())
}
}
let buffer = &mut [0u8; 5];
let thing = Thing { value: 42 };
let slice = thing.to_bytes(buffer).unwrap();
assert_eq!(slice, &[0x2A, 0xB7, 0xF5, 0x22, 0x19]);
}
+251
View File
@@ -0,0 +1,251 @@
use core::fmt::Debug;
use core::fmt::Write;
use core::ops::Deref;
#[cfg(feature = "heapless")]
use heapless::{FnvIndexMap, String, Vec};
#[cfg(feature = "heapless")]
use postcard::to_vec;
use postcard::from_bytes;
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize};
#[derive(Debug, Serialize, Deserialize, Eq, PartialEq)]
struct BasicU8S {
st: u16,
ei: u8,
sf: u64,
tt: u32,
}
#[allow(dead_code)]
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
enum BasicEnum {
Bib,
Bim,
Bap,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
struct EnumStruct {
eight: u8,
sixt: u16,
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
enum DataEnum {
Bib(u16),
Bim(u64),
Bap(u8),
Kim(EnumStruct),
Chi { a: u8, b: u32 },
Sho(u16, u8),
}
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
struct NewTypeStruct(u32);
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
struct TupleStruct((u8, u16));
#[derive(Serialize, Deserialize, Debug, Eq, PartialEq)]
struct RefStruct<'a> {
bytes: &'a [u8],
str_s: &'a str,
}
#[cfg(feature = "heapless")]
#[test]
fn loopback() {
// Basic types
test_one((), &[]);
test_one(false, &[0x00]);
test_one(true, &[0x01]);
test_one(5u8, &[0x05]);
test_one(0xA5C7u16, &[0xC7, 0xCB, 0x02]);
test_one(0xCDAB3412u32, &[0x92, 0xE8, 0xAC, 0xED, 0x0C]);
test_one(
0x1234_5678_90AB_CDEFu64,
&[0xEF, 0x9B, 0xAF, 0x85, 0x89, 0xCF, 0x95, 0x9A, 0x12],
);
// https://github.com/jamesmunns/postcard/pull/83
test_one(32767i16, &[0xFE, 0xFF, 0x03]);
test_one(-32768i16, &[0xFF, 0xFF, 0x03]);
// chars
test_one('z', &[0x01, 0x7a]);
test_one('¢', &[0x02, 0xc2, 0xa2]);
test_one('𐍈', &[0x04, 0xF0, 0x90, 0x8D, 0x88]);
test_one('🥺', &[0x04, 0xF0, 0x9F, 0xA5, 0xBA]);
// Structs
test_one(
BasicU8S {
st: 0xABCD,
ei: 0xFE,
sf: 0x1234_4321_ABCD_DCBA,
tt: 0xACAC_ACAC,
},
&[
0xCD, 0xD7, 0x02, 0xFE, 0xBA, 0xB9, 0xB7, 0xDE, 0x9A, 0xE4, 0x90, 0x9A, 0x12, 0xAC,
0xD9, 0xB2, 0xE5, 0x0A,
],
);
// Enums!
test_one(BasicEnum::Bim, &[0x01]);
test_one(
DataEnum::Bim(u64::MAX),
&[
0x01, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0xFF, 0x01,
],
);
test_one(DataEnum::Bib(u16::MAX), &[0x00, 0xFF, 0xFF, 0x03]);
test_one(DataEnum::Bap(u8::MAX), &[0x02, 0xFF]);
test_one(
DataEnum::Kim(EnumStruct {
eight: 0xF0,
sixt: 0xACAC,
}),
&[0x03, 0xF0, 0xAC, 0xD9, 0x02],
);
test_one(
DataEnum::Chi {
a: 0x0F,
b: 0xC7C7C7C7,
},
&[0x04, 0x0F, 0xC7, 0x8F, 0x9F, 0xBE, 0x0C],
);
test_one(DataEnum::Sho(0x6969, 0x07), &[0x05, 0xE9, 0xD2, 0x01, 0x07]);
// Tuples!
test_one((0x12u8, 0xC7A5u16), &[0x12, 0xA5, 0x8F, 0x03]);
// Structs!
test_one(NewTypeStruct(5), &[0x05]);
test_one(TupleStruct((0xA0, 0x1234)), &[0xA0, 0xB4, 0x24]);
let mut input: Vec<u8, 4> = Vec::new();
input.extend_from_slice(&[0x01, 0x02, 0x03, 0x04]).unwrap();
test_one(input, &[0x04, 0x01, 0x02, 0x03, 0x04]);
let mut input: String<8> = String::new();
write!(&mut input, "helLO!").unwrap();
test_one(input, &[0x06, b'h', b'e', b'l', b'L', b'O', b'!']);
let mut input: FnvIndexMap<u8, u8, 4> = FnvIndexMap::new();
input.insert(0x01, 0x05).unwrap();
input.insert(0x02, 0x06).unwrap();
input.insert(0x03, 0x07).unwrap();
input.insert(0x04, 0x08).unwrap();
test_one(
input,
&[0x04, 0x01, 0x05, 0x02, 0x06, 0x03, 0x07, 0x04, 0x08],
);
// `CString` (uses `serialize_bytes`/`deserialize_byte_buf`)
#[cfg(feature = "use-std")]
test_one(
std::ffi::CString::new("heLlo").unwrap(),
&[0x05, b'h', b'e', b'L', b'l', b'o'],
);
}
#[cfg(feature = "heapless")]
#[track_caller]
fn test_one<T>(data: T, ser_rep: &[u8])
where
T: Serialize + DeserializeOwned + Eq + PartialEq + Debug,
{
let serialized: Vec<u8, 2048> = to_vec(&data).unwrap();
assert_eq!(serialized.len(), ser_rep.len());
let mut x: ::std::vec::Vec<u8> = vec![];
x.extend(serialized.deref().iter().cloned());
// let bysl: &'de [u8] = serialized.deref();
assert_eq!(x, ser_rep);
{
// let deserialized: T = from_bytes(serialized.deref()).unwrap();
let deserialized: T = from_bytes(&x).unwrap();
assert_eq!(data, deserialized);
}
}
#[cfg(feature = "use-std")]
#[test]
fn std_io_loopback() {
use postcard::from_io;
use postcard::to_io;
fn test_io<T>(data: T, ser_rep: &[u8])
where
T: Serialize + DeserializeOwned + Eq + PartialEq + Debug,
{
let serialized: ::std::vec::Vec<u8> = vec![];
let ser = to_io(&data, serialized).unwrap();
assert_eq!(ser.len(), ser_rep.len());
assert_eq!(ser, ser_rep);
{
let mut buff = [0; 2048];
let x = ser.clone();
let deserialized: T = from_io((x.as_slice(), &mut buff)).unwrap().0;
assert_eq!(data, deserialized);
}
}
test_io(DataEnum::Sho(0x6969, 0x07), &[0x05, 0xE9, 0xD2, 0x01, 0x07]);
test_io(
BasicU8S {
st: 0xABCD,
ei: 0xFE,
sf: 0x1234_4321_ABCD_DCBA,
tt: 0xACAC_ACAC,
},
&[
0xCD, 0xD7, 0x02, 0xFE, 0xBA, 0xB9, 0xB7, 0xDE, 0x9A, 0xE4, 0x90, 0x9A, 0x12, 0xAC,
0xD9, 0xB2, 0xE5, 0x0A,
],
);
}
#[cfg(all(
any(feature = "embedded-io-04", feature = "embedded-io-06"),
feature = "alloc"
))]
#[test]
fn std_eio_loopback() {
use postcard::from_eio;
use postcard::to_eio;
fn test_io<T>(data: T, ser_rep: &[u8])
where
T: Serialize + DeserializeOwned + Eq + PartialEq + Debug,
{
let serialized: ::std::vec::Vec<u8> = vec![];
let ser = to_eio(&data, serialized).unwrap();
assert_eq!(ser.len(), ser_rep.len());
assert_eq!(ser, ser_rep);
{
let mut buff = [0; 2048];
let x = ser.clone();
let deserialized: T = from_eio((x.as_slice(), &mut buff)).unwrap().0;
assert_eq!(data, deserialized);
}
}
test_io(DataEnum::Sho(0x6969, 0x07), &[0x05, 0xE9, 0xD2, 0x01, 0x07]);
test_io(
BasicU8S {
st: 0xABCD,
ei: 0xFE,
sf: 0x1234_4321_ABCD_DCBA,
tt: 0xACAC_ACAC,
},
&[
0xCD, 0xD7, 0x02, 0xFE, 0xBA, 0xB9, 0xB7, 0xDE, 0x9A, 0xE4, 0x90, 0x9A, 0x12, 0xAC,
0xD9, 0xB2, 0xE5, 0x0A,
],
);
}
+101
View File
@@ -0,0 +1,101 @@
#![allow(unused_imports)]
#[cfg(feature = "experimental-derive")]
mod tests {
use postcard::experimental::max_size::MaxSize;
use postcard::to_slice;
use serde::Serialize;
#[test]
fn test_struct_max_size() {
#[derive(MaxSize)]
struct Foo {
_a: u16,
_b: Option<u8>,
}
assert_eq!(Foo::POSTCARD_MAX_SIZE, 5);
}
#[test]
fn test_enum_max_size() {
#[allow(dead_code)]
#[derive(MaxSize, Serialize)]
enum Bar {
A(u16),
B(u8),
}
assert_eq!(Bar::POSTCARD_MAX_SIZE, 4);
let mut buf = [0u8; 128];
let used = to_slice(&Bar::A(0xFFFF), &mut buf).unwrap();
assert!(
used.len() <= Bar::POSTCARD_MAX_SIZE,
"FAIL {} > {}",
used.len(),
Bar::POSTCARD_MAX_SIZE
);
#[derive(MaxSize)]
enum Baz {}
assert_eq!(Baz::POSTCARD_MAX_SIZE, 0);
}
#[test]
fn test_ref() {
#[allow(dead_code)]
#[derive(MaxSize)]
struct Foo {
a: &'static u32,
}
}
#[cfg(feature = "heapless")]
#[test]
fn test_vec_edge_cases() {
#[track_caller]
fn test_equals<const N: usize>(buf: &mut [u8]) {
let mut v = heapless::Vec::<u8, N>::new();
for _ in 0..N {
v.push(0).unwrap();
}
let serialized = postcard::to_slice(&v, buf).unwrap();
assert_eq!(heapless::Vec::<u8, N>::POSTCARD_MAX_SIZE, serialized.len());
}
let mut buf = [0; 16400];
test_equals::<1>(&mut buf);
test_equals::<2>(&mut buf);
test_equals::<127>(&mut buf);
test_equals::<128>(&mut buf);
test_equals::<129>(&mut buf);
test_equals::<16383>(&mut buf);
test_equals::<16384>(&mut buf);
test_equals::<16385>(&mut buf);
}
// #[cfg(feature = "experimental-derive")]
// #[test]
// fn test_union_max_size() {
// #[derive(postcard::MaxSize)]
// union Foo {
// a: u16,
// b: Option<u8>,
// }
// }
// #[cfg(feature = "experimental-derive")]
// #[test]
// fn test_not_implemented() {
// #[derive(postcard::MaxSize)]
// struct Foo {
// a: &'static str,
// }
// }
}