Compare commits

..

3 Commits

Author SHA1 Message Date
Julien Calixte
00a852b8c8 docs(firmware): record Spike 2 EPD verification and build stamping 2026-07-04 18:58:47 +02:00
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
Julien Calixte
d971f36f10 chore(firmware): stamp builds with UTC time and git hash
Emit BUILD_TIME and BUILD_GIT (git describe --always --dirty) as
rustc env vars so the running firmware can identify itself on serial
and on-panel. A rerun-if-changed on a nonexistent file forces the
script every build, keeping the timestamp fresh. Bring-up lesson:
know which build you're diagnosing.
2026-07-04 18:58:33 +02:00
5 changed files with 458 additions and 67 deletions

View File

@@ -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"

View File

@@ -7,19 +7,40 @@ context.
## Current state
**Spike 1Blink.** Toggles GPIO 2 every 500 ms and logs `blink N` to the
USB-serial console. This proves three things only:
**Spike 2EPD: verified 2026-07-04.** `main.rs` drives the GDEY0579T93
e-paper panel through the thin dual-SSD1683 driver in
[`src/epd.rs`](src/epd.rs) (ported from GxEPD2's `GxEPD2_579_GDEY0579T93`).
Verified on the bench rig over 4 MHz SPI:
1. The Espressif Rust toolchain (Xtensa) is installed and on PATH.
2. The crate links against `esp-idf-svc` and compiles for
`xtensa-esp32s3-espidf`.
3. Basic GPIO output works on real silicon — verified 2026-07-04 on an
ESP32-S3 (rev v0.2, 16 MB flash): `blink N` streams at 1 Hz over
USB-serial (115200) and GPIO 2 + the on-board WS2812 toggle in lockstep.
- **2a — uniform fill:** clean full-panel white ↔ black refreshes, proving
the wiring, both cascaded controllers, RAM addressing, and the full
refresh waveform.
- **2b — graphics/text:** `epd::Frame` implements `embedded-graphics`'
`DrawTarget`; a stroked circle straddling the master/slave seam (x = 396)
renders round and continuous, and `FONT_10X20` text is legible — proving
the split-and-mirror full-frame blit (`Epd::display_frame`).
Everything past that — EPD, SD, USB host, partial refresh, Wi-Fi/TLS,
gitoxide push — is its own follow-up spike per
[`docs/v0.1-mvp-technical.md`](../docs/v0.1-mvp-technical.md#hardware-bring-up-order).
Wiring: SCK 12 · DIN/MOSI 11 · CS 7 · DC 6 · RST 5 · BUSY 4, via the
DESPI-C579 breakout.
Every build is stamped by [`build.rs`](build.rs) with UTC time and
`git describe --always --dirty`; the tag is logged on serial at boot and
drawn on the panel, so the running build is always identifiable during
diagnosis.
Bring-up note: the initial symptom was per-pixel noise on the panel — a
half-seated CS jumper, not firmware. If the panel shows speckle/banding,
reseat the jumpers (CS first) before debugging code.
Next up per
[`docs/v0.1-mvp-technical.md`](../docs/v0.1-mvp-technical.md#hardware-bring-up-order):
USB host (keyboard), partial refresh, Wi-Fi/TLS, gitoxide push; SD is
deferred.
**Spike 1 — Blink: verified 2026-07-04.** GPIO 2 + on-board WS2812 toggled
at 1 Hz with `blink N` on USB-serial, proving toolchain, esp-idf link, and
GPIO on real silicon. The blink code was replaced by Spike 2 in `main.rs`
(see git history: `e040a8d`).
## Quick commands

View File

@@ -1,3 +1,27 @@
use std::process::Command;
fn main() {
embuild::espidf::sysenv::output();
// Stamp the binary so serial output and on-panel text identify the
// exact build (bring-up lesson: know which build you're diagnosing).
let git = Command::new("git")
.args(["describe", "--always", "--dirty"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".into());
let time = Command::new("date")
.args(["-u", "+%m-%d %H:%M"])
.output()
.ok()
.filter(|o| o.status.success())
.map(|o| String::from_utf8_lossy(&o.stdout).trim().to_string())
.unwrap_or_else(|| "unknown".into());
println!("cargo:rustc-env=BUILD_GIT={git}");
println!("cargo:rustc-env=BUILD_TIME={time}Z");
// Pointing rerun-if-changed at a file that never exists forces this
// script to rerun on every build, keeping BUILD_TIME fresh.
println!("cargo:rerun-if-changed=.force-build-stamp");
}

321
firmware/src/epd.rs Normal file
View File

@@ -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<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(())
}
}

View File

@@ -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 2632, octal PSRAM
// 3337, 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::<AnyIOPin>, // 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
}