WIP: functional checkpoint!

This commit is contained in:
swaits committed 2026-04-06 21:15:35 -06:00
1 parent bb588c93ce
commit 38684482e8
19 files changed
+601 -151

No files matched your search

+1
View File
@@ -1,5 +1,6 @@
//! OLED display driver with radio status dashboard.
mod render;
pub mod sh1106;
mod task;
pub use task::display_task;
+15 -14
View File
@@ -69,10 +69,13 @@ pub fn dashboard(
match status.state {
RadioState::Receiving => {
// "RX" inverted on row 0
Rectangle::new(Point::new(0, 0), Size::new(MODE_BOX_W as u32, FONT_H as u32))
.into_styled(fill)
.draw(target)
.ok();
Rectangle::new(
Point::new(0, 0),
Size::new(MODE_BOX_W as u32, FONT_H as u32),
)
.into_styled(fill)
.draw(target)
.ok();
Text::new("RX", Point::new(1, FONT_H - 1), inv_style)
.draw(target)
.ok();
@@ -217,10 +220,7 @@ pub fn dashboard(
}
/// Render the splash/waiting screen (shown when idle or no config).
pub fn splash(
target: &mut impl DrawTarget<Color = BinaryColor>,
board: &BoardInfo,
) {
pub fn splash(target: &mut impl DrawTarget<Color = BinaryColor>, board: &BoardInfo) {
let bb = target.bounding_box();
let w = bb.size.width as i32;
@@ -324,11 +324,8 @@ fn rssi_sparkline(
i + committed.saturating_sub(effective_bars)
} else {
let start = count - RSSI_HISTORY_LEN;
let skip = RSSI_HISTORY_LEN.saturating_sub(if live {
visible_bars - 1
} else {
visible_bars
});
let skip =
RSSI_HISTORY_LEN.saturating_sub(if live { visible_bars - 1 } else { visible_bars });
(start + skip + i) % RSSI_HISTORY_LEN
};
let is_tx = tx_history[idx];
@@ -361,7 +358,11 @@ fn bar_height(rssi: i16, is_tx: bool, spark_h: i32) -> Option<i32> {
let clamped = rssi.clamp(RSSI_MIN, RSSI_MAX);
((clamped - RSSI_MIN) as i32 * spark_h) / (RSSI_MAX - RSSI_MIN) as i32
};
if h == 0 { None } else { Some(h) }
if h == 0 {
None
} else {
Some(h)
}
}
/// Draw a single sparkline bar (solid for RX, dotted for TX).
+136
View File
@@ -0,0 +1,136 @@
//! Minimal async SH1106 I2C OLED driver (128x64, page addressing).
//!
//! The SH1106 is very similar to the SSD1306 but only supports page-mode
//! addressing (no horizontal/vertical auto-advance). This driver sends
//! framebuffer data page-by-page with explicit page/column commands.
use embedded_graphics::draw_target::DrawTarget;
use embedded_graphics::geometry::{OriginDimensions, Size};
use embedded_graphics::pixelcolor::BinaryColor;
use embedded_graphics::Pixel;
use embedded_hal_async::i2c::I2c;
const WIDTH: usize = 128;
const HEIGHT: usize = 64;
const PAGES: usize = HEIGHT / 8;
const BUF_SIZE: usize = WIDTH * PAGES; // 1024
/// SH1106 has a 132-column RAM; visible 128 columns are centered with a
/// 2-column offset on each side.
const COL_OFFSET: u8 = 2;
pub struct Sh1106<I> {
i2c: I,
addr: u8,
buffer: [u8; BUF_SIZE],
}
impl<I: I2c> Sh1106<I> {
pub fn new(i2c: I, addr: u8) -> Self {
Self {
i2c,
addr,
buffer: [0u8; BUF_SIZE],
}
}
/// Send a sequence of commands (each prefixed with the I2C command byte 0x00).
async fn cmd(&mut self, commands: &[u8]) -> Result<(), I::Error> {
for &c in commands {
self.i2c.write(self.addr, &[0x00, c]).await?;
}
Ok(())
}
/// Initialize the display with sensible defaults.
pub async fn init(&mut self) -> Result<(), I::Error> {
self.cmd(&[
0xAE, // display off
0xD5, 0x80, // clock div: default
0xA8, 0x3F, // multiplex ratio: 64
0xD3, 0x00, // display offset: 0
0x40, // start line: 0
0x8D, 0x14, // charge pump: enable
0xA1, // segment remap: column 127 mapped to SEG0
0xC8, // COM scan direction: remapped
0xDA, 0x12, // COM pin config: alternative, no L/R remap
0x81, 0xCF, // contrast: 207
0xD9, 0xF1, // pre-charge: phase1=1, phase2=15
0xDB, 0x40, // VCOMH deselect: ~0.77×Vcc
0xA4, // entire display ON (follow RAM)
0xA6, // normal display (not inverted)
0xAF, // display on
])
.await
}
/// Flush the entire framebuffer to the display, page by page.
pub async fn flush(&mut self) -> Result<(), I::Error> {
// Each I2C data write is prefixed with 0x40 (data continuation byte).
let mut page_buf = [0u8; 1 + WIDTH]; // 0x40 + 128 data bytes
page_buf[0] = 0x40;
for page in 0..PAGES {
// Set page address and column start (with SH1106 2-column offset).
self.cmd(&[
0xB0 | page as u8, // page address
0x00 | (COL_OFFSET & 0x0F), // column low nibble
0x10 | (COL_OFFSET >> 4), // column high nibble
])
.await?;
let start = page * WIDTH;
page_buf[1..].copy_from_slice(&self.buffer[start..start + WIDTH]);
self.i2c.write(self.addr, &page_buf).await?;
}
Ok(())
}
/// Set display brightness (contrast).
pub async fn set_brightness(&mut self, value: u8) -> Result<(), I::Error> {
self.cmd(&[0x81, value]).await
}
/// Clear the framebuffer (call flush() to update the display).
pub fn clear_buffer(&mut self) {
self.buffer.fill(0);
}
}
impl<I: I2c> DrawTarget for Sh1106<I> {
type Color = BinaryColor;
type Error = core::convert::Infallible;
fn draw_iter<T>(&mut self, pixels: T) -> Result<(), Self::Error>
where
T: IntoIterator<Item = Pixel<Self::Color>>,
{
for Pixel(pos, color) in pixels {
let x = pos.x;
let y = pos.y;
if x >= 0 && x < WIDTH as i32 && y >= 0 && y < HEIGHT as i32 {
let x = x as usize;
let y = y as usize;
let idx = (y / 8) * WIDTH + x;
let bit = (y % 8) as u8;
if color.is_on() {
self.buffer[idx] |= 1 << bit;
} else {
self.buffer[idx] &= !(1 << bit);
}
}
}
Ok(())
}
fn clear(&mut self, color: Self::Color) -> Result<(), Self::Error> {
self.buffer.fill(if color.is_on() { 0xFF } else { 0x00 });
Ok(())
}
}
impl<I: I2c> OriginDimensions for Sh1106<I> {
fn size(&self) -> Size {
Size::new(WIDTH as u32, HEIGHT as u32)
}
}
+79 -78
View File
@@ -1,15 +1,11 @@
use embassy_executor::task;
use embassy_futures::select::{select3, Either3};
use embassy_time::Timer;
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::DisplayRotation;
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
use embedded_graphics::draw_target::DrawTarget;
use embedded_graphics::pixelcolor::BinaryColor;
use crate::board::DisplayParts;
use crate::channel::{
DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch,
};
use crate::channel::{DisplayCommand, DisplayCommandChannel, RadioState, RadioStatus, StatusWatch};
use crate::board::{Board, LoRaBoard};
@@ -80,6 +76,28 @@ impl DisplayState {
}
}
/// Render the appropriate screen for the current state into the display buffer.
fn render_current(
display: &mut impl DrawTarget<Color = BinaryColor>,
state: &DisplayState,
board: &render::BoardInfo<'_>,
) {
if state.is_active() {
render::dashboard(
display,
&state.last_status,
&state.rssi_history,
&state.tx_history,
state.rssi_count,
state.current_slot_rssi,
state.current_slot_tx,
board,
);
} else {
render::splash(display, board);
}
}
#[task]
pub async fn display_task(
parts: DisplayParts,
@@ -103,20 +121,51 @@ pub async fn display_task(
mac: &mac_str,
};
let interface = I2CDisplayInterface::new(parts.i2c);
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
cfg_if::cfg_if! {
if #[cfg(feature = "wio_tracker_l1")] {
let mut display = super::sh1106::Sh1106::new(parts.i2c, 0x3D);
if display.init().await.is_err() {
defmt::error!("SSD1306 init failed");
return;
}
if display
.set_brightness(ssd1306::prelude::Brightness::BRIGHTEST)
.await
.is_err()
{
defmt::warn!("display brightness set failed");
Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SH1106 init failed — retrying");
Timer::after_millis(500).await;
if display.init().await.is_err() {
defmt::error!("SH1106 init failed twice, giving up");
return;
}
}
if display.set_brightness(0xFF).await.is_err() {
defmt::warn!("display brightness set failed");
}
} else {
use ssd1306::mode::DisplayConfigAsync;
use ssd1306::prelude::DisplayRotation;
use ssd1306::size::DisplaySize128x64;
use ssd1306::{I2CDisplayInterface, Ssd1306Async};
let interface = I2CDisplayInterface::new(parts.i2c);
let mut display = Ssd1306Async::new(interface, DisplaySize128x64, DisplayRotation::Rotate0)
.into_buffered_graphics_mode();
Timer::after_millis(100).await;
if display.init().await.is_err() {
defmt::error!("SSD1306 init failed — retrying");
Timer::after_millis(500).await;
if display.init().await.is_err() {
defmt::error!("SSD1306 init failed twice, giving up");
return;
}
}
if display
.set_brightness(ssd1306::prelude::Brightness::BRIGHTEST)
.await
.is_err()
{
defmt::warn!("display brightness set failed");
}
}
}
let mut state = DisplayState::new();
@@ -126,7 +175,8 @@ pub async fn display_task(
};
// Show splash/waiting screen
render_splash(&mut display, &board_info).await;
render::splash(&mut display, &board_info);
let _ = display.flush().await;
loop {
match select3(
@@ -151,7 +201,8 @@ pub async fn display_task(
state.last_status = radio_status;
if state.display_on {
render_current(&mut display, &state, &board_info).await;
render_current(&mut display, &state, &board_info);
let _ = display.flush().await;
}
}
Either3::Second(cmd) => match cmd {
@@ -167,73 +218,23 @@ pub async fn display_task(
if let Some(s) = receiver.try_get() {
state.last_status = s;
}
render_current(&mut display, &state, &board_info).await;
render_current(&mut display, &state, &board_info);
let _ = display.flush().await;
}
DisplayCommand::Reset => {
state = DisplayState::new();
render_splash(&mut display, &board_info).await;
render::splash(&mut display, &board_info);
let _ = display.flush().await;
}
},
Either3::Third(()) => {
// Timer tick: advance sparkline slot
state.advance_slot();
if state.display_on && state.is_active() {
render_dashboard(&mut display, &state, &board_info).await;
render_current(&mut display, &state, &board_info);
let _ = display.flush().await;
}
}
}
}
}
// The concrete display type is verbose but avoids making this generic
// over both DrawTarget and the SSD1306-specific flush method.
type Display<I> = ssd1306::Ssd1306Async<
I,
DisplaySize128x64,
ssd1306::mode::BufferedGraphicsModeAsync<DisplaySize128x64>,
>;
/// Render the appropriate screen (active dashboard or splash) and flush.
async fn render_current<I>(
display: &mut Display<I>,
state: &DisplayState,
board: &render::BoardInfo<'_>,
) where
I: display_interface::AsyncWriteOnlyDataCommand,
{
if state.is_active() {
render_dashboard(display, state, board).await;
} else {
render_splash(display, board).await;
}
}
/// Render the active dashboard and flush.
async fn render_dashboard<I>(
display: &mut Display<I>,
state: &DisplayState,
board: &render::BoardInfo<'_>,
) where
I: display_interface::AsyncWriteOnlyDataCommand,
{
render::dashboard(
display,
&state.last_status,
&state.rssi_history,
&state.tx_history,
state.rssi_count,
state.current_slot_rssi,
state.current_slot_tx,
board,
);
let _ = display.flush().await;
}
/// Render the splash/waiting screen and flush.
async fn render_splash<I>(display: &mut Display<I>, board: &render::BoardInfo<'_>)
where
I: display_interface::AsyncWriteOnlyDataCommand,
{
render::splash(display, board);
let _ = display.flush().await;
}