diff --git a/rust/pm-kit/src/main.rs b/rust/pm-kit/src/main.rs index 7f0c241..e94b021 100644 --- a/rust/pm-kit/src/main.rs +++ b/rust/pm-kit/src/main.rs @@ -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: SPI, + dc: DC, + cs: CS, + rst: RST, } -impl OutputPin for NoCs { - fn set_low(&mut self) -> Result<(), core::convert::Infallible> { +impl St7796 +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(&mut self, pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator>, + { + 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(&mut self, area: &Rectangle, colors: I) -> Result<(), Self::Error> + where + I: IntoIterator, + { + // 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; 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::(); let mosi = pins.gpio3.into_function::(); 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 = 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); } }