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

+685
View File
@@ -0,0 +1,685 @@
use std::{
collections::HashMap,
env,
fmt::{self, Write as _},
fs,
ops::Range,
path::PathBuf,
};
const DOC_TABLE_HEADER: &str = r#"
| Name | Description | Default value |
|------|-------------|---------------|
"#;
const SELECTED_TABLE_HEADER: &str = r#"
| Name | Selected value |
|------|----------------|
"#;
/// Configuration errors.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Error {
/// Parse errors.
Parse(String),
/// Validation errors.
Validation(String),
}
impl Error {
/// Convenience function for creating parse errors.
pub fn parse<S>(message: S) -> Self
where
S: Into<String>,
{
Self::Parse(message.into())
}
/// Convenience function for creating validation errors.
pub fn validation<S>(message: S) -> Self
where
S: Into<String>,
{
Self::Validation(message.into())
}
}
impl fmt::Display for Error {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Error::Parse(message) => write!(f, "{message}"),
Error::Validation(message) => write!(f, "{message}"),
}
}
}
/// Supported configuration value types.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Value {
/// Booleans.
Bool(bool),
/// Integers.
Integer(i128),
/// Strings.
String(String),
}
// TODO: Do we want to handle negative values for non-decimal values?
impl Value {
fn parse_in_place(&mut self, s: &str) -> Result<(), Error> {
*self = match self {
Value::Bool(_) => match s {
"true" => Value::Bool(true),
"false" => Value::Bool(false),
_ => {
return Err(Error::parse(format!(
"Expected 'true' or 'false', found: '{s}'"
)))
}
},
Value::Integer(_) => {
let inner = match s.as_bytes() {
[b'0', b'x', ..] => i128::from_str_radix(&s[2..], 16),
[b'0', b'o', ..] => i128::from_str_radix(&s[2..], 8),
[b'0', b'b', ..] => i128::from_str_radix(&s[2..], 2),
_ => i128::from_str_radix(&s, 10),
}
.map_err(|_| Error::parse(format!("Expected valid intger value, found: '{s}'")))?;
Value::Integer(inner)
}
Value::String(_) => Value::String(s.into()),
};
Ok(())
}
/// Convert the value to a [bool].
pub fn as_bool(&self) -> bool {
match self {
Value::Bool(value) => *value,
_ => panic!("attempted to convert non-bool value to a bool"),
}
}
/// Convert the value to an [i128].
pub fn as_integer(&self) -> i128 {
match self {
Value::Integer(value) => *value,
_ => panic!("attempted to convert non-integer value to an integer"),
}
}
/// Convert the value to a [String].
pub fn as_string(&self) -> String {
match self {
Value::String(value) => value.to_owned(),
_ => panic!("attempted to convert non-string value to a string"),
}
}
/// Is the value a bool?
pub fn is_bool(&self) -> bool {
matches!(self, Value::Bool(_))
}
/// Is the value an integer?
pub fn is_integer(&self) -> bool {
matches!(self, Value::Integer(_))
}
/// Is the value a string?
pub fn is_string(&self) -> bool {
matches!(self, Value::String(_))
}
}
impl fmt::Display for Value {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Value::Bool(b) => write!(f, "{b}"),
Value::Integer(i) => write!(f, "{i}"),
Value::String(s) => write!(f, "{s}"),
}
}
}
/// Configuration value validation functions.
pub enum Validator {
/// Only allow negative integers, i.e. any values less than 0.
NegativeInteger,
/// Only allow non-negative integers, i.e. any values greater than or equal
/// to 0.
NonNegativeInteger,
/// Only allow positive integers, i.e. any values greater than to 0.
PositiveInteger,
/// Ensure that an integer value falls within the specified range.
IntegerInRange(Range<i128>),
/// A custom validation function to run against any supported value type.
Custom(Box<dyn Fn(&Value) -> Result<(), Error>>),
}
impl Validator {
fn validate(&self, value: &Value) -> Result<(), Error> {
match self {
Validator::NegativeInteger => negative_integer(value)?,
Validator::NonNegativeInteger => non_negative_integer(value)?,
Validator::PositiveInteger => positive_integer(value)?,
Validator::IntegerInRange(range) => integer_in_range(range, value)?,
Validator::Custom(validator_fn) => validator_fn(value)?,
}
Ok(())
}
}
fn negative_integer(value: &Value) -> Result<(), Error> {
if !value.is_integer() {
return Err(Error::validation(
"Validator::NegativeInteger can only be used with integer values",
));
} else if value.as_integer() >= 0 {
return Err(Error::validation(format!(
"Expected negative integer, found '{}'",
value.as_integer()
)));
}
Ok(())
}
fn non_negative_integer(value: &Value) -> Result<(), Error> {
if !value.is_integer() {
return Err(Error::validation(
"Validator::NonNegativeInteger can only be used with integer values",
));
} else if value.as_integer() < 0 {
return Err(Error::validation(format!(
"Expected non-negative integer, found '{}'",
value.as_integer()
)));
}
Ok(())
}
fn positive_integer(value: &Value) -> Result<(), Error> {
if !value.is_integer() {
return Err(Error::validation(
"Validator::PositiveInteger can only be used with integer values",
));
} else if value.as_integer() <= 0 {
return Err(Error::validation(format!(
"Expected positive integer, found '{}'",
value.as_integer()
)));
}
Ok(())
}
fn integer_in_range(range: &Range<i128>, value: &Value) -> Result<(), Error> {
if !value.is_integer() || !range.contains(&value.as_integer()) {
Err(Error::validation(format!(
"Value '{}' does not fall within range '{:?}'",
value, range
)))
} else {
Ok(())
}
}
/// Generate and parse config from a prefix, and an array tuples containing the
/// name, description, default value, and an optional validator.
///
/// This function will parse any `SCREAMING_SNAKE_CASE` environment variables
/// that match the given prefix. It will then attempt to parse the [`Value`] and
/// run any validators which have been specified.
///
/// Once the config has been parsed, this function will emit `snake_case` cfg's
/// _without_ the prefix which can be used in the dependant crate. After that,
/// it will create a markdown table in the `OUT_DIR` under the name
/// `{prefix}_config_table.md` where prefix has also been converted to
/// `snake_case`. This can be included in crate documentation to outline the
/// available configuration options for the crate.
///
/// Passing a value of true for the `emit_md_tables` argument will create and
/// write markdown files of the available configuration and selected
/// configuration which can be included in documentation.
///
/// Unknown keys with the supplied prefix will cause this function to panic.
pub fn generate_config(
prefix: &str,
config: &[(&str, &str, Value, Option<Validator>)],
emit_md_tables: bool,
) -> HashMap<String, Value> {
// Only rebuild if `build.rs` changed. Otherwise, Cargo will rebuild if any
// other file changed.
println!("cargo:rerun-if-changed=build.rs");
#[cfg(not(test))]
env_change_work_around();
let mut doc_table = String::from(DOC_TABLE_HEADER);
let mut selected_config = String::from(SELECTED_TABLE_HEADER);
// Ensure that the prefix is `SCREAMING_SNAKE_CASE`:
let prefix = screaming_snake_case(prefix);
// Build a lookup table for any provided validators; we must prefix the
// name of the config and transform it to SCREAMING_SNAKE_CASE so that
// it matches the keys in the hash table produced by `create_config`.
let config_validators = config
.iter()
.flat_map(|(name, _description, _default, validator)| {
if let Some(validator) = validator {
let name = format!("{prefix}_{}", screaming_snake_case(name));
Some((name, validator))
} else {
None
}
})
.collect::<HashMap<_, _>>();
let mut configs = create_config(&prefix, config, &mut doc_table);
capture_from_env(&prefix, &mut configs);
for (name, value) in configs.iter() {
if let Some(validator) = config_validators.get(name) {
validator.validate(value).unwrap();
}
}
emit_configuration(&prefix, &configs, &mut selected_config);
if emit_md_tables {
let file_name = snake_case(&prefix);
write_config_tables(&file_name, doc_table, selected_config);
}
configs
}
// A work-around for https://github.com/rust-lang/cargo/issues/10358
// This can be removed when https://github.com/rust-lang/cargo/pull/14058 is merged.
// Unlikely to work on projects in workspaces
#[cfg(not(test))]
fn env_change_work_around() {
let mut out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
// We clean out_dir by removing all trailing directories, until it ends with
// target
while !out_dir.ends_with("target") {
if !out_dir.pop() {
return; // We ran out of directories...
}
}
out_dir.pop();
let dotcargo = out_dir.join(".cargo/");
if dotcargo.exists() {
if dotcargo.join("config.toml").exists() {
println!(
"cargo:rerun-if-changed={}",
dotcargo.join("config.toml").display()
);
}
if dotcargo.join("config").exists() {
println!(
"cargo:rerun-if-changed={}",
dotcargo.join("config").display()
);
}
}
}
fn create_config(
prefix: &str,
config: &[(&str, &str, Value, Option<Validator>)],
doc_table: &mut String,
) -> HashMap<String, Value> {
let mut configs = HashMap::new();
for (name, description, default, _validator) in config {
let name = format!("{prefix}_{}", screaming_snake_case(name));
configs.insert(name.clone(), default.clone());
// Write documentation table line:
let default = default.to_string();
writeln!(doc_table, "|**{name}**|{description}|{default}|").unwrap();
// Rebuild if config environment variable changed:
println!("cargo:rerun-if-env-changed={name}");
}
configs
}
fn capture_from_env(prefix: &str, configs: &mut HashMap<String, Value>) {
let mut unknown = Vec::new();
let mut failed = Vec::new();
// Try and capture input from the environment:
for (var, value) in env::vars() {
if var.strip_prefix(prefix).is_some() {
let Some(cfg) = configs.get_mut(&var) else {
unknown.push(var);
continue;
};
if let Err(e) = cfg.parse_in_place(&value) {
failed.push(format!("{var}: {e}"));
}
}
}
if !failed.is_empty() {
panic!("Invalid configuration options detected: {:?}", failed);
}
if !unknown.is_empty() {
panic!("Unknown configuration options detected: {:?}", unknown);
}
}
fn emit_configuration(
prefix: &str,
configs: &HashMap<String, Value>,
selected_config: &mut String,
) {
for (name, value) in configs.iter() {
let cfg_name = snake_case(name.trim_start_matches(&format!("{prefix}_")));
println!("cargo:rustc-check-cfg=cfg({cfg_name})");
if let Value::Bool(true) = value {
println!("cargo:rustc-cfg={cfg_name}");
}
let value = value.to_string();
// Values that haven't been seen will be output here with the default value:
println!("cargo:rustc-env={}={}", name, value);
writeln!(selected_config, "|**{name}**|{value}|").unwrap();
}
}
fn write_config_tables(prefix: &str, doc_table: String, selected_config: String) {
let out_dir = PathBuf::from(env::var_os("OUT_DIR").unwrap());
let out_file = out_dir
.join(format!("{prefix}_config_table.md"))
.display()
.to_string();
fs::write(out_file, doc_table).unwrap();
let out_file = out_dir
.join(format!("{prefix}_selected_config.md"))
.display()
.to_string();
fs::write(out_file, selected_config).unwrap();
}
fn snake_case(name: &str) -> String {
let mut name = name.replace("-", "_");
name.make_ascii_lowercase();
name
}
fn screaming_snake_case(name: &str) -> String {
let mut name = name.replace("-", "_");
name.make_ascii_uppercase();
name
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn value_number_formats() {
const INPUTS: &[&str] = &["0xAA", "0o252", "0b0000000010101010", "170"];
let mut v = Value::Integer(0);
for input in INPUTS {
v.parse_in_place(input).unwrap();
// no matter the input format, the output format should be decimal
assert_eq!(format!("{v}"), "170");
}
}
#[test]
fn value_bool_inputs() {
let mut v = Value::Bool(false);
v.parse_in_place("true").unwrap();
assert_eq!(format!("{v}"), "true");
v.parse_in_place("false").unwrap();
assert_eq!(format!("{v}"), "false");
}
#[test]
fn env_override() {
temp_env::with_vars(
[
("ESP_TEST_NUMBER", Some("0xaa")),
("ESP_TEST_NUMBER_SIGNED", Some("-999")),
("ESP_TEST_STRING", Some("Hello world!")),
("ESP_TEST_BOOL", Some("true")),
],
|| {
let configs = generate_config(
"esp-test",
&[
("number", "NA", Value::Integer(999), None),
("number_signed", "NA", Value::Integer(-777), None),
("string", "NA", Value::String("Demo".to_owned()), None),
("bool", "NA", Value::Bool(false), None),
("number_default", "NA", Value::Integer(999), None),
(
"string_default",
"NA",
Value::String("Demo".to_owned()),
None,
),
("bool_default", "NA", Value::Bool(false), None),
],
false,
);
// some values have changed
assert_eq!(
match configs.get("ESP_TEST_NUMBER").unwrap() {
Value::Integer(num) => *num,
_ => unreachable!(),
},
0xaa
);
assert_eq!(
match configs.get("ESP_TEST_NUMBER_SIGNED").unwrap() {
Value::Integer(num) => *num,
_ => unreachable!(),
},
-999
);
assert_eq!(
match configs.get("ESP_TEST_STRING").unwrap() {
Value::String(val) => val,
_ => unreachable!(),
},
"Hello world!"
);
assert_eq!(
match configs.get("ESP_TEST_BOOL").unwrap() {
Value::Bool(val) => *val,
_ => unreachable!(),
},
true
);
// the rest are the defaults
assert_eq!(
match configs.get("ESP_TEST_NUMBER_DEFAULT").unwrap() {
Value::Integer(num) => *num,
_ => unreachable!(),
},
999
);
assert_eq!(
match configs.get("ESP_TEST_STRING_DEFAULT").unwrap() {
Value::String(val) => val,
_ => unreachable!(),
},
"Demo"
);
assert_eq!(
match configs.get("ESP_TEST_BOOL_DEFAULT").unwrap() {
Value::Bool(val) => *val,
_ => unreachable!(),
},
false
);
},
)
}
#[test]
fn builtin_validation_passes() {
temp_env::with_vars(
[
("ESP_TEST_POSITIVE_NUMBER", Some("7")),
("ESP_TEST_NEGATIVE_NUMBER", Some("-1")),
("ESP_TEST_NON_NEGATIVE_NUMBER", Some("0")),
("ESP_TEST_RANGE", Some("9")),
],
|| {
generate_config(
"esp-test",
&[
(
"positive_number",
"NA",
Value::Integer(-1),
Some(Validator::PositiveInteger),
),
(
"negative_number",
"NA",
Value::Integer(1),
Some(Validator::NegativeInteger),
),
(
"non_negative_number",
"NA",
Value::Integer(-1),
Some(Validator::NonNegativeInteger),
),
(
"range",
"NA",
Value::Integer(0),
Some(Validator::IntegerInRange(5..10)),
),
],
false,
)
},
);
}
#[test]
fn custom_validation_passes() {
temp_env::with_vars([("ESP_TEST_NUMBER", Some("13"))], || {
generate_config(
"esp-test",
&[(
"number",
"NA",
Value::Integer(-1),
Some(Validator::Custom(Box::new(|value| {
let range = 10..20;
if !value.is_integer() || !range.contains(&value.as_integer()) {
Err(Error::validation("value does not fall within range"))
} else {
Ok(())
}
}))),
)],
false,
)
});
}
#[test]
#[should_panic]
fn builtin_validation_bails() {
temp_env::with_vars([("ESP_TEST_POSITIVE_NUMBER", Some("-99"))], || {
generate_config(
"esp-test",
&[(
"positive_number",
"NA",
Value::Integer(-1),
Some(Validator::PositiveInteger),
)],
false,
)
});
}
#[test]
#[should_panic]
fn custom_validation_bails() {
temp_env::with_vars([("ESP_TEST_NUMBER", Some("37"))], || {
generate_config(
"esp-test",
&[(
"number",
"NA",
Value::Integer(-1),
Some(Validator::Custom(Box::new(|value| {
let range = 10..20;
if !value.is_integer() || !range.contains(&value.as_integer()) {
Err(Error::validation("value does not fall within range"))
} else {
Ok(())
}
}))),
)],
false,
)
});
}
#[test]
#[should_panic]
fn env_unknown_bails() {
temp_env::with_vars(
[
("ESP_TEST_NUMBER", Some("0xaa")),
("ESP_TEST_RANDOM_VARIABLE", Some("")),
],
|| {
generate_config(
"esp-test",
&[("number", "NA", Value::Integer(999), None)],
false,
);
},
);
}
#[test]
#[should_panic]
fn env_invalid_values_bails() {
temp_env::with_vars([("ESP_TEST_NUMBER", Some("Hello world"))], || {
generate_config(
"esp-test",
&[("number", "NA", Value::Integer(999), None)],
false,
);
});
}
}
+110
View File
@@ -0,0 +1,110 @@
#![doc = include_str!("../README.md")]
//! ## Feature Flags
#![doc = document_features::document_features!(feature_label = r#"<span class="stab portability"><code>{feature}</code></span>"#)]
#![doc(html_logo_url = "https://avatars.githubusercontent.com/u/46717278")]
#![cfg_attr(not(feature = "build"), no_std)]
#![deny(missing_docs, rust_2018_idioms)]
#[cfg(feature = "build")]
mod generate;
#[cfg(feature = "build")]
pub use generate::{generate_config, Error, Validator, Value};
/// Parse the value of an environment variable as a [bool] at compile time.
#[macro_export]
macro_rules! esp_config_bool {
( $var:expr ) => {
match env!($var).as_bytes() {
b"true" => true,
b"false" => false,
_ => ::core::panic!("boolean value must be either 'true' or 'false'"),
}
};
}
// TODO: From 1.82 on, we can use `<$ty>::from_str_radix(env!($var), 10)`
/// Parse the value of an environment variable as an integer at compile time.
#[macro_export]
macro_rules! esp_config_int {
( $ty:ty, $var:expr ) => {
const {
const BYTES: &[u8] = env!($var).as_bytes();
esp_config_int_parse!($ty, BYTES)
}
};
}
/// Get the string value of an environment variable at compile time.
#[macro_export]
macro_rules! esp_config_str {
( $var:expr ) => {
env!($var)
};
}
/// Parse a string like "777" into an integer, which _can_ be used in a `const`
/// context
#[doc(hidden)] // To avoid confusion with `esp_config_int`, hide this in the docs
#[macro_export]
macro_rules! esp_config_int_parse {
( $ty:ty, $bytes:expr ) => {{
let mut bytes = $bytes;
let mut val: $ty = 0;
let mut sign_seen = false;
let mut is_negative = false;
while let [byte, rest @ ..] = bytes {
match *byte {
b'0'..=b'9' => {
val = val * 10 + (*byte - b'0') as $ty;
}
b'-' | b'+' if !sign_seen => {
is_negative = *byte == b'-';
sign_seen = true;
}
_ => ::core::panic!("invalid character encountered while parsing integer"),
}
bytes = rest;
}
if is_negative {
let original = val;
// Subtract the value twice to get a negative:
val -= original;
val -= original;
}
val
}};
}
#[cfg(test)]
mod test {
// We can only test success in the const context
const _: () = {
core::assert!(esp_config_int_parse!(i64, "-77777".as_bytes()) == -77777);
core::assert!(esp_config_int_parse!(isize, "-7777".as_bytes()) == -7777);
core::assert!(esp_config_int_parse!(i32, "-999".as_bytes()) == -999);
core::assert!(esp_config_int_parse!(i16, "-99".as_bytes()) == -99);
core::assert!(esp_config_int_parse!(i8, "-9".as_bytes()) == -9);
core::assert!(esp_config_int_parse!(u64, "77777".as_bytes()) == 77777);
core::assert!(esp_config_int_parse!(usize, "7777".as_bytes()) == 7777);
core::assert!(esp_config_int_parse!(u32, "999".as_bytes()) == 999);
core::assert!(esp_config_int_parse!(u16, "99".as_bytes()) == 99);
core::assert!(esp_config_int_parse!(u8, "9".as_bytes()) == 9);
};
#[test]
#[should_panic]
fn test_expect_positive() {
esp_config_int_parse!(u8, "-5".as_bytes());
}
#[test]
#[should_panic]
fn test_invalid_digit() {
esp_config_int_parse!(u32, "a".as_bytes());
}
}