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

+17
View File
@@ -0,0 +1,17 @@
//! Blocking CAN API
/// A blocking CAN interface that is able to transmit and receive frames.
pub trait Can {
/// Associated frame type.
type Frame: crate::Frame;
/// Associated error type.
type Error: crate::Error;
/// Puts a frame in the transmit buffer. Blocks until space is available in
/// the transmit buffer.
fn transmit(&mut self, frame: &Self::Frame) -> Result<(), Self::Error>;
/// Blocks until a frame was received or an error occured.
fn receive(&mut self) -> Result<Self::Frame, Self::Error>;
}
+209
View File
@@ -0,0 +1,209 @@
//! CAN Identifiers.
/// Standard 11-bit CAN Identifier (`0..=0x7FF`).
#[derive(Debug, Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Hash)]
pub struct StandardId(u16);
impl StandardId {
/// CAN ID `0`, the highest priority.
pub const ZERO: Self = Self(0);
/// CAN ID `0x7FF`, the lowest priority.
pub const MAX: Self = Self(0x7FF);
/// Tries to create a `StandardId` from a raw 16-bit integer.
///
/// This will return `None` if `raw` is out of range of an 11-bit integer (`> 0x7FF`).
#[inline]
pub const fn new(raw: u16) -> Option<Self> {
if raw <= 0x7FF {
Some(Self(raw))
} else {
None
}
}
/// Creates a new `StandardId` without checking if it is inside the valid range.
///
/// # Safety
/// Using this method can create an invalid ID and is thus marked as unsafe.
#[inline]
pub const unsafe fn new_unchecked(raw: u16) -> Self {
Self(raw)
}
/// Returns this CAN Identifier as a raw 16-bit integer.
#[inline]
pub fn as_raw(&self) -> u16 {
self.0
}
}
/// Extended 29-bit CAN Identifier (`0..=1FFF_FFFF`).
#[derive(Debug, Copy, Clone, Eq, PartialEq, PartialOrd, Ord, Hash)]
pub struct ExtendedId(u32);
impl ExtendedId {
/// CAN ID `0`, the highest priority.
pub const ZERO: Self = Self(0);
/// CAN ID `0x1FFFFFFF`, the lowest priority.
pub const MAX: Self = Self(0x1FFF_FFFF);
/// Tries to create a `ExtendedId` from a raw 32-bit integer.
///
/// This will return `None` if `raw` is out of range of an 29-bit integer (`> 0x1FFF_FFFF`).
#[inline]
pub const fn new(raw: u32) -> Option<Self> {
if raw <= 0x1FFF_FFFF {
Some(Self(raw))
} else {
None
}
}
/// Creates a new `ExtendedId` without checking if it is inside the valid range.
///
/// # Safety
/// Using this method can create an invalid ID and is thus marked as unsafe.
#[inline]
pub const unsafe fn new_unchecked(raw: u32) -> Self {
Self(raw)
}
/// Returns this CAN Identifier as a raw 32-bit integer.
#[inline]
pub fn as_raw(&self) -> u32 {
self.0
}
/// Returns the Base ID part of this extended identifier.
pub fn standard_id(&self) -> StandardId {
// ID-28 to ID-18
StandardId((self.0 >> 18) as u16)
}
}
/// A CAN Identifier (standard or extended).
#[derive(Debug, Copy, Clone, Eq, PartialEq, Hash)]
pub enum Id {
/// Standard 11-bit Identifier (`0..=0x7FF`).
Standard(StandardId),
/// Extended 29-bit Identifier (`0..=0x1FFF_FFFF`).
Extended(ExtendedId),
}
/// Implement `Ord` according to the CAN arbitration rules
///
/// When performing arbitration, frames are looked at bit for bit starting
/// from the beginning. A bit with the value 0 is dominant and a bit with
/// value of 1 is recessive.
///
/// When two devices are sending frames at the same time, as soon as the first
/// bit is found which differs, the frame with the corresponding dominant
/// 0 bit will win and get to send the rest of the frame.
///
/// This implementation of `Ord` for `Id` will take this into consideration
/// and when comparing two different instances of `Id` the "smallest" will
/// always be the ID which would form the most dominant frame, all other
/// things being equal.
impl Ord for Id {
fn cmp(&self, other: &Self) -> core::cmp::Ordering {
let split_id = |id: &Id| {
let (standard_id_part, ide_bit, extended_id_part) = match id {
Id::Standard(StandardId(x)) => (*x, 0, 0),
Id::Extended(x) => (
x.standard_id().0,
1,
x.0 & ((1 << 18) - 1), // Bit ID-17 to ID-0
),
};
(standard_id_part, ide_bit, extended_id_part)
};
split_id(self).cmp(&split_id(other))
}
}
impl PartialOrd for Id {
fn partial_cmp(&self, other: &Id) -> Option<core::cmp::Ordering> {
Some(self.cmp(other))
}
}
impl From<StandardId> for Id {
#[inline]
fn from(id: StandardId) -> Self {
Id::Standard(id)
}
}
impl From<ExtendedId> for Id {
#[inline]
fn from(id: ExtendedId) -> Self {
Id::Extended(id)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn standard_id_new() {
assert_eq!(
StandardId::new(StandardId::MAX.as_raw()),
Some(StandardId::MAX)
);
}
#[test]
fn standard_id_new_out_of_range() {
assert_eq!(StandardId::new(StandardId::MAX.as_raw() + 1), None);
}
#[test]
fn standard_id_new_unchecked_out_of_range() {
let id = StandardId::MAX.as_raw() + 1;
assert_eq!(unsafe { StandardId::new_unchecked(id) }, StandardId(id));
}
#[test]
fn extended_id_new() {
assert_eq!(
ExtendedId::new(ExtendedId::MAX.as_raw()),
Some(ExtendedId::MAX)
);
}
#[test]
fn extended_id_new_out_of_range() {
assert_eq!(ExtendedId::new(ExtendedId::MAX.as_raw() + 1), None);
}
#[test]
fn extended_id_new_unchecked_out_of_range() {
let id = ExtendedId::MAX.as_raw() + 1;
assert_eq!(unsafe { ExtendedId::new_unchecked(id) }, ExtendedId(id));
}
#[test]
fn get_standard_id_from_extended_id() {
assert_eq!(
Some(ExtendedId::MAX.standard_id()),
StandardId::new((ExtendedId::MAX.0 >> 18) as u16)
);
}
#[test]
fn cmp_id() {
assert!(StandardId::ZERO < StandardId::MAX);
assert!(ExtendedId::ZERO < ExtendedId::MAX);
assert!(Id::Standard(StandardId::ZERO) < Id::Extended(ExtendedId::ZERO));
assert!(Id::Extended(ExtendedId::ZERO) < Id::Extended(ExtendedId::MAX));
assert!(Id::Extended(ExtendedId((1 << 11) - 1)) < Id::Standard(StandardId(1)));
assert!(Id::Standard(StandardId(1)) < Id::Extended(ExtendedId::MAX));
}
}
+133
View File
@@ -0,0 +1,133 @@
//! Controller Area Network (CAN) traits
#![warn(missing_docs)]
#![no_std]
pub mod blocking;
pub mod nb;
mod id;
pub use id::*;
/// A CAN2.0 Frame
pub trait Frame: Sized {
/// Creates a new frame.
///
/// This will return `None` if the data slice is too long.
fn new(id: impl Into<Id>, data: &[u8]) -> Option<Self>;
/// Creates a new remote frame (RTR bit set).
///
/// This will return `None` if the data length code (DLC) is not valid.
fn new_remote(id: impl Into<Id>, dlc: usize) -> Option<Self>;
/// Returns true if this frame is a extended frame.
fn is_extended(&self) -> bool;
/// Returns true if this frame is a standard frame.
fn is_standard(&self) -> bool {
!self.is_extended()
}
/// Returns true if this frame is a remote frame.
fn is_remote_frame(&self) -> bool;
/// Returns true if this frame is a data frame.
fn is_data_frame(&self) -> bool {
!self.is_remote_frame()
}
/// Returns the frame identifier.
fn id(&self) -> Id;
/// Returns the data length code (DLC) which is in the range 0..8.
///
/// For data frames the DLC value always matches the length of the data.
/// Remote frames do not carry any data, yet the DLC can be greater than 0.
fn dlc(&self) -> usize;
/// Returns the frame data (0..8 bytes in length).
fn data(&self) -> &[u8];
}
/// CAN error
pub trait Error: core::fmt::Debug {
/// Convert error to a generic CAN error kind
///
/// By using this method, CAN errors freely defined by HAL implementations
/// can be converted to a set of generic serial errors upon which generic
/// code can act.
fn kind(&self) -> ErrorKind;
}
impl Error for core::convert::Infallible {
fn kind(&self) -> ErrorKind {
match *self {}
}
}
/// CAN error kind
///
/// This represents a common set of CAN operation errors. HAL implementations are
/// free to define more specific or additional error types. However, by providing
/// a mapping to these common CAN errors, generic code can still react to them.
#[derive(Debug, Copy, Clone, Eq, PartialEq, Ord, PartialOrd, Hash)]
#[non_exhaustive]
pub enum ErrorKind {
/// The peripheral receive buffer was overrun.
Overrun,
// MAC sublayer errors
/// A bit error is detected at that bit time when the bit value that is
/// monitored differs from the bit value sent.
Bit,
/// A stuff error is detected at the bit time of the sixth consecutive
/// equal bit level in a frame field that shall be coded by the method
/// of bit stuffing.
Stuff,
/// Calculated CRC sequence does not equal the received one.
Crc,
/// A form error shall be detected when a fixed-form bit field contains
/// one or more illegal bits.
Form,
/// An ACK error shall be detected by a transmitter whenever it does not
/// monitor a dominant bit during the ACK slot.
Acknowledge,
/// A different error occurred. The original error may contain more information.
Other,
}
impl Error for ErrorKind {
fn kind(&self) -> ErrorKind {
*self
}
}
impl core::fmt::Display for ErrorKind {
fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
match self {
Self::Overrun => write!(f, "The peripheral receive buffer was overrun"),
Self::Bit => write!(
f,
"Bit value that is monitored differs from the bit value sent"
),
Self::Stuff => write!(f, "Sixth consecutive equal bits detected"),
Self::Crc => write!(f, "Calculated CRC sequence does not equal the received one"),
Self::Form => write!(
f,
"A fixed-form bit field contains one or more illegal bits"
),
Self::Acknowledge => write!(f, "Transmitted frame was not acknowledged"),
Self::Other => write!(
f,
"A different error occurred. The original error may contain more information"
),
}
}
}
+28
View File
@@ -0,0 +1,28 @@
//! Non-blocking CAN API
/// A CAN interface that is able to transmit and receive frames.
pub trait Can {
/// Associated frame type.
type Frame: crate::Frame;
/// Associated error type.
type Error: crate::Error;
/// Puts a frame in the transmit buffer to be sent on the bus.
///
/// If the transmit buffer is full, this function will try to replace a pending
/// lower priority frame and return the frame that was replaced.
/// Returns `Err(WouldBlock)` if the transmit buffer is full and no frame can be
/// replaced.
///
/// # Notes for implementers
///
/// * Frames of equal identifier shall be transmited in FIFO fashion when more
/// than one transmit buffer is available.
/// * When replacing pending frames make sure the frame is not in the process of
/// being send to the bus.
fn transmit(&mut self, frame: &Self::Frame) -> nb::Result<Option<Self::Frame>, Self::Error>;
/// Returns a received frame if available.
fn receive(&mut self) -> nb::Result<Self::Frame, Self::Error>;
}