diff --git a/firmware/Cargo.toml b/firmware/Cargo.toml index d1af9b4..5362469 100644 --- a/firmware/Cargo.toml +++ b/firmware/Cargo.toml @@ -27,6 +27,7 @@ log = "0.4" esp-idf-svc = { version = "0.52.1", features = ["critical-section", "embassy-time-driver", "embassy-sync"] } # Remove `generic-queue-8` if you plan to use `embassy-time` WITH `embassy-executor` embassy-time = { version = "0.5", features = ["generic-queue-8"] } +embedded-graphics = "0.8" [build-dependencies] embuild = "0.33" diff --git a/firmware/src/epd.rs b/firmware/src/epd.rs new file mode 100644 index 0000000..ba25cb1 --- /dev/null +++ b/firmware/src/epd.rs @@ -0,0 +1,321 @@ +//! Thin SSD1683 driver for the GDEY0579T93 (792×272) e-paper panel. +//! +//! This panel is a *dual-controller* device: 792×272 exceeds one SSD1683's +//! 400×300 limit, so it is driven as a **master** (command offset `0x00`) + +//! **slave** (`0x80`) pair, with the framebuffer split between them. The +//! command sequences and RAM-window math are ported faithfully from GxEPD2's +//! `GxEPD2_579_GDEY0579T93` (Jean-Marc Zingg), itself based on the Good +//! Display factory demo. See `docs/v0.1-mvp-technical.md` (Spike 2) and +//! ADR-003. +//! +//! **Spike 2a scope:** hardware reset, init, uniform full-screen fill, full +//! refresh — enough to prove wiring + SPI + both controllers + refresh. +//! Text (an `embedded-graphics` `DrawTarget` + the per-quadrant blit from +//! GxEPD2's `_writeFromImage`) is Spike 2b. + +use embedded_graphics::pixelcolor::BinaryColor; +use embedded_graphics::prelude::*; +use esp_idf_svc::hal::delay::FreeRtos; +use esp_idf_svc::hal::gpio::{Input, Output, PinDriver}; +use esp_idf_svc::hal::spi::{SpiBusDriver, SpiDriver}; +use esp_idf_svc::sys::EspError; + +pub const WIDTH: u16 = 792; +pub const HEIGHT: u16 = 272; + +/// Each controller drives one half. SSD1683 X is byte-addressed; 396 px +/// rounds up to 50 bytes (400 px) of RAM width, full panel height (272 rows). +const CTRL_BYTES_W: usize = 50; +const CTRL_BYTES: usize = CTRL_BYTES_W * HEIGHT as usize; // 50 * 272 = 13600 + +/// Full-frame 1-bit framebuffer: 792 px = 99 bytes per row, MSB-first, +/// 1 = white, 0 = black (SSD16xx convention). +pub const FB_BYTES_W: usize = (WIDTH / 8) as usize; // 99 +pub const FB_BYTES: usize = FB_BYTES_W * HEIGHT as usize; // 26928 + +/// In-memory 792×272 1-bit frame, drawable via `embedded-graphics`. +/// `BinaryColor::On` = black ink, `Off` = white paper. +pub struct Frame { + buf: Vec, +} + +impl Frame { + pub fn new_white() -> Self { + Self { buf: vec![0xFF; FB_BYTES] } + } + + pub fn new_black() -> Self { + Self { buf: vec![0x00; FB_BYTES] } + } + + pub fn bytes(&self) -> &[u8] { + &self.buf + } +} + +impl OriginDimensions for Frame { + fn size(&self) -> Size { + Size::new(WIDTH as u32, HEIGHT as u32) + } +} + +impl DrawTarget for Frame { + type Color = BinaryColor; + type Error = core::convert::Infallible; + + fn draw_iter(&mut self, pixels: I) -> Result<(), Self::Error> + where + I: IntoIterator>, + { + for Pixel(p, color) in pixels { + if (0..WIDTH as i32).contains(&p.x) && (0..HEIGHT as i32).contains(&p.y) { + let idx = p.y as usize * FB_BYTES_W + p.x as usize / 8; + let bit = 0x80u8 >> (p.x % 8); + match color { + BinaryColor::On => self.buf[idx] &= !bit, // black ink + BinaryColor::Off => self.buf[idx] |= bit, // white paper + } + } + } + Ok(()) + } +} + +/// Max bytes per SPI transfer; matches the DMA size configured in `main`. +const SPI_CHUNK: usize = 4096; + +pub struct Epd<'d> { + spi: SpiBusDriver<'d, SpiDriver<'d>>, + dc: PinDriver<'d, Output>, + rst: PinDriver<'d, Output>, + cs: PinDriver<'d, Output>, + busy: PinDriver<'d, Input>, +} + +impl<'d> Epd<'d> { + pub fn new( + spi: SpiBusDriver<'d, SpiDriver<'d>>, + dc: PinDriver<'d, Output>, + rst: PinDriver<'d, Output>, + cs: PinDriver<'d, Output>, + busy: PinDriver<'d, Input>, + ) -> Self { + Self { spi, dc, rst, cs, busy } + } + + // ---- low-level SPI framing (DC low = command, DC high = data) ---- + + fn cmd(&mut self, c: u8) -> Result<(), EspError> { + self.dc.set_low()?; + self.cs.set_low()?; + self.spi.write(&[c])?; + self.cs.set_high()?; + Ok(()) + } + + fn data(&mut self, bytes: &[u8]) -> Result<(), EspError> { + self.dc.set_high()?; + self.cs.set_low()?; + for chunk in bytes.chunks(SPI_CHUNK) { + self.spi.write(chunk)?; + } + self.cs.set_high()?; + Ok(()) + } + + /// BUSY is active-HIGH on this panel (GxEPD2 constructs with `HIGH`). + fn wait_while_busy(&mut self, timeout_ms: u32) -> Result<(), EspError> { + let mut waited = 0; + while self.busy.is_high() { + FreeRtos::delay_ms(1); + waited += 1; + if waited >= timeout_ms { + log::warn!("EPD BUSY still high after {timeout_ms} ms — continuing"); + break; + } + } + Ok(()) + } + + // ---- panel bring-up ---- + + /// Hardware reset (RST is active-low). ~20 ms pulses per GxEPD2 default. + pub fn reset(&mut self) -> Result<(), EspError> { + self.rst.set_high()?; + FreeRtos::delay_ms(20); + self.rst.set_low()?; + FreeRtos::delay_ms(20); + self.rst.set_high()?; + FreeRtos::delay_ms(20); + self.wait_while_busy(100)?; + Ok(()) + } + + /// Port of GxEPD2 `_InitDisplay` (B/W mode). The `0x20` master + /// activations load the temperature value and LUT. + pub fn init(&mut self) -> Result<(), EspError> { + self.cmd(0x12)?; // SWRESET + FreeRtos::delay_ms(10); + self.wait_while_busy(100)?; + self.cmd(0x18)?; // temperature sensor control + self.data(&[0x80])?; // internal sensor + self.cmd(0x22)?; // display update control 2 + self.data(&[0xB1])?; // enable clock, load temp, load LUT (B/W), disable clock + self.cmd(0x20)?; // master activation + FreeRtos::delay_ms(10); + self.wait_while_busy(100)?; + self.cmd(0x1A)?; // write to temperature register + self.data(&[0x64, 0x00])?; + self.cmd(0x22)?; + self.data(&[0x91])?; // load temp, load LUT (B/W), disable clock + self.cmd(0x20)?; + FreeRtos::delay_ms(10); + self.wait_while_busy(100)?; + Ok(()) + } + + /// Port of GxEPD2 `_setPartialRamArea`. `target` is `0x00` (master) or + /// `0x80` (slave); `mode` selects X/Y increment/decrement (0x00–0x03). + fn set_ram_area( + &mut self, + x: u16, + y: u16, + w: u16, + h: u16, + mode: u8, + target: u8, + ) -> Result<(), EspError> { + self.cmd(0x11 | target)?; // data entry mode + self.data(&[mode])?; + let xl = (x / 8) as u8; + let xh = ((x + w - 1) / 8) as u8; + let ys = [(y % 256) as u8, (y / 256) as u8]; + let ye = [((y + h - 1) % 256) as u8, ((y + h - 1) / 256) as u8]; + match mode { + 0x03 => { + // X increment, Y increment + self.cmd(0x44 | target)?; + self.data(&[xl, xh])?; + self.cmd(0x45 | target)?; + self.data(&[ys[0], ys[1], ye[0], ye[1]])?; + self.cmd(0x4E | target)?; + self.data(&[xl])?; + self.cmd(0x4F | target)?; + self.data(&[ys[0], ys[1]])?; + } + 0x02 => { + // X decrement, Y increment + self.cmd(0x44 | target)?; + self.data(&[xh, xl])?; + self.cmd(0x45 | target)?; + self.data(&[ys[0], ys[1], ye[0], ye[1]])?; + self.cmd(0x4E | target)?; + self.data(&[xh])?; + self.cmd(0x4F | target)?; + self.data(&[ys[0], ys[1]])?; + } + 0x01 => { + // X increment, Y decrement + self.cmd(0x44 | target)?; + self.data(&[xl, xh])?; + self.cmd(0x45 | target)?; + self.data(&[ye[0], ye[1], ys[0], ys[1]])?; + self.cmd(0x4E | target)?; + self.data(&[xl])?; + self.cmd(0x4F | target)?; + self.data(&[ye[0], ye[1]])?; + } + _ => { + // 0x00: X decrement, Y decrement + self.cmd(0x44 | target)?; + self.data(&[xh, xl])?; + self.cmd(0x45 | target)?; + self.data(&[ye[0], ye[1], ys[0], ys[1]])?; + self.cmd(0x4E | target)?; + self.data(&[xh])?; + self.cmd(0x4F | target)?; + self.data(&[ye[0], ye[1]])?; + } + } + FreeRtos::delay_ms(2); + Ok(()) + } + + /// Fill one RAM bank (`0x24` current or `0x26` previous) on both + /// controllers with a constant byte. One clean full-coverage window per + /// controller (slave = left half `0x80`, master = right half `0x00`) — + /// simpler and more complete than GxEPD2's overlapping-window fill, which + /// only matters for a constant value anyway. + fn write_buffer(&mut self, command: u8, value: u8) -> Result<(), EspError> { + let buf = vec![value; CTRL_BYTES]; + for target in [0x80u8, 0x00u8] { + self.set_ram_area(0, 0, 400, HEIGHT, 0x03, target)?; + self.cmd(command | target)?; + self.data(&buf)?; + } + Ok(()) + } + + /// Port of GxEPD2 `refresh(false)` → `_Update_Full` (fast full update). + fn update_full(&mut self) -> Result<(), EspError> { + self.set_ram_area(0, 0, WIDTH / 2, HEIGHT, 0x03, 0x80)?; // slave + self.set_ram_area(0, 0, WIDTH / 2, HEIGHT, 0x03, 0x00)?; // master + self.cmd(0x21)?; // display update control 1 + self.data(&[0x40, 0x10])?; // bypass RED as 0, cascade + self.cmd(0x1A)?; // temperature register (fast full update) + self.data(&[0x64, 0x00])?; + self.cmd(0x22)?; + self.data(&[0xD7])?; // fast full update + self.cmd(0x20)?; // master activation + self.wait_while_busy(2500)?; // full_refresh_time ≈ 2200 ms + Ok(()) + } + + /// Fill the whole panel with one value and full-refresh. + /// `0xFF` = white, `0x00` = black. Port of GxEPD2 `clearScreen`. + pub fn clear_screen(&mut self, value: u8) -> Result<(), EspError> { + self.write_buffer(0x26, value)?; // previous + self.write_buffer(0x24, value)?; // current + self.update_full()?; + Ok(()) + } + + /// Blit a full 792×272 framebuffer into one RAM bank on both + /// controllers. Port of the full-frame case of GxEPD2 `_writeFromImage`: + /// slave gets panel bytes 0..=49 of each row in X-increment mode; the + /// master's sources are wired mirrored, so it gets bytes 49..=98 in + /// bitmap order while the address counter walks RAM 49..=0 (mode 0x02). + /// The seam byte 49 (px 392..399) lands on both; the 4 columns past each + /// controller's 396 sources aren't wired. + fn write_frame_bank(&mut self, command: u8, fb: &[u8]) -> Result<(), EspError> { + let mut buf = Vec::with_capacity(CTRL_BYTES); + for y in 0..HEIGHT as usize { + let row = &fb[y * FB_BYTES_W..(y + 1) * FB_BYTES_W]; + buf.extend_from_slice(&row[..CTRL_BYTES_W]); + } + self.set_ram_area(0, 0, WIDTH / 2, HEIGHT, 0x03, 0x80)?; // slave + self.cmd(command | 0x80)?; + self.data(&buf)?; + + buf.clear(); + for y in 0..HEIGHT as usize { + let row = &fb[y * FB_BYTES_W..(y + 1) * FB_BYTES_W]; + buf.extend_from_slice(&row[FB_BYTES_W - CTRL_BYTES_W..]); + } + self.set_ram_area(0, 0, WIDTH / 2, HEIGHT, 0x02, 0x00)?; // master + self.cmd(command)?; + self.data(&buf)?; + Ok(()) + } + + /// Show a full 792×272 framebuffer (`FB_BYTES` long) with a full + /// refresh. Writes both RAM banks so the next differential update has a + /// consistent "previous" image. + pub fn display_frame(&mut self, fb: &[u8]) -> Result<(), EspError> { + assert_eq!(fb.len(), FB_BYTES, "framebuffer must be 99 x 272 bytes"); + self.write_frame_bank(0x26, fb)?; // previous + self.write_frame_bank(0x24, fb)?; // current + self.update_full()?; + Ok(()) + } +} diff --git a/firmware/src/main.rs b/firmware/src/main.rs index 6417b63..bfbcd24 100644 --- a/firmware/src/main.rs +++ b/firmware/src/main.rs @@ -1,14 +1,23 @@ -use std::time::Duration; +mod epd; +use embedded_graphics::mono_font::ascii::FONT_10X20; +use embedded_graphics::mono_font::MonoTextStyle; +use embedded_graphics::pixelcolor::BinaryColor; +use embedded_graphics::prelude::*; +use embedded_graphics::primitives::{Circle, PrimitiveStyle}; +use embedded_graphics::text::{Alignment, Text}; use esp_idf_svc::hal::delay::FreeRtos; -use esp_idf_svc::hal::gpio::PinDriver; +use esp_idf_svc::hal::gpio::{AnyIOPin, PinDriver, Pull}; use esp_idf_svc::hal::peripherals::Peripherals; -use esp_idf_svc::hal::rmt::config::{TransmitConfig, TxChannelConfig}; -use esp_idf_svc::hal::rmt::encoder::CopyEncoder; -use esp_idf_svc::hal::rmt::{PinState, Symbol, TxChannelDriver}; -use esp_idf_svc::hal::units::Hertz; +use esp_idf_svc::hal::spi::config::{Config, DriverConfig}; +use esp_idf_svc::hal::spi::{Dma, SpiBusDriver, SpiDriver}; +use esp_idf_svc::hal::units::FromValueType; -const WS2812_RESOLUTION: Hertz = Hertz(10_000_000); +use epd::Epd; + +/// Injected by build.rs so serial output and the panel itself identify the +/// exact build being diagnosed. +const BUILD_TAG: &str = concat!("build ", env!("BUILD_TIME"), " @", env!("BUILD_GIT")); fn main() -> anyhow::Result<()> { // Required once before any esp-idf-svc call; some runtime patches @@ -17,61 +26,76 @@ fn main() -> anyhow::Result<()> { esp_idf_svc::log::EspLogger::initialize_default(); let peripherals = Peripherals::take()?; - let mut led = PinDriver::output(peripherals.pins.gpio2)?; + let pins = peripherals.pins; - // On-board addressable LED (WS2812) — GPIO 48 on the DevKitC-1 v1.0; - // v1.1 boards moved it to GPIO 38. - let mut rgb = TxChannelDriver::new( - peripherals.pins.gpio48, - &TxChannelConfig { - resolution: WS2812_RESOLUTION, - ..Default::default() - }, + // GDEY0579T93 wiring on S3-safe GPIOs (clear of flash 26–32, octal PSRAM + // 33–37, strapping 0/3/45/46, USB 19/20, RGB LED 38/48). See + // docs/v0.1-mvp-technical.md (Spike 2): + // SCK 12 · DIN/MOSI 11 · CS 7 · DC 6 · RST 5 · BUSY 4 + let spi = SpiDriver::new( + peripherals.spi2, + pins.gpio12, // SCK + pins.gpio11, // SDO / MOSI (DIN) + None::, // SDI / MISO — unused (write-only panel) + &DriverConfig::new().dma(Dma::Auto(4096)), )?; + // 4 MHz — GxEPD2's default for this controller. Verified clean on the + // breadboard rig; a loose CS jumper (not clock speed) was behind the + // early bring-up noise. + let bus = SpiBusDriver::new(spi, &Config::new().baudrate(4.MHz().into()))?; - log::info!("Typoena Spike 1 — Blink on GPIO 2 + on-board WS2812 (GPIO 48)"); + let cs = PinDriver::output(pins.gpio7)?; + let dc = PinDriver::output(pins.gpio6)?; + let rst = PinDriver::output(pins.gpio5)?; + let busy = PinDriver::input(pins.gpio4, Pull::Down)?; - let mut n: u32 = 0; + let mut epd = Epd::new(bus, dc, rst, cs, busy); + + log::info!("Typoena Spike 2b — GDEY0579T93 text test, {BUILD_TAG}"); + log::info!("hardware reset…"); + epd.reset()?; + log::info!("init…"); + epd.init()?; + epd.clear_screen(0xFF)?; // initial clean slate, per GxEPD2 + + // Alternate normal and inverted frames: circle on the master/slave seam + // (proves the split-and-mirror blit), "Typoena" inside it, and the build + // tag at the bottom so the panel identifies the running build. + let frames = [make_frame(false), make_frame(true)]; + let mut i = 0; loop { - led.set_high()?; - ws2812_set(&mut rgb, 4, 0, 24)?; - log::info!("blink {n}"); - n = n.wrapping_add(1); - FreeRtos::delay_ms(500); - - led.set_low()?; - ws2812_set(&mut rgb, 0, 0, 0)?; - FreeRtos::delay_ms(500); + log::info!("frame → {}", if i == 0 { "black on WHITE" } else { "white on BLACK" }); + epd.display_frame(frames[i].bytes())?; + log::info!("refresh done; holding 3 s"); + FreeRtos::delay_ms(3000); + i = 1 - i; } } -/// Send one WS2812 GRB frame. The ≥50 µs low reset the LED needs between -/// frames is covered by the 500 ms idle gap between calls. -fn ws2812_set(tx: &mut TxChannelDriver, r: u8, g: u8, b: u8) -> anyhow::Result<()> { - // Bit timings from the WS2812 datasheet. - let zero = Symbol::new_with( - WS2812_RESOLUTION, - PinState::High, - Duration::from_nanos(350), - PinState::Low, - Duration::from_nanos(800), - )?; - let one = Symbol::new_with( - WS2812_RESOLUTION, - PinState::High, - Duration::from_nanos(700), - PinState::Low, - Duration::from_nanos(600), - )?; - - let mut symbols = [zero; 24]; - for (i, byte) in [g, r, b].into_iter().enumerate() { - for bit in 0..8 { - if byte & (0x80 >> bit) != 0 { - symbols[i * 8 + bit] = one; - } - } - } - tx.send_and_wait(CopyEncoder::new()?, &symbols, &TransmitConfig::default())?; - Ok(()) +/// Circle centered on the controller seam, "Typoena" inside, build tag at +/// the bottom edge. `inverted` swaps ink and paper. +fn make_frame(inverted: bool) -> epd::Frame { + let (mut frame, ink) = if inverted { + (epd::Frame::new_black(), BinaryColor::Off) + } else { + (epd::Frame::new_white(), BinaryColor::On) + }; + let center = Point::new(epd::WIDTH as i32 / 2, epd::HEIGHT as i32 / 2); + let style = MonoTextStyle::new(&FONT_10X20, ink); + Circle::with_center(center, 200) + .into_styled(PrimitiveStyle::with_stroke(ink, 6)) + .draw(&mut frame) + .unwrap(); + Text::with_alignment("Typoena", center + Point::new(0, 7), style, Alignment::Center) + .draw(&mut frame) + .unwrap(); + Text::with_alignment( + BUILD_TAG, + Point::new(center.x, epd::HEIGHT as i32 - 10), + style, + Alignment::Center, + ) + .draw(&mut frame) + .unwrap(); + frame }