Rust UI: host simulator (PNG) + shared pm-ui crate; trim panel init

Answer to 'can you simulate it?': the UI now renders on the host.
- pm-ui: shared no_std embedded-graphics drawing (draw_ui), used by BOTH the
  firmware and the simulator — single source, no divergence.
- uisim: host crate that draws pm-ui onto a framebuffer and exports a PNG (pure
  Rust, no SDL). Confirmed the bring-up pattern renders correctly off-device, so
  the black screen is a panel/controller issue, not a draw bug.
- pm-kit: use pm_ui::draw_ui; trim the ST7796 extension init to just unlock + 0xB6
  (the gamma/VCOM sent after DISPON likely blanked it); LED solid during init then
  slow 1 Hz blink so hung-init / running / reset-loop are distinguishable.

Note: the simulator covers WHAT we draw (layout/colour/logic). It does NOT model
the ST7796 controller's hardware quirks (0xB6 line count, MADCTL scan, SPI init) —
those still need the bench, but that's a one-time bring-up.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-05-31 21:54:20 -05:00
parent 67182cd74c
commit 0fa32a827f
7 changed files with 149 additions and 56 deletions

View file

@ -12,6 +12,7 @@ embedded-hal = "1"
embedded-hal-bus = "0.2" embedded-hal-bus = "0.2"
mipidsi = "0.9" mipidsi = "0.9"
embedded-graphics = "0.8" embedded-graphics = "0.8"
pm-ui = { path = "../pm-ui" }
[profile.release] [profile.release]
opt-level = "s" opt-level = "s"

View file

