Files
typewriter/firmware/src/epd.rs
Julien Calixte 6145d27405 feat(firmware): drive GDEY0579T93 EPD via dual-SSD1683 driver
Add a thin dual-SSD1683 driver (src/epd.rs, ported from GxEPD2's
GxEPD2_579_GDEY0579T93) for the 792x272 e-paper panel: reset/init,
RAM addressing, the split-and-mirror full-frame blit across the
master/slave seam at x=396, and full-refresh waveform. Frame
implements embedded-graphics' DrawTarget.

Replace the Spike 1 blink harness in main.rs with the Spike 2 test:
SPI at 4 MHz over SCK 12 / DIN 11 / CS 7 / DC 6 / RST 5 / BUSY 4,
alternating normal/inverted frames with a seam-straddling circle,
"Typoena", and the build tag. Verified on hardware 2026-07-04.
2026-07-04 18:58:42 +02:00

322 lines
12 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! 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<u8>,
}
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<I>(&mut self, pixels: I) -> Result<(), Self::Error>
where
I: IntoIterator<Item = Pixel<Self::Color>>,
{
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 (0x000x03).
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(())
}
}