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

+22
View File
@@ -0,0 +1,22 @@
use super::*;
macro_rules! write_to_le_bytes {
($($s:ident),*) => {
$(/// Implementation detail
pub fn $s(b: &$s) {
write(&b.to_le_bytes())
})*
};
}
write_to_le_bytes!(u8, u16, u32, u64, u128, i8, i16, i32, i64, i128);
/// Implementation detail
pub fn usize(b: &usize) {
write(&(*b as u32).to_le_bytes())
}
/// Implementation detail
pub fn isize(b: &isize) {
write(&(*b as i32).to_le_bytes())
}
+235
View File
@@ -0,0 +1,235 @@
mod integers;
mod traits;
use core::fmt::Write as _;
use crate::{Format, Formatter, Str};
pub use self::integers::*;
pub use bitflags::bitflags;
pub trait UnsignedInt {}
impl UnsignedInt for u8 {}
impl UnsignedInt for u16 {}
impl UnsignedInt for u32 {}
impl UnsignedInt for u64 {}
impl UnsignedInt for u128 {}
#[cfg(feature = "unstable-test")]
thread_local! {
static I: core::sync::atomic::AtomicU16 = const { core::sync::atomic::AtomicU16::new(0) };
static BYTES: core::cell::RefCell<Vec<u8>> = const { core::cell::RefCell::new(Vec::new()) };
}
/// For testing purposes
#[cfg(feature = "unstable-test")]
pub fn fetch_string_index() -> u16 {
I.with(|i| i.load(core::sync::atomic::Ordering::Relaxed))
}
/// For testing purposes
#[cfg(feature = "unstable-test")]
pub fn fetch_add_string_index() -> u16 {
I.with(|i| i.fetch_add(1, core::sync::atomic::Ordering::Relaxed))
}
/// Get and clear the logged bytes
#[cfg(feature = "unstable-test")]
pub fn fetch_bytes() -> Vec<u8> {
BYTES.with(|b| core::mem::take(&mut *b.borrow_mut()))
}
/// Only to be used by the defmt macros
/// Safety: must be paired with a later call to release()
#[cfg(feature = "unstable-test")]
pub unsafe fn acquire() {}
/// Only to be used by the defmt macros
/// Safety: must be paired with a later call to release()
#[cfg(not(feature = "unstable-test"))]
#[inline(always)]
pub unsafe fn acquire() {
extern "Rust" {
fn _defmt_acquire();
}
_defmt_acquire()
}
/// Only to be used by the defmt macros
/// Safety: must follow an earlier call to acquire()
#[cfg(feature = "unstable-test")]
pub unsafe fn release() {}
/// Only to be used by the defmt macros
/// Safety: must follow an earlier call to acquire()
#[cfg(not(feature = "unstable-test"))]
#[inline(always)]
pub unsafe fn release() {
extern "Rust" {
fn _defmt_release();
}
_defmt_release()
}
#[cfg(feature = "unstable-test")]
pub fn write(bytes: &[u8]) {
BYTES.with(|b| b.borrow_mut().extend(bytes))
}
#[cfg(not(feature = "unstable-test"))]
#[inline(always)]
pub fn write(bytes: &[u8]) {
extern "Rust" {
fn _defmt_write(bytes: &[u8]);
}
unsafe { _defmt_write(bytes) }
}
/// For testing purposes
#[cfg(feature = "unstable-test")]
pub fn timestamp(_fmt: crate::Formatter<'_>) {}
#[cfg(not(feature = "unstable-test"))]
#[inline(always)]
pub fn timestamp(fmt: crate::Formatter<'_>) {
extern "Rust" {
fn _defmt_timestamp(_: crate::Formatter<'_>);
}
unsafe { _defmt_timestamp(fmt) }
}
/// Returns the interned string at `address`.
pub fn make_istr(address: u16) -> Str {
Str { address }
}
/// Create a Formatter.
pub fn make_formatter<'a>() -> Formatter<'a> {
Formatter {
_phantom: core::marker::PhantomData,
}
}
pub fn truncate<T>(x: impl traits::Truncate<T>) -> T {
x.truncate()
}
pub fn into_result<T: traits::IntoResult>(x: T) -> Result<T::Ok, T::Error> {
x.into_result()
}
/// For testing purposes
#[cfg(feature = "unstable-test")]
pub fn panic() -> ! {
panic!()
}
#[cfg(not(feature = "unstable-test"))]
#[inline(always)]
pub fn panic() -> ! {
extern "Rust" {
fn _defmt_panic() -> !;
}
unsafe { _defmt_panic() }
}
/// Implementation detail
pub fn fmt<T: Format + ?Sized>(f: &T) {
istr(&T::_format_tag());
f._format_data();
}
/// Implementation detail
pub fn fmt_slice<T: Format>(values: &[T]) {
usize(&values.len());
istr(&T::_format_tag());
for value in values {
value._format_data();
}
}
/// Implementation detail
pub fn f32(b: &f32) {
write(&f32::to_bits(*b).to_le_bytes())
}
/// Implementation detail
pub fn f64(b: &f64) {
write(&f64::to_bits(*b).to_le_bytes())
}
/// Implementation detail
pub fn char(b: &char) {
write(&(*b as u32).to_le_bytes())
}
pub fn str(s: &str) {
usize(&s.len());
write(s.as_bytes());
}
pub fn slice(s: &[u8]) {
usize(&s.len());
write(s);
}
// NOTE: This is passed `&[u8; N]` – it's just coerced to a slice.
pub fn u8_array(a: &[u8]) {
write(a);
}
// NOTE: This is passed `&[u8; N]` – it's just coerced to a slice.
pub fn fmt_array<T: Format>(a: &[T]) {
istr(&T::_format_tag());
for value in a {
value._format_data();
}
}
/// Implementation detail
pub fn istr(s: &Str) {
write(&s.address.to_le_bytes())
}
/// Implementation detail
pub fn bool(b: &bool) {
u8(&(*b as u8));
}
/// Implementation detail
pub fn debug(val: &dyn core::fmt::Debug) {
core::write!(FmtWrite, "{val:?}").ok();
write(&[0xff]);
}
/// Implementation detail
pub fn display(val: &dyn core::fmt::Display) {
core::write!(FmtWrite, "{val}").ok();
write(&[0xff]);
}
#[inline(never)]
pub unsafe fn acquire_and_header(s: &Str) {
acquire();
istr(s);
timestamp(make_formatter());
}
#[inline(never)]
pub fn acquire_header_and_release(s: &Str) {
// safety: will be released a few lines further down
unsafe { acquire() };
istr(s);
timestamp(make_formatter());
// safety: acquire() was called a few lines above
unsafe { release() };
}
struct FmtWrite;
impl core::fmt::Write for FmtWrite {
fn write_str(&mut self, s: &str) -> core::fmt::Result {
write(s.as_bytes());
Ok(())
}
}
+82
View File
@@ -0,0 +1,82 @@
#[allow(unused_imports)]
use crate as defmt;
use crate::{Format, Formatter, Str};
pub trait Truncate<U> {
fn truncate(self) -> U;
}
macro_rules! impl_truncate {
($($from:ty => $into:ty),*) => {
$(impl Truncate<$into> for $from {
fn truncate(self) -> $into {
self as $into
}
})*
};
}
// We implement `Truncate<X> for X` so that the macro can unconditionally use it,
// even if no truncation is performed.
impl_truncate!(
u8 => u8,
u16 => u8,
u32 => u8,
u64 => u8,
u128 => u8,
u16 => u16,
u32 => u16,
u64 => u16,
u128 => u16,
u32 => u32,
u64 => u32,
u128 => u32,
u64 => u64,
u128 => u64,
u128 => u128
);
#[derive(Debug, Copy, Clone, Eq, PartialEq)]
pub struct NoneError;
impl Format for NoneError {
fn format(&self, _fmt: Formatter) {
unreachable!();
}
fn _format_tag() -> Str {
defmt_macros::internp!("Unwrap of a None option value")
}
fn _format_data(&self) {}
}
/// Transform `self` into a `Result`
///
/// # Call sites
/// * [`defmt::unwrap!`]
pub trait IntoResult {
type Ok;
type Error;
fn into_result(self) -> Result<Self::Ok, Self::Error>;
}
impl<T> IntoResult for Option<T> {
type Ok = T;
type Error = NoneError;
#[inline]
fn into_result(self) -> Result<T, NoneError> {
self.ok_or(NoneError)
}
}
impl<T, E> IntoResult for Result<T, E> {
type Ok = T;
type Error = E;
#[inline]
fn into_result(self) -> Self {
self
}
}