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

+81
View File
@@ -0,0 +1,81 @@
use proc_macro::TokenStream;
use proc_macro_error2::{abort, abort_call_site};
use quote::{quote, ToTokens};
use syn::{parse_macro_input, parse_quote, Data, DeriveInput};
mod codegen;
pub(crate) fn expand(input: TokenStream) -> TokenStream {
let DeriveInput {
attrs,
vis: _,
ident,
mut generics,
data,
} = parse_macro_input!(input as DeriveInput);
let defmt_path = match defmt_crate_path(&attrs) {
Ok(defmt_path) => defmt_path,
Err(e) => abort!(e),
};
let encode_data = match &data {
Data::Enum(data) => codegen::encode_enum_data(&ident, data, &defmt_path),
Data::Struct(data) => codegen::encode_struct_data(&ident, data, &defmt_path),
Data::Union(_) => abort_call_site!("`#[derive(Format)]` does not support unions"),
};
let codegen::EncodeData {
format_tag,
stmts,
where_predicates,
} = match encode_data {
Ok(data) => data,
Err(e) => return e.into_compile_error().into(),
};
let codegen::Generics {
impl_generics,
type_generics,
where_clause,
} = codegen::Generics::codegen(&mut generics, where_predicates);
quote!(
#[automatically_derived]
impl #impl_generics #defmt_path::Format for #ident #type_generics #where_clause {
fn format(&self, f: #defmt_path::Formatter) {
use #defmt_path as defmt;
#defmt_path::unreachable!()
}
fn _format_tag() -> #defmt_path::Str {
#format_tag
}
fn _format_data(&self) {
#(#stmts)*
}
}
)
.into()
}
fn defmt_crate_path(attrs: &[syn::Attribute]) -> Result<syn::Path, syn::Error> {
let mut defmt_path = parse_quote!(defmt);
let res = attrs
.iter()
.filter(|attr| attr.path().is_ident("defmt"))
.try_for_each(|attr| {
attr.parse_nested_meta(|meta| {
if meta.path.get_ident().is_some_and(|ident| ident == "crate") {
meta.input.parse::<syn::Token![=]>()?;
defmt_path = meta.input.parse::<syn::Path>()?;
Ok(())
} else {
let path = meta.path.to_token_stream().to_string().replace(' ', "");
Err(meta.error(format_args!("unknown defmt attribute `{path}`")))
}
})
});
res.map(|()| defmt_path)
}
+71
View File
@@ -0,0 +1,71 @@
use proc_macro2::TokenStream as TokenStream2;
use quote::quote;
use syn::{DataStruct, Ident, ImplGenerics, TypeGenerics, WhereClause, WherePredicate};
pub(crate) use enum_data::encode as encode_enum_data;
use crate::construct;
mod enum_data;
mod fields;
pub(crate) struct EncodeData {
pub(crate) format_tag: TokenStream2,
pub(crate) stmts: Vec<TokenStream2>,
pub(crate) where_predicates: Vec<WherePredicate>,
}
pub(crate) fn encode_struct_data(
ident: &Ident,
data: &DataStruct,
defmt_path: &syn::Path,
) -> syn::Result<EncodeData> {
let mut format_string = ident.to_string();
let mut stmts = vec![];
let mut field_patterns = vec![];
let (encode_fields_stmts, where_predicates) = fields::codegen(
&data.fields,
&mut format_string,
&mut field_patterns,
defmt_path,
)?;
stmts.push(quote!(match self {
Self { #(#field_patterns),* } => {
#(#encode_fields_stmts;)*
}
}));
let format_tag = construct::interned_string(&format_string, "derived", false, None, defmt_path);
Ok(EncodeData {
format_tag,
stmts,
where_predicates,
})
}
pub(crate) struct Generics<'a> {
pub(crate) impl_generics: ImplGenerics<'a>,
pub(crate) type_generics: TypeGenerics<'a>,
pub(crate) where_clause: WhereClause,
}
impl<'a> Generics<'a> {
pub(crate) fn codegen(
generics: &'a mut syn::Generics,
where_predicates: Vec<WherePredicate>,
) -> Self {
let mut where_clause = generics.make_where_clause().clone();
let (impl_generics, type_generics, _) = generics.split_for_impl();
// Extend where-clause with `Format` bounds for all field types.
where_clause.predicates.extend(where_predicates);
Self {
impl_generics,
type_generics,
where_clause,
}
}
}
@@ -0,0 +1,125 @@
use proc_macro2::TokenStream as TokenStream2;
use proc_macro_error2::abort_call_site;
use quote::quote;
use syn::{DataEnum, Ident};
use crate::construct;
use super::EncodeData;
pub(crate) fn encode(
ident: &Ident,
data: &DataEnum,
defmt_path: &syn::Path,
) -> syn::Result<EncodeData> {
if data.variants.is_empty() {
return Ok(EncodeData {
stmts: vec![quote!(match *self {})],
format_tag: construct::interned_string("!", "derived", false, None, defmt_path),
where_predicates: vec![],
});
}
let mut format_string = String::new();
let mut where_predicates = vec![];
let mut match_arms = vec![];
let mut is_first_variant = true;
let discriminant_encoder = DiscriminantEncoder::new(data.variants.len());
let enum_ident = ident;
for (index, variant) in data.variants.iter().enumerate() {
let variant_ident = &variant.ident;
if is_first_variant {
is_first_variant = false;
} else {
format_string.push('|');
}
format_string.push_str(&variant_ident.to_string());
let mut field_patterns = vec![];
let (encode_fields_stmts, encode_field_where_predicates) = super::fields::codegen(
&variant.fields,
&mut format_string,
&mut field_patterns,
defmt_path,
)?;
where_predicates.extend(encode_field_where_predicates.into_iter());
let pattern = quote!( { #(#field_patterns),* } );
let encode_discriminant_stmt = discriminant_encoder.encode(index, defmt_path);
match_arms.push(quote!(
#enum_ident::#variant_ident #pattern => {
#encode_discriminant_stmt
#(#encode_fields_stmts;)*
}
))
}
let format_tag = construct::interned_string(&format_string, "derived", false, None, defmt_path);
let stmts = vec![quote!(match self {
#(#match_arms)*
})];
where_predicates.dedup_by(|a, b| a == b);
Ok(EncodeData {
format_tag,
stmts,
where_predicates,
})
}
enum DiscriminantEncoder {
Nop,
U8,
U16,
U32,
U64,
}
impl DiscriminantEncoder {
fn new(number_of_variants: usize) -> Self {
if number_of_variants == 1 {
Self::Nop
} else if number_of_variants <= usize::from(u8::MAX) {
Self::U8
} else if number_of_variants <= usize::from(u16::MAX) {
Self::U16
} else if number_of_variants as u128 > u128::from(u64::MAX) {
// unreachable on existing hardware?
abort_call_site!(
"`#[derive(Format)]` does not support enums with more than {} variants",
number_of_variants
)
} else if number_of_variants as u64 <= u64::from(u32::MAX) {
Self::U32
} else {
Self::U64
}
}
// NOTE this assumes `index` < `number_of_variants` used to construct `self`
fn encode(&self, index: usize, defmt_path: &syn::Path) -> TokenStream2 {
match self {
// For single-variant enums, there is no need to encode the discriminant.
DiscriminantEncoder::Nop => quote!(),
DiscriminantEncoder::U8 => {
let index = index as u8;
quote!(#defmt_path::export::u8(&#index);)
}
DiscriminantEncoder::U16 => {
let index = index as u16;
quote!(#defmt_path::export::u16(&#index);)
}
DiscriminantEncoder::U32 => {
let index = index as u32;
quote!(#defmt_path::export::u32(&#index);)
}
DiscriminantEncoder::U64 => {
let index = index as u64;
quote!(#defmt_path::export::u64(&#index);)
}
}
}
}
+169
View File
@@ -0,0 +1,169 @@
use std::fmt::Write as _;
use proc_macro2::TokenStream as TokenStream2;
use quote::{format_ident, quote};
use syn::{parse_quote, Field, Fields, Index, Type, WherePredicate};
use crate::consts;
pub(crate) fn codegen(
fields: &Fields,
format_string: &mut String,
patterns: &mut Vec<TokenStream2>,
defmt_path: &syn::Path,
) -> syn::Result<(Vec<TokenStream2>, Vec<WherePredicate>)> {
let (fields, fields_are_named) = match fields {
Fields::Named(named) => (&named.named, true),
Fields::Unit => return Ok((vec![], vec![])),
Fields::Unnamed(unnamed) => (&unnamed.unnamed, false),
};
if fields.is_empty() {
return Ok((vec![], vec![]));
}
if fields_are_named {
format_string.push_str(" {{ ");
} else {
format_string.push('(');
}
let mut stmts = vec![];
let mut where_predicates = vec![];
let mut is_first = true;
for (index, field) in fields.iter().enumerate() {
if is_first {
is_first = false;
} else {
format_string.push_str(", ");
}
let format_opt = get_defmt_format_option(field)?;
// Find out if the field type is natively supported by defmt. `ty` will be None if not.
let ty = as_native_type(&field.ty);
// `field_ty` will be the field's type if it is not natively supported by defmt
let field_ty = if ty.is_none() { Some(&field.ty) } else { None };
// Get the field format specifier. Either the native specifier or '?'.
let ty = ty.unwrap_or_else(|| consts::TYPE_FORMAT.to_string());
let ident = field
.ident
.clone()
.unwrap_or_else(|| format_ident!("arg{}", index));
// Find the required trait bounds for the field and add the formatting statement depending on the field type and the formatting options
let bound: Option<syn::Path> = if let Some(FormatOption::Debug2Format) = format_opt {
stmts.push(quote!(#defmt_path::export::fmt(&#defmt_path::Debug2Format(&#ident))));
field_ty.map(|_| parse_quote!(::core::fmt::Debug))
} else if let Some(FormatOption::Display2Format) = format_opt {
stmts.push(quote!(#defmt_path::export::fmt(&#defmt_path::Display2Format(&#ident))));
field_ty.map(|_| parse_quote!(::core::fmt::Display))
} else if ty == consts::TYPE_FORMAT {
stmts.push(quote!(#defmt_path::export::fmt(#ident)));
field_ty.map(|_| parse_quote!(#defmt_path::Format))
} else {
let method = format_ident!("{}", ty);
stmts.push(quote!(#defmt_path::export::#method(#ident)));
field_ty.map(|_| parse_quote!(#defmt_path::Format))
};
if let Some(bound) = bound {
where_predicates.push(parse_quote!(#field_ty: #bound));
}
if field.ident.is_some() {
// Named field.
write!(format_string, "{ident}: {{={ty}:?}}").ok();
patterns.push(quote!( #ident ));
} else {
// Unnamed (tuple) field.
write!(format_string, "{{={ty}}}").ok();
let index = Index::from(index);
patterns.push(quote!( #index: #ident ));
}
}
if fields_are_named {
format_string.push_str(" }}");
} else {
format_string.push(')');
}
Ok((stmts, where_predicates))
}
#[derive(Copy, Clone, Eq, PartialEq, Debug)]
enum FormatOption {
Debug2Format,
Display2Format,
}
/// If the field has a valid defmt attribute (e.g. `#[defmt(Debug2Format)]`), returns `Ok(Some(FormatOption))`.
/// Returns `Err` if we can't parse a valid defmt attribute.
/// Returns `Ok(None)` if there are no `defmt` attributes on the field.
fn get_defmt_format_option(field: &Field) -> syn::Result<Option<FormatOption>> {
let mut format_option = None;
for attr in &field.attrs {
if attr.path().is_ident("defmt") {
if format_option.is_some() {
return Err(syn::Error::new_spanned(
field,
"multiple `defmt` attributes not supported",
));
}
let mut parsed_format = None;
attr.parse_nested_meta(|meta| {
// #[defmt(Debug2Format)]
if meta.path.is_ident("Debug2Format") {
parsed_format = Some(FormatOption::Debug2Format);
return Ok(());
}
// #[defmt(Display2Format)]
if meta.path.is_ident("Display2Format") {
parsed_format = Some(FormatOption::Display2Format);
return Ok(());
}
Err(meta.error("expected `Debug2Format` or `Display2Format`"))
})?;
if parsed_format.is_none() {
return Err(syn::Error::new_spanned(
&attr.meta,
"expected 1 attribute argument",
));
}
format_option = parsed_format;
}
}
Ok(format_option)
}
/// Returns `Some` if `ty` refers to a builtin Rust type that has native support from defmt and does
/// not have to go through the `Format` trait.
///
/// This should return `Some` for all types that can be used as `{=TYPE}`.
///
/// Note: This is technically incorrect, since builtin types can be shadowed. However the efficiency
/// gains are too big to pass up, so we expect user code to not do that.
fn as_native_type(ty: &Type) -> Option<String> {
match ty {
Type::Path(path) => {
let ident = path.path.get_ident()?;
let ty_name = ident.to_string();
match &*ty_name {
"u8" | "u16" | "u32" | "u64" | "u128" | "usize" | "i8" | "i16" | "i32" | "i64"
| "i128" | "isize" | "f32" | "f64" | "bool" | "str" => Some(ty_name),
_ => None,
}
}
Type::Reference(ty_ref) => as_native_type(&ty_ref.elem),
_ => None,
}
}