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

+89
View File
@@ -0,0 +1,89 @@
//! defmt global logger implementation.
// Implementation taken from defmt-rtt, with a custom framing prefix
#[cfg(feature = "critical-section")]
use critical_section::RestoreState;
use super::PrinterImpl;
/// Global logger lock.
#[cfg(feature = "critical-section")]
static mut TAKEN: bool = false;
#[cfg(feature = "critical-section")]
static mut CS_RESTORE: RestoreState = RestoreState::invalid();
static mut ENCODER: defmt::Encoder = defmt::Encoder::new();
#[defmt::global_logger]
pub struct Logger;
unsafe impl defmt::Logger for Logger {
fn acquire() {
#[cfg(feature = "critical-section")]
unsafe {
// safety: Must be paired with corresponding call to release(), see below
let restore = critical_section::acquire();
// safety: accessing the `static mut` is OK because we have acquired a critical
// section.
if TAKEN {
panic!("defmt logger taken reentrantly")
}
// safety: accessing the `static mut` is OK because we have acquired a critical
// section.
TAKEN = true;
// safety: accessing the `static mut` is OK because we have acquired a critical
// section.
CS_RESTORE = restore;
}
// If not disabled, write a non-UTF8 sequence to indicate the start of a defmt
// frame. We need this to distinguish defmt frames from other data that
// might be written to the printer.
do_write(&[0xFF, 0x00]);
// safety: accessing the `static mut` is OK because we have acquired a critical
// section.
unsafe { ENCODER.start_frame(do_write) }
}
unsafe fn release() {
// safety: accessing the `static mut` is OK because we have acquired a critical
// section.
ENCODER.end_frame(do_write);
Self::flush();
#[cfg(feature = "critical-section")]
{
// We don't need to write a custom end-of-frame sequence because:
// - using `defmt`, the rzcobs encoding already includes a terminating zero
// - using `defmt-raw`, we don't add any additional framing data
// safety: accessing the `static mut` is OK because we have acquired a critical
// section.
TAKEN = false;
// safety: accessing the `static mut` is OK because we have acquired a critical
// section.
let restore = CS_RESTORE;
// safety: Must be paired with corresponding call to acquire(), see above
critical_section::release(restore);
}
}
unsafe fn flush() {
PrinterImpl::flush();
}
unsafe fn write(bytes: &[u8]) {
// safety: accessing the `static mut` is OK because we have acquired a critical
// section.
ENCODER.write(bytes, do_write);
}
}
fn do_write(bytes: &[u8]) {
PrinterImpl::write_bytes_assume_cs(bytes)
}
+483
View File
@@ -0,0 +1,483 @@
#![doc = include_str!("../README.md")]
#![doc(html_logo_url = "https://avatars.githubusercontent.com/u/46717278")]
#![allow(rustdoc::bare_urls)]
#![no_std]
#[cfg(feature = "defmt-espflash")]
pub mod defmt;
#[cfg(feature = "log")]
pub mod logger;
/// Prints to the selected output, with a newline.
#[cfg(not(feature = "no-op"))]
#[macro_export]
macro_rules! println {
($($arg:tt)*) => {{
{
use core::fmt::Write;
writeln!($crate::Printer, $($arg)*).ok();
}
}};
}
/// Prints to the selected output.
#[cfg(not(feature = "no-op"))]
#[macro_export]
macro_rules! print {
($($arg:tt)*) => {{
{
use core::fmt::Write;
write!($crate::Printer, $($arg)*).ok();
}
}};
}
/// Prints to the configured output, with a newline.
#[cfg(feature = "no-op")]
#[macro_export]
macro_rules! println {
($($arg:tt)*) => {{}};
}
/// Prints to the configured output.
#[cfg(feature = "no-op")]
#[macro_export]
macro_rules! print {
($($arg:tt)*) => {{}};
}
/// Prints and returns the value of a given expression for quick and dirty
/// debugging.
// implementation adapted from `std::dbg`
#[macro_export]
macro_rules! dbg {
// NOTE: We cannot use `concat!` to make a static string as a format argument
// of `eprintln!` because `file!` could contain a `{` or
// `$val` expression could be a block (`{ .. }`), in which case the `println!`
// will be malformed.
() => {
$crate::println!("[{}:{}]", ::core::file!(), ::core::line!())
};
($val:expr $(,)?) => {
// Use of `match` here is intentional because it affects the lifetimes
// of temporaries - https://stackoverflow.com/a/48732525/1063961
match $val {
tmp => {
$crate::println!("[{}:{}] {} = {:#?}",
::core::file!(), ::core::line!(), ::core::stringify!($val), &tmp);
tmp
}
}
};
($($val:expr),+ $(,)?) => {
($($crate::dbg!($val)),+,)
};
}
/// The printer that is used by the `print!` and `println!` macros.
pub struct Printer;
impl core::fmt::Write for Printer {
fn write_str(&mut self, s: &str) -> core::fmt::Result {
Printer::write_bytes(s.as_bytes());
Ok(())
}
}
impl Printer {
/// Writes a byte slice to the configured output.
pub fn write_bytes(bytes: &[u8]) {
with(|| {
PrinterImpl::write_bytes_assume_cs(bytes);
PrinterImpl::flush();
})
}
}
#[cfg(feature = "jtag-serial")]
type PrinterImpl = serial_jtag_printer::Printer;
#[cfg(feature = "uart")]
type PrinterImpl = uart_printer::Printer;
#[cfg(feature = "auto")]
type PrinterImpl = auto_printer::Printer;
#[cfg(all(
feature = "auto",
any(
feature = "esp32c3",
feature = "esp32c6",
feature = "esp32h2",
feature = "esp32p4", // as mentioned in 'build.rs'
feature = "esp32s3"
)
))]
mod auto_printer {
use super::with;
use crate::{
serial_jtag_printer::Printer as PrinterSerialJtag,
uart_printer::Printer as PrinterUart,
};
pub struct Printer;
impl Printer {
fn use_jtag() -> bool {
// Decide if serial-jtag is used by checking SOF interrupt flag.
// SOF packet is sent by the HOST every 1ms on a full speed bus.
// Between two consecutive ticks, there will be at least 1ms (selectable tick
// rate range is 1 - 1000Hz).
// We don't reset the flag - if it was ever connected we assume serial-jtag is
// used
#[cfg(feature = "esp32c3")]
const USB_DEVICE_INT_RAW: *const u32 = 0x60043008 as *const u32;
#[cfg(feature = "esp32c6")]
const USB_DEVICE_INT_RAW: *const u32 = 0x6000f008 as *const u32;
#[cfg(feature = "esp32h2")]
const USB_DEVICE_INT_RAW: *const u32 = 0x6000f008 as *const u32;
#[cfg(feature = "esp32p4")]
const USB_DEVICE_INT_RAW: *const u32 = unimplemented!();
#[cfg(feature = "esp32s3")]
const USB_DEVICE_INT_RAW: *const u32 = 0x60038000 as *const u32;
const SOF_INT_MASK: u32 = 0b10;
unsafe { (USB_DEVICE_INT_RAW.read_volatile() & SOF_INT_MASK) != 0 }
}
pub fn write_bytes_assume_cs(bytes: &[u8]) {
if Self::use_jtag() {
with(|| {
PrinterSerialJtag::write_bytes_assume_cs(bytes);
})
} else {
with(|| {
PrinterUart::write_bytes_assume_cs(bytes);
})
}
}
pub fn flush() {
if Self::use_jtag() {
with(|| {
PrinterSerialJtag::flush();
})
} else {
with(|| {
PrinterUart::flush();
})
}
}
}
}
#[cfg(all(
feature = "auto",
not(any(
feature = "esp32c3",
feature = "esp32c6",
feature = "esp32h2",
feature = "esp32p4",
feature = "esp32s3"
))
))]
mod auto_printer {
// models that only have UART
pub type Printer = crate::uart_printer::Printer;
}
#[cfg(all(
any(feature = "jtag-serial", feature = "auto"),
any(
feature = "esp32c3",
feature = "esp32c6",
feature = "esp32h2",
feature = "esp32p4",
feature = "esp32s3"
)
))]
mod serial_jtag_printer {
use portable_atomic::{AtomicBool, Ordering};
pub struct Printer;
#[cfg(feature = "esp32c3")]
const SERIAL_JTAG_FIFO_REG: usize = 0x6004_3000;
#[cfg(feature = "esp32c3")]
const SERIAL_JTAG_CONF_REG: usize = 0x6004_3004;
#[cfg(any(feature = "esp32c6", feature = "esp32h2"))]
const SERIAL_JTAG_FIFO_REG: usize = 0x6000_F000;
#[cfg(any(feature = "esp32c6", feature = "esp32h2"))]
const SERIAL_JTAG_CONF_REG: usize = 0x6000_F004;
#[cfg(feature = "esp32p4")]
const SERIAL_JTAG_FIFO_REG: usize = 0x500D_2000;
#[cfg(feature = "esp32p4")]
const SERIAL_JTAG_CONF_REG: usize = 0x500D_2004;
#[cfg(feature = "esp32s3")]
const SERIAL_JTAG_FIFO_REG: usize = 0x6003_8000;
#[cfg(feature = "esp32s3")]
const SERIAL_JTAG_CONF_REG: usize = 0x6003_8004;
/// A previous wait has timed out. We use this flag to avoid blocking
/// forever if there is no host attached.
static TIMED_OUT: AtomicBool = AtomicBool::new(false);
fn fifo_flush() {
let conf = SERIAL_JTAG_CONF_REG as *mut u32;
unsafe { conf.write_volatile(0b001) };
}
fn fifo_full() -> bool {
let conf = SERIAL_JTAG_CONF_REG as *mut u32;
unsafe { conf.read_volatile() & 0b010 == 0b000 }
}
fn fifo_write(byte: u8) {
let fifo = SERIAL_JTAG_FIFO_REG as *mut u32;
unsafe { fifo.write_volatile(byte as u32) }
}
fn wait_for_flush() -> bool {
const TIMEOUT_ITERATIONS: usize = 50_000;
// Wait for some time for the FIFO to clear.
let mut timeout = TIMEOUT_ITERATIONS;
while fifo_full() {
if timeout == 0 {
TIMED_OUT.store(true, Ordering::Relaxed);
return false;
}
timeout -= 1;
}
true
}
impl Printer {
pub fn write_bytes_assume_cs(bytes: &[u8]) {
if fifo_full() {
// The FIFO is full. Let's see if we can progress.
if TIMED_OUT.load(Ordering::Relaxed) {
// Still wasn't able to drain the FIFO. Let's assume we won't be able to, and
// don't queue up more data.
// This is important so we don't block forever if there is no host attached.
return;
}
// Give the fifo some time to drain.
if !wait_for_flush() {
return;
}
} else {
// Reset the flag - we managed to clear our FIFO.
TIMED_OUT.store(false, Ordering::Relaxed);
}
for &b in bytes {
if fifo_full() {
fifo_flush();
// Wait for the FIFO to clear, we have more data to shift out.
if !wait_for_flush() {
return;
}
}
fifo_write(b);
}
}
pub fn flush() {
fifo_flush();
}
}
}
#[cfg(all(any(feature = "uart", feature = "auto"), feature = "esp32"))]
mod uart_printer {
const UART_TX_ONE_CHAR: usize = 0x4000_9200;
pub struct Printer;
impl Printer {
pub fn write_bytes_assume_cs(bytes: &[u8]) {
for &b in bytes {
unsafe {
let uart_tx_one_char: unsafe extern "C" fn(u8) -> i32 =
core::mem::transmute(UART_TX_ONE_CHAR);
uart_tx_one_char(b)
};
}
}
pub fn flush() {}
}
}
#[cfg(all(any(feature = "uart", feature = "auto"), feature = "esp32s2"))]
mod uart_printer {
pub struct Printer;
impl Printer {
pub fn write_bytes_assume_cs(bytes: &[u8]) {
// On ESP32-S2 the UART_TX_ONE_CHAR ROM-function seems to have some issues.
for chunk in bytes.chunks(64) {
for &b in chunk {
unsafe {
// write FIFO
(0x3f400000 as *mut u32).write_volatile(b as u32);
};
}
// wait for TX_DONE
while unsafe { (0x3f400004 as *const u32).read_volatile() } & (1 << 14) == 0 {}
unsafe {
// reset TX_DONE
(0x3f400010 as *mut u32).write_volatile(1 << 14);
}
}
}
pub fn flush() {}
}
}
#[cfg(all(
any(feature = "uart", feature = "auto"),
not(any(feature = "esp32", feature = "esp32s2"))
))]
mod uart_printer {
trait Functions {
const TX_ONE_CHAR: usize;
const CHUNK_SIZE: usize = 32;
fn tx_byte(b: u8) {
unsafe {
let tx_one_char: unsafe extern "C" fn(u8) -> i32 =
core::mem::transmute(Self::TX_ONE_CHAR);
tx_one_char(b);
}
}
fn flush();
}
struct Device;
#[cfg(feature = "esp32c2")]
impl Functions for Device {
const TX_ONE_CHAR: usize = 0x4000_005C;
fn flush() {
// tx_one_char waits for empty
}
}
#[cfg(feature = "esp32c3")]
impl Functions for Device {
const TX_ONE_CHAR: usize = 0x4000_0068;
fn flush() {
unsafe {
const TX_FLUSH: usize = 0x4000_0080;
const GET_CHANNEL: usize = 0x4000_058C;
let tx_flush: unsafe extern "C" fn(u8) = core::mem::transmute(TX_FLUSH);
let get_channel: unsafe extern "C" fn() -> u8 = core::mem::transmute(GET_CHANNEL);
const G_USB_PRINT_ADDR: usize = 0x3FCD_FFD0;
let g_usb_print = G_USB_PRINT_ADDR as *mut bool;
let channel = if *g_usb_print {
// Flush USB-JTAG
3
} else {
get_channel()
};
tx_flush(channel);
}
}
}
#[cfg(feature = "esp32s3")]
impl Functions for Device {
const TX_ONE_CHAR: usize = 0x4000_0648;
fn flush() {
unsafe {
const TX_FLUSH: usize = 0x4000_0690;
const GET_CHANNEL: usize = 0x4000_1A58;
let tx_flush: unsafe extern "C" fn(u8) = core::mem::transmute(TX_FLUSH);
let get_channel: unsafe extern "C" fn() -> u8 = core::mem::transmute(GET_CHANNEL);
const G_USB_PRINT_ADDR: usize = 0x3FCE_FFB8;
let g_usb_print = G_USB_PRINT_ADDR as *mut bool;
let channel = if *g_usb_print {
// Flush USB-JTAG
4
} else {
get_channel()
};
tx_flush(channel);
}
}
}
#[cfg(any(feature = "esp32c6", feature = "esp32h2"))]
impl Functions for Device {
const TX_ONE_CHAR: usize = 0x4000_0058;
fn flush() {
unsafe {
const TX_FLUSH: usize = 0x4000_0074;
const GET_CHANNEL: usize = 0x4000_003C;
let tx_flush: unsafe extern "C" fn(u8) = core::mem::transmute(TX_FLUSH);
let get_channel: unsafe extern "C" fn() -> u8 = core::mem::transmute(GET_CHANNEL);
tx_flush(get_channel());
}
}
}
#[cfg(feature = "esp32p4")]
impl Functions for Device {
const TX_ONE_CHAR: usize = 0x4FC0_0054;
fn flush() {
unsafe {
const TX_FLUSH: usize = 0x4FC0_0074;
const GET_CHANNEL: usize = 0x4FC0_0038;
let tx_flush: unsafe extern "C" fn(u8) = core::mem::transmute(TX_FLUSH);
let get_channel: unsafe extern "C" fn() -> u8 = core::mem::transmute(GET_CHANNEL);
tx_flush(get_channel());
}
}
}
pub struct Printer;
impl Printer {
pub fn write_bytes_assume_cs(bytes: &[u8]) {
for chunk in bytes.chunks(Device::CHUNK_SIZE) {
for &b in chunk {
Device::tx_byte(b);
}
Device::flush();
}
}
pub fn flush() {}
}
}
#[inline]
fn with<R>(f: impl FnOnce() -> R) -> R {
#[cfg(feature = "critical-section")]
return critical_section::with(|_| f());
#[cfg(not(feature = "critical-section"))]
f()
}
+69
View File
@@ -0,0 +1,69 @@
use super::println;
#[cfg(not(host_is_windows))]
include!(concat!(env!("OUT_DIR"), "/log_filter.rs"));
#[cfg(host_is_windows)]
include!(concat!(env!("OUT_DIR"), "\\log_filter.rs"));
/// Initialize the logger with the given maximum log level.
///
/// `ESP_LOG` environment variable will still be honored if set.
pub fn init_logger(level: log::LevelFilter) {
unsafe {
log::set_logger_racy(&EspLogger).unwrap();
log::set_max_level_racy(level);
}
}
/// Initialize the logger from the `ESP_LOG` environment variable.
pub fn init_logger_from_env() {
unsafe {
log::set_logger_racy(&EspLogger).unwrap();
log::set_max_level_racy(FILTER_MAX);
}
}
struct EspLogger;
impl log::Log for EspLogger {
fn enabled(&self, metadata: &log::Metadata) -> bool {
let level = metadata.level();
let target = metadata.target();
is_enabled(level, target)
}
#[allow(unused)]
fn log(&self, record: &log::Record) {
if !self.enabled(&record.metadata()) {
return;
}
const RESET: &str = "\u{001B}[0m";
const RED: &str = "\u{001B}[31m";
const GREEN: &str = "\u{001B}[32m";
const YELLOW: &str = "\u{001B}[33m";
const BLUE: &str = "\u{001B}[34m";
const CYAN: &str = "\u{001B}[35m";
#[cfg(feature = "colors")]
let color = match record.level() {
log::Level::Error => RED,
log::Level::Warn => YELLOW,
log::Level::Info => GREEN,
log::Level::Debug => BLUE,
log::Level::Trace => CYAN,
};
#[cfg(feature = "colors")]
let reset = RESET;
#[cfg(not(feature = "colors"))]
let color = "";
#[cfg(not(feature = "colors"))]
let reset = "";
println!("{}{} - {}{}", color, record.level(), record.args(), reset);
}
fn flush(&self) {}
}