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

+34
View File
@@ -0,0 +1,34 @@
use criterion::*;
use embedded_graphics::{
pixelcolor::{Gray8, Rgb555, Rgb565, Rgb888},
prelude::*,
};
fn impl_bench<CFrom, CTo>(c: &mut Criterion, name: &str)
where
CFrom: PixelColor + Default + Into<CTo>,
CTo: PixelColor + Default,
{
c.bench_function(name, |b| {
let input = &[CFrom::default(); 320 * 240];
let mut output = [CTo::default(); 320 * 240];
b.iter(|| {
for (i, o) in black_box(input).iter().copied().zip(output.iter_mut()) {
*o = i.into();
}
})
});
}
fn color_conversions(c: &mut Criterion) {
impl_bench::<Rgb555, Rgb565>(c, "Rgb555 to Rgb565");
impl_bench::<Rgb565, Rgb555>(c, "Rgb565 to Rgb555");
impl_bench::<Rgb565, Rgb888>(c, "Rgb565 to Rgb888");
impl_bench::<Rgb888, Rgb565>(c, "Rgb888 to Rgb565");
impl_bench::<Gray8, Rgb888>(c, "Gray8 to Rgb888");
impl_bench::<Rgb888, Gray8>(c, "Rgb888 to Gray8");
}
criterion_group!(benches, color_conversions);
criterion_main!(benches);
+58
View File
@@ -0,0 +1,58 @@
use embedded_graphics::prelude::*;
use std::convert::TryFrom;
const SIZE: usize = 256;
pub struct Framebuffer<C> {
pixels: [[C; SIZE]; SIZE],
}
impl<C> Framebuffer<C>
where
C: PixelColor + Default,
{
pub fn new() -> Self {
Self {
pixels: [[C::default(); 256]; 256],
}
}
fn set_pixel(&mut self, position: Point, color: C) {
if let (Ok(x), Ok(y)) = (usize::try_from(position.x), usize::try_from(position.y)) {
self.pixels
.get_mut(y)
.and_then(|row| row.get_mut(x))
.map(|pixel| *pixel = color);
} else {
panic!(
"tried to set pixel outside the framebuffer at {:?}",
position
);
}
}
}
impl<C> DrawTarget for Framebuffer<C>
where
C: PixelColor + Default,
{
type Color = C;
type Error = core::convert::Infallible;
fn draw_iter<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
for Pixel(position, color) in pixels {
self.set_pixel(position, color);
}
Ok(())
}
}
impl<C> OriginDimensions for Framebuffer<C> {
fn size(&self) -> Size {
Size::new_equal(SIZE as u32)
}
}
+28
View File
@@ -0,0 +1,28 @@
use criterion::*;
use embedded_graphics::{prelude::*, primitives::*};
fn triangle_contains_inside(c: &mut Criterion) {
c.bench_function("triangle contains point (inside)", |b| {
let object = Triangle::new(Point::new(5, 10), Point::new(15, 20), Point::new(5, 20));
let midpoint = object.bounding_box().center();
b.iter(|| object.contains(midpoint))
});
}
// Point outside triangle but still within bounding box. If point is outside bounding box, this
// benchmark doesn't exercise the expensive point-in-triangle code.
fn triangle_contains_outside(c: &mut Criterion) {
c.bench_function("triangle contains point (outside)", |b| {
let object = Triangle::new(Point::new(5, 10), Point::new(55, 20), Point::new(30, 40));
b.iter(|| object.contains(Point::new(50, 13)))
});
}
criterion_group!(
contains,
triangle_contains_inside,
triangle_contains_outside
);
criterion_main!(contains);
+108
View File
@@ -0,0 +1,108 @@
use criterion::*;
use embedded_graphics::{
geometry::Point,
mono_font::{
ascii::{FONT_10X20, FONT_6X9},
MonoTextStyle, MonoTextStyleBuilder,
},
pixelcolor::Gray8,
prelude::*,
text::Text,
};
mod common;
use common::Framebuffer;
fn one_line<S>(style: S) -> Text<'static, S> {
Text::new("Hello world!", Point::new_equal(20), style)
}
fn three_lines<S>(style: S) -> Text<'static, S> {
Text::new("line 1\nl2\nThis is line 3", Point::new_equal(20), style)
}
fn font_6x9(c: &mut Criterion) {
let mut group = c.benchmark_group("font 6x9");
let style = MonoTextStyle::new(&FONT_6X9, Gray8::WHITE);
let style_with_bg = MonoTextStyleBuilder::new()
.font(&FONT_6X9)
.text_color(Gray8::WHITE)
.background_color(Gray8::BLACK)
.build();
group.bench_function("one line", |b| {
let object = one_line(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("one line with background", |b| {
let object = one_line(style_with_bg);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("three lines", |b| {
let object = three_lines(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("three lines with background)", |b| {
let object = three_lines(style_with_bg);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.finish();
}
fn font_10x20(c: &mut Criterion) {
let mut group = c.benchmark_group("font 10x20");
let style = MonoTextStyle::new(&FONT_10X20, Gray8::WHITE);
let style_with_bg = MonoTextStyleBuilder::new()
.font(&FONT_10X20)
.text_color(Gray8::WHITE)
.background_color(Gray8::BLACK)
.build();
group.bench_function("one line", |b| {
let object = one_line(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("one line with background", |b| {
let object = one_line(style_with_bg);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("three lines", |b| {
let object = three_lines(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("three lines with background)", |b| {
let object = three_lines(style_with_bg);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.finish();
}
criterion_group!(fonts, font_6x9, font_10x20);
criterion_main!(fonts);
+112
View File
@@ -0,0 +1,112 @@
use criterion::*;
use embedded_graphics::{
framebuffer::{buffer_size, Framebuffer},
geometry::Point,
image::GetPixel,
pixelcolor::{raw::LittleEndian, BinaryColor, Rgb565},
prelude::{DrawTarget, Size, WebColors},
primitives::{Primitive, PrimitiveStyle, Rectangle},
};
fn framebuffer_set_1bpp(c: &mut Criterion) {
c.bench_function("framebuffer set pixel 1bpp", |b| {
let mut fb = Framebuffer::<
BinaryColor,
_,
LittleEndian,
320,
240,
{ buffer_size::<BinaryColor>(320, 240) },
>::new();
b.iter(|| {
fb.set_pixel(Point::new(1, 1), BinaryColor::On);
fb.set_pixel(Point::new(300, 200), BinaryColor::On);
})
});
}
fn framebuffer_get_1bpp(c: &mut Criterion) {
c.bench_function("framebuffer get pixel 1bpp", |b| {
let fb = Framebuffer::<
BinaryColor,
_,
LittleEndian,
320,
240,
{ buffer_size::<BinaryColor>(320, 240) },
>::new();
b.iter(|| {
fb.pixel(Point::new(1, 1));
fb.pixel(Point::new(300, 200));
})
});
}
fn framebuffer_1bpp_draw_iter(c: &mut Criterion) {
c.bench_function("framebuffer 1bpp draw iter", |b| {
let mut fb = Framebuffer::<
BinaryColor,
_,
LittleEndian,
320,
240,
{ buffer_size::<BinaryColor>(320, 240) },
>::new();
b.iter(|| {
let rect = Rectangle::new(Point::new(20, 30), Size::new(40, 50))
.into_styled(PrimitiveStyle::with_fill(BinaryColor::On))
.pixels();
fb.draw_iter(rect).unwrap();
})
});
}
fn framebuffer_set_rgb565(c: &mut Criterion) {
c.bench_function("framebuffer set pixel rgb565", |b| {
let mut fb = Framebuffer::<
Rgb565,
_,
LittleEndian,
320,
240,
{ buffer_size::<Rgb565>(320, 240) },
>::new();
b.iter(|| {
fb.set_pixel(Point::new(1, 1), Rgb565::CSS_DARK_SALMON);
fb.set_pixel(Point::new(300, 200), Rgb565::CSS_TEAL);
})
});
}
fn framebuffer_get_rgb565(c: &mut Criterion) {
c.bench_function("framebuffer get pixel rgb565", |b| {
let fb = Framebuffer::<
Rgb565,
_,
LittleEndian,
320,
240,
{ buffer_size::<Rgb565>(320, 240) },
>::new();
b.iter(|| {
fb.pixel(Point::new(1, 1));
fb.pixel(Point::new(300, 200));
})
});
}
criterion_group!(
framebuffer,
framebuffer_set_1bpp,
framebuffer_get_1bpp,
framebuffer_set_rgb565,
framebuffer_get_rgb565,
framebuffer_1bpp_draw_iter
);
criterion_main!(framebuffer);
+25
View File
@@ -0,0 +1,25 @@
use criterion::*;
use embedded_graphics::{
image::{Image, ImageRaw},
pixelcolor::BinaryColor,
prelude::*,
};
mod common;
use common::Framebuffer;
fn image_1bpp(c: &mut Criterion) {
c.bench_function("image 4x4px", |b| {
let bytes = include_bytes!("../assets/patch_1bpp.raw");
let image: ImageRaw<BinaryColor> = ImageRaw::new(bytes, 4);
let object = Image::new(&image, Point::zero());
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
}
criterion_group!(images, image_1bpp);
criterion_main!(images);
+239
View File
@@ -0,0 +1,239 @@
use criterion::*;
use embedded_graphics::{geometry::AnchorPoint, pixelcolor::Gray8, prelude::*, primitives::*};
mod common;
use common::Framebuffer;
const BOUNDING_BOX: Rectangle = Rectangle::new(Point::new_equal(32), Size::new_equal(192));
fn rectangle(c: &mut Criterion) {
closed_shape_benches(c, "rectangle", || BOUNDING_BOX);
}
fn rounded_rectangle(c: &mut Criterion) {
closed_shape_benches(c, "rounded rectangle", || {
RoundedRectangle::new(BOUNDING_BOX, CornerRadii::new(Size::new(10, 12)))
});
}
fn rounded_rectangle_corners(c: &mut Criterion) {
closed_shape_benches(c, "rounded rectangle corners", || {
RoundedRectangle::new(
BOUNDING_BOX,
CornerRadii {
top_left: Size::new(10, 12),
top_right: Size::new(14, 16),
bottom_right: Size::new(18, 20),
bottom_left: Size::new(22, 24),
},
)
});
}
fn triangle(c: &mut Criterion) {
closed_shape_benches(c, "triangle", || {
Triangle::new(
BOUNDING_BOX.anchor_point(AnchorPoint::BottomLeft),
BOUNDING_BOX.anchor_point(AnchorPoint::TopCenter),
BOUNDING_BOX.anchor_point(AnchorPoint::BottomRight),
)
});
}
fn circle(c: &mut Criterion) {
closed_shape_benches(c, "circle", || {
Circle::new(BOUNDING_BOX.top_left, BOUNDING_BOX.size.width)
});
}
fn ellipse(c: &mut Criterion) {
closed_shape_benches(c, "ellipse", || {
Ellipse::with_center(BOUNDING_BOX.center(), BOUNDING_BOX.size - Size::new(0, 20))
});
}
fn sector_150(c: &mut Criterion) {
closed_shape_benches(c, "sector 150°", || {
Sector::with_center(
BOUNDING_BOX.center(),
BOUNDING_BOX.size.width,
-30.0.deg(),
150.0.deg(),
)
});
}
fn sector_360(c: &mut Criterion) {
closed_shape_benches(c, "sector 360°", || {
Sector::with_center(
BOUNDING_BOX.center(),
BOUNDING_BOX.size.width,
-30.0.deg(),
150.0.deg(),
)
});
}
fn line(c: &mut Criterion) {
open_shape_benches(c, "line", || {
Line::new(
BOUNDING_BOX.anchor_point(AnchorPoint::TopLeft),
// move point up a bit, because non 45° lines might be slower
BOUNDING_BOX.anchor_point(AnchorPoint::BottomRight) - Point::new(0, 20),
)
});
}
fn polyline(c: &mut Criterion) {
let points = [
BOUNDING_BOX.anchor_point(AnchorPoint::BottomLeft),
BOUNDING_BOX.anchor_point(AnchorPoint::TopCenter) - Point::new(20, 0),
BOUNDING_BOX.anchor_point(AnchorPoint::BottomCenter),
BOUNDING_BOX.anchor_point(AnchorPoint::TopRight),
BOUNDING_BOX.anchor_point(AnchorPoint::BottomRight),
];
open_shape_benches(c, "polyline", || Polyline::new(&points));
}
fn arc_150(c: &mut Criterion) {
open_shape_benches(c, "arc 150°", || {
Arc::with_center(
BOUNDING_BOX.center(),
BOUNDING_BOX.size.width,
-30.0.deg(),
150.0.deg(),
)
});
}
fn arc_360(c: &mut Criterion) {
open_shape_benches(c, "arc 360°", || {
Arc::with_center(
BOUNDING_BOX.center(),
BOUNDING_BOX.size.width,
0.0.deg(),
360.0.deg(),
)
});
}
criterion_group!(
primitives,
rectangle,
rounded_rectangle,
rounded_rectangle_corners,
triangle,
circle,
ellipse,
line,
polyline,
sector_150,
sector_360,
arc_150,
arc_360,
);
criterion_main!(primitives);
fn closed_shape_benches<P>(c: &mut Criterion, name: &str, build: impl Fn() -> P)
where
P: Primitive,
Styled<P, PrimitiveStyle<Gray8>>: Drawable<Color = Gray8>,
{
let mut group = c.benchmark_group(name);
group.bench_function("fill", |b| {
let style = PrimitiveStyle::with_fill(Gray8::WHITE);
let object = build().into_styled(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("stroke 1px", |b| {
let style = PrimitiveStyleBuilder::new()
.stroke_color(Gray8::BLACK)
.stroke_width(1)
.stroke_alignment(StrokeAlignment::Inside)
.build();
let object = build().into_styled(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("stroke 10px", |b| {
let style = PrimitiveStyleBuilder::new()
.stroke_color(Gray8::BLACK)
.stroke_width(10)
.stroke_alignment(StrokeAlignment::Inside)
.build();
let object = build().into_styled(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("stroke 1px and fill", |b| {
let style = PrimitiveStyleBuilder::new()
.fill_color(Gray8::WHITE)
.stroke_color(Gray8::BLACK)
.stroke_width(1)
.stroke_alignment(StrokeAlignment::Inside)
.build();
let object = build().into_styled(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("stroke 10px and fill", |b| {
let style = PrimitiveStyleBuilder::new()
.fill_color(Gray8::WHITE)
.stroke_color(Gray8::BLACK)
.stroke_width(10)
.stroke_alignment(StrokeAlignment::Inside)
.build();
let object = build().into_styled(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.finish()
}
fn open_shape_benches<P>(c: &mut Criterion, name: &str, build: impl Fn() -> P)
where
P: Primitive,
Styled<P, PrimitiveStyle<Gray8>>: Drawable<Color = Gray8>,
{
let mut group = c.benchmark_group(name);
group.bench_function("stroke 1px", |b| {
let style = PrimitiveStyle::with_stroke(Gray8::WHITE, 1);
let object = build().into_styled(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("stroke 10px", |b| {
let style = PrimitiveStyle::with_stroke(Gray8::WHITE, 10);
let object = build().into_styled(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.bench_function("stroke 50px", |b| {
let style = PrimitiveStyle::with_stroke(Gray8::WHITE, 50);
let object = build().into_styled(style);
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
group.finish()
}
@@ -0,0 +1,39 @@
use criterion::*;
use embedded_graphics::{geometry::AngleUnit, pixelcolor::Gray8, prelude::*, primitives::*};
mod common;
use common::Framebuffer;
fn arc(c: &mut Criterion) {
c.bench_function("arc", |b| {
let object = &Arc::new(Point::new(100, 100), 100, -30.0.deg(), 150.0.deg())
.into_styled(PrimitiveStyle::with_stroke(Gray8::new(1), 1));
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
}
fn sector(c: &mut Criterion) {
c.bench_function("sector", |b| {
let object = &Sector::new(Point::new(100, 100), 100, -30.0.deg(), 150.0.deg())
.into_styled(PrimitiveStyle::with_stroke(Gray8::new(1), 1));
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
}
fn filled_sector(c: &mut Criterion) {
c.bench_function("filled_sector", |b| {
let object = &Sector::new(Point::new(100, 100), 100, -30.0.deg(), 150.0.deg())
.into_styled(PrimitiveStyle::with_fill(Gray8::new(1)));
let mut framebuffer = Framebuffer::new();
b.iter(|| object.draw(&mut framebuffer))
});
}
criterion_group!(primitives_fixed_point, arc, sector, filled_sector);
criterion_main!(primitives_fixed_point);
+42
View File
@@ -0,0 +1,42 @@
use criterion::*;
use embedded_graphics::{iterator::raw::RawDataSlice, pixelcolor::raw::*};
const TEST_DATA: &[u8] = include_bytes!("../fonts/src/10x20.bdf");
macro_rules! impl_bench {
($fn:ident, $type:ident) => {
fn $fn(c: &mut Criterion) {
c.bench_function(stringify!($type), |b| {
let slice = RawDataSlice::<$type, LittleEndian>::new(TEST_DATA);
b.iter(|| slice.into_iter().collect::<Vec<_>>())
});
c.bench_function(concat!(stringify!($type), " step by 20"), |b| {
let slice = RawDataSlice::<$type, LittleEndian>::new(TEST_DATA);
b.iter(|| slice.into_iter().step_by(20).collect::<Vec<_>>())
});
}
};
}
impl_bench!(raw_u1, RawU1);
impl_bench!(raw_u2, RawU2);
impl_bench!(raw_u4, RawU4);
impl_bench!(raw_u8, RawU8);
impl_bench!(raw_u16, RawU16);
impl_bench!(raw_u24, RawU24);
impl_bench!(raw_u32, RawU32);
criterion_group!(
raw_data_iter,
raw_u1,
raw_u2,
raw_u4,
raw_u8,
raw_u16,
raw_u24,
raw_u32
);
criterion_main!(raw_data_iter);