@ -7,13 +7,6 @@
#![no_std] #![no_std]
#![no_main] #![no_main]
use embedded_graphics::{
mono_font::{ascii::FONT_10X20, MonoTextStyle},
pixelcolor::Rgb565,
prelude::*,
primitives::{PrimitiveStyle, PrimitiveStyleBuilder, Rectangle},
text::Text,
};
use embedded_hal::delay::DelayNs; use embedded_hal::delay::DelayNs;
use embedded_hal::digital::OutputPin; use embedded_hal::digital::OutputPin;
use embedded_hal_bus::spi::ExclusiveDevice; use embedded_hal_bus::spi::ExclusiveDevice;
@ -56,6 +49,7 @@ fn main() -> ! {
let pins = hal::gpio::Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS); let pins = hal::gpio::Pins::new(pac.IO_BANK0, pac.PADS_BANK0, sio.gpio_bank0, &mut pac.RESETS);
let mut led = pins.gpio25.into_push_pull_output(); let mut led = pins.gpio25.into_push_pull_output();
led.set_high().unwrap(); // solid ON during init: if it stays solid → hung in init; slow blink → reached the loop
// --- ST7796 over SPI0 --- // --- ST7796 over SPI0 ---
let sclk = pins.gpio2.into_function::<hal::gpio::FunctionSpi>(); let sclk = pins.gpio2.into_function::<hal::gpio::FunctionSpi>();
@ -92,61 +86,22 @@ fn main() -> ! {
let di = unsafe { display.dcs() }; let di = unsafe { display.dcs() };
di.send_command(0xF0, &[0xC3]).unwrap(); // unlock extension command set di.send_command(0xF0, &[0xC3]).unwrap(); // unlock extension command set
di.send_command(0xF0, &[0x96]).unwrap(); di.send_command(0xF0, &[0x96]).unwrap();
di.send_command(0xB4, &[0x01]).unwrap(); // 1-dot inversion di.send_command(0xB6, &[0x80, 0x02, 0x3B]).unwrap(); // display function control: 480 lines (the essential one)
di.send_command(0xB6, &[0x80, 0x02, 0x3B]).unwrap(); // display function control: 480 lines
di.send_command(0xE8, &[0x40, 0x8A, 0x00, 0x00, 0x29, 0x19, 0xA5, 0x33]).unwrap();
di.send_command(0xC1, &[0x06]).unwrap();
di.send_command(0xC2, &[0xA7]).unwrap();
di.send_command(0xC5, &[0x18]).unwrap();
di.send_command(0xE0, &[0xF0, 0x09, 0x0B, 0x06, 0x04, 0x15, 0x2F, 0x54, 0x42, 0x3C, 0x17, 0x14, 0x18, 0x1B]).unwrap();
di.send_command(0xE1, &[0xE0, 0x09, 0x0B, 0x06, 0x04, 0x03, 0x2B, 0x43, 0x42, 0x3B, 0x16, 0x14, 0x17, 0x1B]).unwrap();
di.send_command(0xF0, &[0x3C]).unwrap(); // lock
di.send_command(0xF0, &[0x69]).unwrap();
} }
timer.delay_ms(120); timer.delay_ms(20);
let w = WIDTH as i32; // Same UI code the host simulator renders (rust/uisim → PNG). If this is wrong, the sim
let h = HEIGHT as i32; // shows it without the bench; if the sim is right but the panel is wrong, it's a controller
let m = 36; // marker size // issue (init/MADCTL/0xB6), not a draw bug.
pm_ui::draw_ui(&mut display).unwrap();
// Full-screen fill via the SAME draw path as the shapes (clear() left snow last time).
Rectangle::new(Point::zero(), Size::new(WIDTH as u32, HEIGHT as u32))
.into_styled(PrimitiveStyle::with_fill(Rgb565::new(0, 0, 12)))
.draw(&mut display)
.unwrap();
// Red 8px border on all four edges — if any edge is missing, the addressed area != panel.
Rectangle::new(Point::zero(), Size::new(WIDTH as u32, HEIGHT as u32))
.into_styled(
PrimitiveStyleBuilder::new()
.stroke_color(Rgb565::RED)
.stroke_width(8)
.build(),
)
.draw(&mut display)
.unwrap();
// Distinct corner markers so orientation/mirror is unambiguous.
let ms = Size::new(m as u32, m as u32);
for (x, y, c) in [
(0, 0, Rgb565::GREEN), // top-left
(w - m, 0, Rgb565::YELLOW), // top-right
(0, h - m, Rgb565::CYAN), // bottom-left
(w - m, h - m, Rgb565::MAGENTA), // bottom-right
] {
Rectangle::new(Point::new(x, y), ms)
.into_styled(PrimitiveStyle::with_fill(c))
.draw(&mut display)
.unwrap();
}
// Labels: "TL" near origin, big "PMK" centred.
let label = MonoTextStyle::new(&FONT_10X20, Rgb565::WHITE);
Text::new("TL", Point::new(44, 28), label).draw(&mut display).unwrap();
Text::new("PMK", Point::new(w / 2 - 30, h / 2), label).draw(&mut display).unwrap();
// Reached the loop → display init + draw completed. Slow 1 Hz blink (vs the solid-ON during
// init above) so "hung in init" / "running" / "reset loop" are distinguishable on the LED.
loop { loop {
led.set_high().unwrap(); led.set_high().unwrap();
timer.delay_ms(250); timer.delay_ms(500);
led.set_low().unwrap(); led.set_low().unwrap();
timer.delay_ms(250); timer.delay_ms(500);
} }
} }

8
rust/pm-ui/Cargo.toml Normal file
View file

@ -0,0 +1,8 @@
[package]
name = "pm-ui"
version = "0.1.0"
edition = "2021"
description = "Shared PM_K-1 UI rendering (embedded-graphics, no_std). Used by the firmware and the host simulator."
[dependencies]
embedded-graphics = "0.8"

58
rust/pm-ui/src/lib.rs Normal file
View file

