Files
typewriter/firmware/src/epd.rs
Julien Calixte 00b57bac55 perf(firmware): window partial refresh to the edited rows
Per keystroke, diff the new frame against the last shown one to find the
changed row band and partial-refresh only those rows instead of all 272.
E-paper update time scales with the gate rows driven, so a single edited
text line refreshes far faster. Windowed in Y only (full width, both
controllers), so the seam/mirroring logic is unchanged. Also drop the
periodic full refresh to every 64 updates — the panel stays visually
clean, so it's now mainly for longevity.
2026-07-05 00:08:14 +02:00

372 lines
14 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.
//!
//! Capabilities: hardware reset, init, uniform fill, full-frame blit via an
//! `embedded-graphics` `DrawTarget` (`Frame`), full refresh (`display_frame`),
//! and partial refresh (`display_frame_partial`) — Spikes 2 and 5.
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] }
}
#[allow(dead_code)] // symmetric with new_white; kept as part of the API
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(())
}
/// Port of GxEPD2 `_Update_Part` — the partial-update waveform. No full
/// flashing; only pixels that differ between the "previous" (`0x26`) and
/// "current" (`0x24`) banks transition. Much faster than a full refresh
/// but leaves faint ghosting that a periodic full refresh clears. Like
/// GxEPD2 for this dual-controller panel, the update covers the whole
/// panel (windowing isn't worthwhile — the waveform time dominates, not
/// the area).
/// `y0`/`h` restrict the update to a horizontal band of rows. E-paper
/// update time scales with the number of gate lines driven, so a narrow
/// band (one text line) is far faster than the whole panel — the win that
/// makes per-keystroke typing responsive. Full width is always driven
/// (both controllers), so the seam/mirroring logic is untouched.
fn update_part(&mut self, y0: u16, h: u16) -> Result<(), EspError> {
self.set_ram_area(0, y0, WIDTH / 2, h, 0x03, 0x80)?; // slave
self.set_ram_area(0, y0, WIDTH / 2, h, 0x03, 0x00)?; // master
self.cmd(0x3C)?; // border waveform control
self.data(&[0x80])?; // VCOM
self.cmd(0x21)?; // display update control 1
self.data(&[0x00, 0x10])?; // RED normal, cascade
self.cmd(0x22)?; // display update control 2
self.data(&[0xFF])?; // partial update
self.cmd(0x20)?; // master activation
self.wait_while_busy(2000)?; // partial is well under the full ~2.2 s
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 rows `y0..y0+h` of a 792×272 framebuffer into one RAM bank on both
/// controllers. Port of GxEPD2 `_writeFromImage`, windowed in Y: 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. Pass `(0, HEIGHT)` for a full-frame blit.
fn write_frame_bank(&mut self, command: u8, fb: &[u8], y0: u16, h: u16) -> Result<(), EspError> {
let rows = y0 as usize..(y0 + h) as usize;
let mut buf = Vec::with_capacity(CTRL_BYTES_W * h as usize);
for y in rows.clone() {
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, y0, WIDTH / 2, h, 0x03, 0x80)?; // slave
self.cmd(command | 0x80)?;
self.data(&buf)?;
buf.clear();
for y in rows {
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, y0, WIDTH / 2, h, 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, 0, HEIGHT)?; // previous
self.write_frame_bank(0x24, fb, 0, HEIGHT)?; // current
self.update_full()?;
Ok(())
}
/// Partial-refresh only rows `y0..y0+h` of the panel from a full
/// framebuffer — the fast per-keystroke path (pass `(0, HEIGHT)` for the
/// whole panel). Requires the `0x26` (previous) bank to already hold the
/// on-screen image for those rows — true after any `display_frame`,
/// `clear_screen`, or a prior partial covering them. Writes the new rows to
/// `0x24`, runs the partial waveform over just that band, then syncs `0x26`
/// so the next partial has a correct baseline. `fb` is always the full
/// frame; only the given rows are used.
pub fn display_frame_partial_window(
&mut self,
fb: &[u8],
y0: u16,
h: u16,
) -> Result<(), EspError> {
assert_eq!(fb.len(), FB_BYTES, "framebuffer must be 99 x 272 bytes");
assert!(h > 0 && y0 + h <= HEIGHT, "row window out of range");
self.write_frame_bank(0x24, fb, y0, h)?; // current = new
self.update_part(y0, h)?; // transition previous (0x26) -> current (0x24)
self.write_frame_bank(0x26, fb, y0, h)?; // previous = new, for next time
Ok(())
}
}