pm-kit: working display — ported ST7796 driver + double-buffer + dirty row-bands

Replace mipidsi with a direct port of pico/main.py's ST7796 driver (per-command CS,
MADCTL 0x48, INVON, no offset, big-endian RGB565) — fixes the geometry/colour mangling.
Render the UI into a 300KB RAM framebuffer (embedded-graphics), then push only the
full-width row bands that changed vs the last frame (FNV row-diff) — flicker-free and
~144/480 rows per playhead step. Full-width windows only: the panel's MX bit mirror-maps
sub-width windows wrongly (that was the black screen). SPI 62.5 MHz per pico-cp to shrink
the tearing window (no TE pin is routed on this board, so hardware vsync isn't available).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Me Here 2026-06-01 11:13:32 -05:00
parent 33dc5ff5cb
commit bd3629ba4a

View file

@ -18,17 +18,16 @@ use embedded_hal::delay::DelayNs;
use embedded_hal::digital::{InputPin, OutputPin};
use embedded_hal::pwm::SetDutyCycle;
use embedded_hal_0_2::adc::OneShot;
use embedded_hal_bus::spi::ExclusiveDevice;
use mipidsi::interface::{Interface, SpiInterface};
use mipidsi::models::ST7796;
use mipidsi::options::{ColorInversion, ColorOrder, Orientation};
use mipidsi::Builder;
use embedded_hal::spi::SpiBus;
use defmt::info;
use defmt_rtt as _; // global defmt logger over RTT (read by `probe-rs run`)
use panic_probe as _; // panic handler that prints the message + location over defmt, then halts
use rp235x_hal as hal;
use hal::fugit::RateExtU32;
use hal::Clock;
use embedded_graphics::pixelcolor::Rgb565;
use embedded_graphics::prelude::*;
use embedded_graphics::primitives::Rectangle;
/// Image definition block — the RP2350 bootrom looks for this to boot the image.
#[link_section = ".start_block"]
@ -39,27 +38,162 @@ const XTAL_FREQ_HZ: u32 = 12_000_000;
const WIDTH: u16 = 320;
const HEIGHT: u16 = 480;
/// No-op chip-select for the SPI wrapper. The real CS (GP5) is held low for the whole session,
/// because mipidsi's SpiInterface sends a command and its parameters as SEPARATE SpiDevice
/// transactions — a normal SpiDevice would toggle CS between them, and the ST7796 needs CS
/// continuous across command+parameters (so MADCTL/COLMOD/B6 args load). DC selects cmd vs data.
struct NoCs;
impl embedded_hal::digital::ErrorType for NoCs {
type Error = core::convert::Infallible;
/// Direct ST7796 driver — a faithful port of the proven MicroPython driver in `pico/main.py`,
/// which runs this exact panel on this exact board. Per-command CS framing (CS low → command →
/// data → CS high), MADCTL 0x48, INVON, NO row/column offset, big-endian RGB565. This replaces
/// mipidsi, whose split-transaction CS and orientation/offset math were mangling the geometry.
/// We render into the RAM `FrameBuf` (embedded-graphics), then `blit` the whole frame in one
/// windowed RAMWR stream — exactly like main.py's `_window` + `spi.write`.
struct St7796<SPI, DC, CS, RST> {
spi: SPI,
dc: DC,
cs: CS,
rst: RST,
}
impl OutputPin for NoCs {
fn set_low(&mut self) -> Result<(), core::convert::Infallible> {
impl<SPI, DC, CS, RST> St7796<SPI, DC, CS, RST>
where
SPI: SpiBus,
DC: OutputPin,
CS: OutputPin,
RST: OutputPin,
{
fn cmd(&mut self, c: u8, data: &[u8]) {
let _ = self.cs.set_low();
let _ = self.dc.set_low();
let _ = self.spi.write(&[c]);
if !data.is_empty() {
let _ = self.dc.set_high();
let _ = self.spi.write(data);
}
let _ = self.cs.set_high();
}
/// main.py reset() + init() verbatim.
fn init(&mut self, delay: &mut impl DelayNs) {
let _ = self.cs.set_high();
let _ = self.dc.set_low();
let _ = self.rst.set_high();
delay.delay_ms(20);
let _ = self.rst.set_low();
delay.delay_ms(40);
let _ = self.rst.set_high();
delay.delay_ms(150);
self.cmd(0x01, &[]); // SWRESET
delay.delay_ms(120);
self.cmd(0x11, &[]); // SLPOUT
delay.delay_ms(120);
self.cmd(0xF0, &[0xC3]); // unlock extension command set
self.cmd(0xF0, &[0x96]);
self.cmd(0x36, &[0x48]); // MADCTL = MX | BGR
self.cmd(0x3A, &[0x55]); // COLMOD 16bpp
self.cmd(0xB4, &[0x01]);
self.cmd(0xB6, &[0x80, 0x02, 0x3B]);
self.cmd(0xE8, &[0x40, 0x8A, 0x00, 0x00, 0x29, 0x19, 0xA5, 0x33]);
self.cmd(0xC1, &[0x06]);
self.cmd(0xC2, &[0xA7]);
self.cmd(0xC5, &[0x18]);
delay.delay_ms(120);
self.cmd(0xE0, &[0xF0, 0x09, 0x0B, 0x06, 0x04, 0x15, 0x2F, 0x54, 0x42, 0x3C, 0x17, 0x14, 0x18, 0x1B]);
self.cmd(0xE1, &[0xE0, 0x09, 0x0B, 0x06, 0x04, 0x03, 0x2B, 0x43, 0x42, 0x3B, 0x16, 0x14, 0x17, 0x1B]);
self.cmd(0xF0, &[0x3C]); // lock
self.cmd(0xF0, &[0x69]);
delay.delay_ms(120);
self.cmd(0x21, &[]); // INVON (INVERT_COLORS = true)
self.cmd(0x29, &[]); // DISPON
delay.delay_ms(50);
}
/// Blit a full-width band of rows `ylo..ylo+h`. CASET is the full width (0..=319, exactly like
/// the full-screen blit), only RASET is partial — and because a full-width band is *contiguous*
/// in the row-major framebuffer, `px` is just that contiguous slice, streamed with the identical
/// loop the full blit uses. The full-screen blit is `blit_band(&px[..], 0, HEIGHT)`.
fn blit_band(&mut self, px: &[Rgb565], ylo: u16, h: u16) {
let y1 = ylo + h - 1;
self.cmd(0x2A, &[0x00, 0x00, 0x01, 0x3F]); // CASET 0..=319 (full width)
self.cmd(0x2B, &[(ylo >> 8) as u8, (ylo & 0xFF) as u8, (y1 >> 8) as u8, (y1 & 0xFF) as u8]);
let _ = self.cs.set_low();
let _ = self.dc.set_low();
let _ = self.spi.write(&[0x2C]); // RAMWR
let _ = self.dc.set_high();
let mut buf = [0u8; 1024];
let mut n = 0;
for &c in px {
let raw = c.into_storage(); // u16 RGB565, big-endian like main.py
buf[n] = (raw >> 8) as u8;
buf[n + 1] = (raw & 0xFF) as u8;
n += 2;
if n == buf.len() {
let _ = self.spi.write(&buf);
n = 0;
}
}
if n > 0 {
let _ = self.spi.write(&buf[..n]);
}
let _ = self.cs.set_high();
}
/// Full-screen blit — the known-good path. Just a full-height band.
fn blit(&mut self, px: &[Rgb565]) {
self.blit_band(px, 0, HEIGHT);
}
}
/// Off-screen framebuffer: a full 320×480 Rgb565 image in RAM (300 KB). The pm-ui draw functions
/// render into THIS (instant, no SPI), then we push the whole thing to the panel in one
/// `fill_contiguous` sweep — so the panel never shows a clear-then-redraw transition → no flicker.
const FB_LEN: usize = WIDTH as usize * HEIGHT as usize;
static mut FRAMEBUF: [Rgb565; FB_LEN] = [Rgb565::new(0, 0, 0); FB_LEN];
struct FrameBuf {
px: &'static mut [Rgb565],
}
impl DrawTarget for FrameBuf {
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 u16) < WIDTH && (p.y as u16) < HEIGHT {
self.px[p.y as usize * WIDTH as usize + p.x as usize] = c;
}
}
Ok(())
}
fn set_high(&mut self) -> Result<(), core::convert::Infallible> {
fn fill_contiguous<I>(&mut self, area: &Rectangle, colors: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Self::Color>,
{
// fast path used by clear()/fill — write straight into the backing slice row by row
let mut it = colors.into_iter();
let x0 = area.top_left.x.max(0) as u16;
let y0 = area.top_left.y.max(0) as u16;
let x1 = (area.top_left.x + area.size.width as i32).min(WIDTH as i32) as u16;
let y1 = (area.top_left.y + area.size.height as i32).min(HEIGHT as i32) as u16;
for y in y0..y1 {
for x in x0..x1 {
if let Some(c) = it.next() {
self.px[y as usize * WIDTH as usize + x as usize] = c;
}
}
}
Ok(())
}
}
impl OriginDimensions for FrameBuf {
fn size(&self) -> Size {
Size::new(WIDTH as u32, HEIGHT as u32)
}
}
#[hal::entry]
fn main() -> ! {
// heap for track parsing (track-format uses alloc). RP2350 has 520 KB SRAM.
const HEAP_SIZE: usize = 96 * 1024;
const HEAP_SIZE: usize = 16 * 1024;
{
use core::mem::MaybeUninit;
static mut HEAP_MEM: [MaybeUninit<u8>; HEAP_SIZE] = [MaybeUninit::uninit(); HEAP_SIZE];
@ -88,71 +222,24 @@ fn main() -> ! {
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 (direct driver, ported from pico/main.py) ---
let sclk = pins.gpio2.into_function::<hal::gpio::FunctionSpi>();
let mosi = pins.gpio3.into_function::<hal::gpio::FunctionSpi>();
let dc = pins.gpio6.into_push_pull_output();
let mut rst = pins.gpio7.into_push_pull_output();
let mut cs = pins.gpio5.into_push_pull_output();
cs.set_low().unwrap(); // hold CS low for the whole session (see NoCs) — the fix for the 1/4 panel
let rst = pins.gpio7.into_push_pull_output();
let cs = pins.gpio5.into_push_pull_output();
let spi = hal::spi::Spi::<_, _, _, 8>::new(pac.SPI0, (mosi, sclk));
let spi = spi.init(
&mut pac.RESETS,
clocks.peripheral_clock.freq(),
16.MHz(),
62500.kHz(), // pico-cp SPI_BAUD: 62.5 MHz — "faster SPI = smaller tearing window" (drop to 40 MHz if unstable)
embedded_hal::spi::MODE_0,
);
let spi_device = ExclusiveDevice::new_no_delay(spi, NoCs).unwrap();
let mut buffer = [0u8; 512];
let mut di = SpiInterface::new(spi_device, dc, &mut buffer);
// Hardware reset, then the FULL known-good CircuitPython st7796_init as the PRIMARY bring-up
// (right after reset, the only time the panel accepts the extension setup cleanly). Confirmed
// necessary: mipidsi's address window + MADCTL already match CircuitPython (host initdump), so
// the only remaining difference was this extension init not running as the primary init.
rst.set_high().unwrap();
timer.delay_ms(5);
rst.set_low().unwrap();
timer.delay_ms(20);
rst.set_high().unwrap();
timer.delay_ms(150);
di.send_command(0x01, &[]).unwrap(); // SWRESET
timer.delay_ms(120);
di.send_command(0x11, &[]).unwrap(); // SLPOUT
timer.delay_ms(120);
di.send_command(0xF0, &[0xC3]).unwrap(); // unlock extension command set
di.send_command(0xF0, &[0x96]).unwrap();
di.send_command(0x36, &[0x48]).unwrap(); // MADCTL
di.send_command(0x3A, &[0x55]).unwrap(); // COLMOD 16bpp
di.send_command(0xB4, &[0x01]).unwrap();
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();
timer.delay_ms(120);
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);
di.send_command(0x21, &[]).unwrap(); // INVON (INVERT_COLORS = true)
di.send_command(0x29, &[]).unwrap(); // DISPON
timer.delay_ms(50);
// Build mipidsi for DRAWING only — NO reset_pin (already reset), so it just re-asserts the
// basics (SLPOUT/MADCTL=0x48/INVON/COLMOD/NORON/DISPON) without touching the extension setup.
let mut display = Builder::new(ST7796, di)
.display_size(WIDTH, HEIGHT)
.color_order(ColorOrder::Bgr)
.invert_colors(ColorInversion::Inverted)
.orientation(Orientation::new().flip_horizontal())
.init(&mut timer)
.unwrap();
info!("display init ok, {} free", HEAP.free());
let mut st = St7796 { spi, dc, cs, rst };
st.init(&mut timer);
info!("display init ok (ported st7796 driver), {} free", HEAP.free());
// ---- inputs + speaker ----
let mut btn_a = pins.gpio15.into_pull_up_input(); // A = play/stop
@ -166,6 +253,7 @@ fn main() -> ! {
spk.set_top(600); // ~2 kHz click
spk.enable();
spk.channel_b.output_to(pins.gpio13);
info!("scaffolding (buttons/adc/pwm) up, {} free", HEAP.free());
// ---- built-in grooves ----
const GROOVES: [&str; 4] = [
@ -183,18 +271,24 @@ fn main() -> ! {
let mut playing = true;
let mut notation = false;
// double-buffer: draw into the RAM framebuffer, then push only the full-width ROW BANDS that
// changed vs the last frame. Full-width windows (CASET 0..319) behave exactly like the proven
// full-screen blit — unlike sub-width tiles, which the panel's MX bit mirror-maps wrongly.
let mut fb = FrameBuf { px: unsafe { &mut *core::ptr::addr_of_mut!(FRAMEBUF) } };
let mut row_hash = [0u64; HEIGHT as usize]; // FNV-1a of each row at its last blit (0 → force first paint)
let mut bar_start = timer.get_counter().ticks();
let mut last_step = usize::MAX;
let mut last_step = usize::MAX; // audio: click on a new step
let mut drawn_step = usize::MAX; // draw: advance the playhead
let mut click_off_us: u64 = 0;
let (mut pa, mut pb) = (false, false);
let mut joy_zone = 0i8;
let mut full_redraw = true;
let mut last_draw_us = 0u64;
let mut dirty = true; // force the first frame
let mut force_full = true; // first paint + big changes use the known-good full blit
let mut hb = false;
let mut hb_us = 0u64;
let mut last_draw_ok = false;
led.set_low().unwrap();
info!("entering main loop");
info!("entering metronome loop (double-buffered)");
loop {
let now = timer.get_counter().ticks();
@ -208,11 +302,12 @@ fn main() -> ! {
bar_start = now;
last_step = usize::MAX;
}
full_redraw = true;
dirty = true;
}
if b && !pb {
notation = !notation;
full_redraw = true;
dirty = true;
force_full = true; // whole screen changes — use the clean full blit
}
pa = a;
pb = b;
@ -243,7 +338,10 @@ fn main() -> ! {
}
_ => {}
}
full_redraw = true;
dirty = true;
if zone == 3 || zone == 4 {
force_full = true; // groove change → new lanes, use the clean full blit
}
}
joy_zone = zone;
@ -270,9 +368,13 @@ fn main() -> ! {
let _ = spk.channel_b.set_duty_cycle(0);
}
// ---- draw: on change, and periodically (so a draw lost right after init reappears, and
// the playhead advances). ~7 fps; partial/playhead-only redraw is the next optimization. ----
if full_redraw || now.wrapping_sub(last_draw_us) > 140_000 {
// redraw when the playhead steps to a new cell (animation), or on any state change
if playing && cur_step != drawn_step {
dirty = true;
}
// ---- draw into the framebuffer, then blit ONCE (flicker-free) ----
if dirty {
let lanes: Vec<pm_ui::LaneView> = track
.lanes
.iter()
@ -285,23 +387,70 @@ fn main() -> ! {
})
.collect();
let screen = pm_ui::Screen { name: NAMES[idx], bpm: tempo, playing, phase, lanes: &lanes };
last_draw_ok = if notation {
pm_ui::draw_notation(&mut display, &screen).is_ok()
if notation {
pm_ui::draw_notation(&mut fb, &screen).ok();
} else {
pm_ui::draw_metronome(&mut display, &screen).is_ok()
};
full_redraw = false;
last_draw_us = now;
pm_ui::draw_metronome(&mut fb, &screen).ok();
}
#[inline(always)]
fn row_fnv(row: &[Rgb565]) -> u64 {
let mut h = 0xcbf29ce484222325u64; // FNV-1a offset basis
for &p in row {
h ^= p.into_storage() as u64;
h = h.wrapping_mul(0x100000001b3);
}
h
}
let w = WIDTH as usize;
if force_full {
// known-good full-screen blit; seed the row hashes so increments diff against it
st.blit(&fb.px[..]);
for yy in 0..HEIGHT as usize {
row_hash[yy] = row_fnv(&fb.px[yy * w..yy * w + w]);
}
force_full = false;
info!("FULL blit (seeded {} rows)", HEIGHT as u32);
} else {
// ---- dirty row-band blit: contiguous runs of changed rows, full-width ----
let mut pushed_rows = 0u32;
let mut y = 0usize;
while y < HEIGHT as usize {
let h = row_fnv(&fb.px[y * w..y * w + w]);
if h != row_hash[y] {
let ylo = y;
row_hash[y] = h;
y += 1;
while y < HEIGHT as usize {
let hh = row_fnv(&fb.px[y * w..y * w + w]);
if hh == row_hash[y] {
break; // an unchanged row ends the dirty run
}
row_hash[y] = hh;
y += 1;
}
let band = (y - ylo) as u16;
st.blit_band(&fb.px[ylo * w..y * w], ylo as u16, band);
pushed_rows += band as u32;
} else {
y += 1;
}
}
if pushed_rows > 0 {
info!("blit {} / {} rows (bands)", pushed_rows, HEIGHT as u32);
}
}
drawn_step = cur_step;
dirty = false;
}
// heartbeat LED + log (~2 Hz) — confirms the loop is alive and the last draw returned Ok
// heartbeat LED + log (~2 Hz)
if now.wrapping_sub(hb_us) > 500_000 {
hb = !hb;
let _ = if hb { led.set_high() } else { led.set_low() };
info!("alive: idx={} step={} playing={} draw_ok={} free={}", idx, cur_step, playing, last_draw_ok, HEAP.free());
info!("alive: idx={} step={} playing={} free={}", idx, cur_step, playing, HEAP.free());
hb_us = now;
}
timer.delay_ms(8);
timer.delay_ms(4);
}
}