@ -0,0 +1,58 @@
//! Shared PM_K-1 UI rendering — `no_std`, generic over any `embedded-graphics` `DrawTarget`.
//!
//! The firmware draws this onto the real ST7796; the host simulator (`rust/uisim`) draws the
//! SAME code onto a framebuffer and exports a PNG. So UI/layout can be developed and reviewed
//! without the device — only true panel/controller quirks need the bench.
#![no_std]
use embedded_graphics::{
mono_font::{ascii::FONT_10X20, MonoTextStyle},
pixelcolor::Rgb565,
prelude::*,
primitives::{PrimitiveStyle, PrimitiveStyleBuilder, Rectangle},
text::Text,
};
/// Current bring-up diagnostic: solid fill + 4-edge border + distinct corner markers + labels.
/// (This is where the real metronome UI will grow.)
pub fn draw_ui<D>(d: &mut D) -> Result<(), D::Error>
where
D: DrawTarget<Color = Rgb565>,
{
let bb = d.bounding_box();
let w = bb.size.width as i32;
let h = bb.size.height as i32;
let m: i32 = 36;
// Full-screen background.
Rectangle::new(Point::zero(), bb.size)
.into_styled(PrimitiveStyle::with_fill(Rgb565::new(0, 0, 31)))
.draw(d)?;
// Red 8px border on all four edges.
Rectangle::new(Point::zero(), bb.size)
.into_styled(
PrimitiveStyleBuilder::new()
.stroke_color(Rgb565::RED)
.stroke_width(8)
.build(),
)
.draw(d)?;
// Distinct corner markers: TL green / TR yellow / BL cyan / BR magenta.
let ms = Size::new(m as u32, m as u32);
for (x, y, c) in [
(0, 0, Rgb565::GREEN),
(w - m, 0, Rgb565::YELLOW),
(0, h - m, Rgb565::CYAN),
(w - m, h - m, Rgb565::MAGENTA),
] {
Rectangle::new(Point::new(x, y), ms)
.into_styled(PrimitiveStyle::with_fill(c))
.draw(d)?;
}
// Labels.
let label = MonoTextStyle::new(&FONT_10X20, Rgb565::WHITE);
Text::new("TL", Point::new(44, 28), label).draw(d)?;
Text::new("PMK", Point::new(w / 2 - 30, h / 2), label).draw(d)?;
Ok(())
}

3
rust/uisim/.gitignore vendored Normal file
View file

@ -0,0 +1,3 @@
target/
*.png
Cargo.lock

10
rust/uisim/Cargo.toml Normal file
View file

@ -0,0 +1,10 @@
[package]
name = "uisim"
version = "0.1.0"
edition = "2021"
description = "Host renderer for pm-ui: draws the firmware UI to a framebuffer and exports a PNG (no hardware)."
[dependencies]
pm-ui = { path = "../pm-ui" }
embedded-graphics = "0.8"
image = { version = "0.25", default-features = false, features = ["png"] }

58
rust/uisim/src/main.rs Normal file
View file

@ -0,0 +1,58 @@
//! Render the shared pm-ui onto a 320×480 framebuffer and save it as a PNG — no device, no SDL.
//! `cargo run` (host) → pm-kit-ui.png. Lets the UI be developed/reviewed off the bench.
use embedded_graphics::{pixelcolor::Rgb565, prelude::*};
const W: u32 = 320;
const H: u32 = 480;
/// A trivial framebuffer DrawTarget.
struct Fb {
px: Vec<Rgb565>,
}
impl Fb {
fn new() -> Self {
Fb { px: vec![Rgb565::BLACK; (W * H) as usize] }
}
}
impl DrawTarget for Fb {
type Color = Rgb565;
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(p, c) in pixels {
if p.x >= 0 && p.y >= 0 && (p.x as u32) < W && (p.y as u32) < H {
self.px[(p.y as u32 * W + p.x as u32) as usize] = c;
}
}
Ok(())
}
}
impl OriginDimensions for Fb {
fn size(&self) -> Size {
Size::new(W, H)
}
}
fn main() {
let mut fb = Fb::new();
pm_ui::draw_ui(&mut fb).unwrap();
let img = image::RgbImage::from_fn(W, H, |x, y| {
let c = fb.px[(y * W + x) as usize];
// Rgb565 channels → 8-bit
let r = (c.r() << 3) | (c.r() >> 2);
let g = (c.g() << 2) | (c.g() >> 4);
let b = (c.b() << 3) | (c.b() >> 2);
image::Rgb([r, g, b])
});
let out = std::env::args().nth(1).unwrap_or_else(|| "pm-kit-ui.png".into());
img.save(&out).unwrap();
println!("wrote {out} ({W}x{H})");
}