Files
typewriter/firmware/src/epd.rs
Julien Calixte e8063a2b13 refactor(editor): extract editor + display into host-testable crates
Move the editor core out of the firmware crate (pinned to the xtensa
target, so it can't run `cargo test`) into a standalone `editor` crate,
with the panel framebuffer + geometry split into a `display` crate. Both
depend only on embedded-graphics + keymap, so the editor is now
host-buildable and unit-tested (8 characterization tests). Firmware links
them and re-exports the geometry from epd.rs; behaviour is unchanged.

The xtensa firmware build was not verified in this environment.
2026-07-11 00:28:37 +02:00

319 lines
13 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 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;
// Panel geometry and the drawable `Frame` now live in the `display` crate, so
// the editor can render onto them off the xtensa target. Re-exported here so
// `epd::HEIGHT`, `epd::FB_BYTES`, etc. keep resolving for main.rs and the driver
// code below, and so the driver need not know they were relocated.
pub use display::{FB_BYTES, FB_BYTES_W, HEIGHT, WIDTH};
/// 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
/// 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(())
}
}