diff --git a/.cargo/config.toml b/.cargo/config.toml new file mode 100644 index 00000000..3c32d251 --- /dev/null +++ b/.cargo/config.toml @@ -0,0 +1,2 @@ +[target.aarch64-unknown-linux-gnu] +linker = "aarch64-linux-gnu-gcc" diff --git a/.gitignore b/.gitignore index 1f41f710..74b6ca30 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,7 @@ Cargo.lock # intellij/clion .idea/ + +# Internal process documentation — never upstream +docs/ +.claude/ diff --git a/Cargo.toml b/Cargo.toml index 73c33370..be57a4ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,22 @@ required-features = ["linux-dev"] name = "epd4in2_variable_size" required-features = ["linux-dev"] +[[example]] +name = "epd2in13_v4" +required-features = ["linux-dev"] + +[[example]] +name = "epd2in13_v4_raw" +required-features = ["linux-dev"] + +[[example]] +name = "epd2in13_v4_status" +required-features = ["linux-dev"] + +[[example]] +name = "epd3in52_status" +required-features = ["linux-dev"] + [[example]] name = "epd4in2" required-features = ["linux-dev"] @@ -54,6 +70,8 @@ default = ["graphics", "linux-dev", "epd2in13_v3"] graphics = ["embedded-graphics-core"] epd2in13_v2 = [] epd2in13_v3 = [] +epd2in13_v4 = [] +epd3in52 = [] linux-dev = [] # Offers an alternative fast full lut for type_a displays, but the refreshed screen isnt as clean looking diff --git a/examples/assets/ferris_110x73.raw b/examples/assets/ferris_110x73.raw new file mode 100644 index 00000000..47f2715a Binary files /dev/null and b/examples/assets/ferris_110x73.raw differ diff --git a/examples/assets/ferris_96x96.raw b/examples/assets/ferris_96x96.raw new file mode 100644 index 00000000..cfdea54c Binary files /dev/null and b/examples/assets/ferris_96x96.raw differ diff --git a/examples/assets/lobster_96x96.raw b/examples/assets/lobster_96x96.raw new file mode 100644 index 00000000..de0ed77b Binary files /dev/null and b/examples/assets/lobster_96x96.raw differ diff --git a/examples/epd2in13_v4.rs b/examples/epd2in13_v4.rs new file mode 100644 index 00000000..117325a8 --- /dev/null +++ b/examples/epd2in13_v4.rs @@ -0,0 +1,212 @@ +//! Ferris walking demo for the Waveshare 2.13" E-Paper HAT V4 (SSD1680). +//! +//! Demonstrates partial refresh by scuttling Ferris across the screen +//! three times. The sequence is: +//! +//! 1. Full refresh — clear the panel white. +//! 2. [`Epd2in13::display_part_base_image`] — establish the base image +//! in both SSD1680 RAM banks. This is required before any partial +//! refresh will produce correct output. +//! 3. Partial refresh loop — on each step, draw a white rectangle over +//! Ferris' previous position, draw him at the new position, then +//! call [`Epd2in13::display_partial`] which runs the soft reset + +//! partial waveform sequence. +//! 4. Final full refresh — clear the panel white. +//! 5. [`Epd2in13::sleep`] — enter deep sleep. +//! +//! # GPIO backend +//! +//! Uses `gpio_cdev` rather than `sysfs_gpio`: sysfs is deprecated on +//! Raspberry Pi OS Bookworm and the Pi 5 requires `gpio_cdev` due to +//! BCM offset changes on `gpiochip0`. The rest of the upstream examples +//! still use `sysfs_gpio`; this example targets newer RPi OS releases. +//! +//! # Wiring (Waveshare 2.13" V4 HAT, BCM numbering) +//! +//! | Signal | BCM | +//! |--------|-----| +//! | PWR | 18 | +//! | RST | 17 | +//! | DC | 25 | +//! | BUSY | 24 | +//! | SPI | CE0 on /dev/spidev0.0, 4 MHz, mode 0 | + +use embedded_graphics::{ + image::{Image, ImageRaw}, + pixelcolor::BinaryColor, + prelude::*, + primitives::{PrimitiveStyle, Rectangle}, +}; +use embedded_hal::delay::DelayNs; +use epd_waveshare::{ + epd2in13_v4::{Display2in13, Epd2in13}, + graphics::DisplayRotation, + prelude::*, +}; +use linux_embedded_hal::{ + gpio_cdev::{Chip, LineRequestFlags}, + spidev::{self, SpidevOptions}, + CdevPin, Delay, SpidevDevice, +}; + +const FERRIS_W: u32 = 110; +const FERRIS_H: u32 = 73; +const FERRIS_BYTES: &[u8] = include_bytes!("./assets/ferris_110x73.raw"); + +/// Pixels advanced per animation frame. +const STEP: i32 = 5; +/// Horizontal walking room: 250 - 110 = 140. +const X_MAX: i32 = 250 - FERRIS_W as i32; +/// Y position: 3 px from the bottom of the 122 px landscape canvas. +const FERRIS_Y: i32 = 122 - FERRIS_H as i32 - 3; +/// Delay between partial-refresh steps. +const STEP_DELAY_MS: u32 = 150; +/// Number of full back-and-forth walk cycles. +const WALK_CYCLES: u32 = 3; + +fn main() -> Result<(), Box> { + // --- SPI setup --------------------------------------------------------- + let mut spi = + SpidevDevice::open("/dev/spidev0.0").map_err(|e| format!("open /dev/spidev0.0: {e}"))?; + let options = SpidevOptions::new() + .bits_per_word(8) + .max_speed_hz(4_000_000) + .mode(spidev::SpiModeFlags::SPI_MODE_0) + .build(); + spi.configure(&options) + .map_err(|e| format!("configure /dev/spidev0.0: {e}"))?; + + // --- GPIO setup (gpio_cdev) -------------------------------------------- + let mut chip = Chip::new("/dev/gpiochip0").map_err(|e| format!("open /dev/gpiochip0: {e}"))?; + let busy = CdevPin::new( + chip.get_line(24) + .map_err(|e| format!("claim GPIO24 (BUSY): {e}"))? + .request(LineRequestFlags::INPUT, 0, "epd-busy") + .map_err(|e| format!("request GPIO24 (BUSY) as input: {e}"))?, + )?; + let dc = CdevPin::new( + chip.get_line(25) + .map_err(|e| format!("claim GPIO25 (DC): {e}"))? + .request(LineRequestFlags::OUTPUT, 0, "epd-dc") + .map_err(|e| format!("request GPIO25 (DC) as output: {e}"))?, + )?; + let rst = CdevPin::new( + chip.get_line(17) + .map_err(|e| format!("claim GPIO17 (RST): {e}"))? + .request(LineRequestFlags::OUTPUT, 1, "epd-rst") + .map_err(|e| format!("request GPIO17 (RST) as output: {e}"))?, + )?; + let pwr = CdevPin::new( + chip.get_line(18) + .map_err(|e| format!("claim GPIO18 (PWR): {e}"))? + .request(LineRequestFlags::OUTPUT, 0, "epd-pwr") + .map_err(|e| format!("request GPIO18 (PWR) as output: {e}"))?, + )?; + + let mut delay = Delay; + + // --- EPD init ---------------------------------------------------------- + let mut epd = Epd2in13::new_with_pwr(&mut spi, busy, dc, rst, &mut delay, None, pwr) + .map_err(|e| format!("EPD init (SSD1680): {e}"))?; + + let mut display = Display2in13::default(); + display.set_rotation(DisplayRotation::Rotate90); + + // 1. Full refresh — start from a clean white panel. + println!("Clearing panel (full refresh)..."); + display.clear(Color::White).ok(); + epd.update_frame(&mut spi, display.buffer(), &mut delay)?; + epd.display_frame(&mut spi, &mut delay)?; + // SSD1680: let the panel settle before the next RAM write. The busy + // pin drops before the full waveform has fully completed internally. + delay.delay_ms(500); + + // 2. Establish the partial-refresh base image in both RAM banks. + // SSD1680 requires both the "old" and "new" RAM banks to hold the + // starting image before partial updates will waveform correctly. + println!("Establishing partial-refresh base image..."); + display.clear(Color::White).ok(); + draw_ferris(&mut display, 0)?; + epd.display_part_base_image(&mut spi, display.buffer(), &mut delay)?; + // display_part_base_image runs a full refresh internally; settle + // before the first partial step. + delay.delay_ms(500); + + // 3. Walk loop — Ferris scuttles back and forth via partial refresh. + println!("Walking Ferris ({} cycles)...", WALK_CYCLES); + let mut prev_x: i32 = 0; + for _ in 0..WALK_CYCLES { + // Left → right + let mut x = 0; + while x <= X_MAX { + partial_step(&mut epd, &mut spi, &mut display, &mut delay, prev_x, x)?; + prev_x = x; + x += STEP; + delay.delay_ms(STEP_DELAY_MS); + } + // Right → left + let mut x = X_MAX; + while x >= 0 { + partial_step(&mut epd, &mut spi, &mut display, &mut delay, prev_x, x)?; + prev_x = x; + x -= STEP; + delay.delay_ms(STEP_DELAY_MS); + } + } + + // 4. Final full refresh — leave the panel clean. + // SSD1680 requires a re-init after display_partial before it will + // accept full-refresh commands again; skipping this leaves the + // final clear silently dropped. + println!("Clearing panel (final full refresh)..."); + epd.reinit(&mut spi, &mut delay)?; + display.clear(Color::White).ok(); + epd.update_frame(&mut spi, display.buffer(), &mut delay)?; + epd.display_frame(&mut spi, &mut delay)?; + + // 5. Deep sleep. + println!("Sleeping..."); + epd.sleep(&mut spi, &mut delay)?; + + Ok(()) +} + +/// Erase Ferris at `prev_x`, draw him at `new_x`, push via partial refresh. +fn partial_step( + epd: &mut Epd2in13, + spi: &mut SPI, + display: &mut Display2in13, + delay: &mut DELAY, + prev_x: i32, + new_x: i32, +) -> Result<(), SPI::Error> +where + SPI: embedded_hal::spi::SpiDevice, + BUSY: embedded_hal::digital::InputPin, + DC: embedded_hal::digital::OutputPin, + RST: embedded_hal::digital::OutputPin, + DELAY: DelayNs, + PWR: embedded_hal::digital::OutputPin, +{ + // Erase the previous sprite footprint. + Rectangle::new(Point::new(prev_x, FERRIS_Y), Size::new(FERRIS_W, FERRIS_H)) + .into_styled(PrimitiveStyle::with_fill(Color::White)) + .draw(display) + .ok(); + // Redraw at the new position (ok to ignore: Display2in13's error is Infallible). + draw_ferris(display, new_x).ok(); + epd.display_partial(spi, display.buffer(), delay) +} + +/// Blit the Ferris sprite at `(x, FERRIS_Y)`. +fn draw_ferris(display: &mut Display2in13, x: i32) -> Result<(), core::convert::Infallible> { + // The raw asset is 1-bit packed (MSB first). Per src/color.rs the on-wire + // encoding is 0 = White, 1 = Black, so we load it as BinaryColor (Off/On) + // and convert at draw time via the Display2in13's DrawTarget impl. + let raw: ImageRaw = ImageRaw::new(FERRIS_BYTES, FERRIS_W); + let image = Image::new(&raw, Point::new(x, FERRIS_Y)); + image + .draw(&mut display.color_converted::()) + .ok(); + Ok(()) +} diff --git a/examples/epd2in13_v4_raw.rs b/examples/epd2in13_v4_raw.rs new file mode 100644 index 00000000..0f5010d9 --- /dev/null +++ b/examples/epd2in13_v4_raw.rs @@ -0,0 +1,192 @@ +//! Raw SPI/GPIO test for Waveshare 2.13" V4 (SSD1680). +//! Bypasses the entire epd-waveshare driver — sends the exact Python init +//! sequence byte-by-byte to isolate hardware vs driver issues. + +use linux_embedded_hal::gpio_cdev::{Chip, LineHandle, LineRequestFlags}; +use linux_embedded_hal::spidev::{SpiModeFlags, SpidevOptions}; +use linux_embedded_hal::SpidevDevice; +use std::io::Write; +use std::thread; +use std::time::Duration; + +struct RawEpd { + spi: SpidevDevice, + dc: LineHandle, + rst: LineHandle, + pwr: LineHandle, + busy: LineHandle, +} + +impl RawEpd { + fn send_cmd(&mut self, cmd: u8) { + self.dc.set_value(0).unwrap(); + self.spi.write(&[cmd]).unwrap(); + } + + fn send_data(&mut self, data: u8) { + self.dc.set_value(1).unwrap(); + self.spi.write(&[data]).unwrap(); + } + + fn send_data_bulk(&mut self, data: &[u8]) { + self.dc.set_value(1).unwrap(); + // Write in chunks of 4096 (Linux SPI limit) + for chunk in data.chunks(4096) { + self.spi.write(chunk).unwrap(); + } + } + + fn wait_busy(&self) { + // SSD1680: BUSY pin HIGH = busy, LOW = idle + let mut count = 0u32; + while self.busy.get_value().unwrap() == 1 { + thread::sleep(Duration::from_millis(10)); + count += 1; + if count > 500 { + println!(" WARN: busy timeout after 5s"); + return; + } + } + if count > 0 { + println!(" busy waited {}ms", count * 10); + } + } + + fn reset(&mut self) { + self.rst.set_value(1).unwrap(); + thread::sleep(Duration::from_millis(20)); + self.rst.set_value(0).unwrap(); + thread::sleep(Duration::from_millis(2)); + self.rst.set_value(1).unwrap(); + thread::sleep(Duration::from_millis(20)); + } +} + +fn main() -> Result<(), Box> { + // --- SPI setup --- + let mut spi = SpidevDevice::open("/dev/spidev0.0")?; + let options = SpidevOptions::new() + .bits_per_word(8) + .max_speed_hz(4_000_000) + .mode(SpiModeFlags::SPI_MODE_0) + .build(); + spi.configure(&options)?; + + // --- GPIO setup --- + let mut chip = Chip::new("/dev/gpiochip0")?; + let dc = chip + .get_line(25)? + .request(LineRequestFlags::OUTPUT, 0, "epd-dc")?; + let rst = chip + .get_line(17)? + .request(LineRequestFlags::OUTPUT, 1, "epd-rst")?; + let pwr = chip + .get_line(18)? + .request(LineRequestFlags::OUTPUT, 0, "epd-pwr")?; + let busy = chip + .get_line(24)? + .request(LineRequestFlags::INPUT, 0, "epd-busy")?; + + let mut epd = RawEpd { + spi, + dc, + rst, + pwr, + busy, + }; + + // --- Step 1: Power on --- + println!("1. PWR HIGH"); + epd.pwr.set_value(1)?; + thread::sleep(Duration::from_millis(10)); + + // --- Step 2: Reset --- + println!("2. Reset"); + epd.reset(); + + // --- Step 3: Wait busy --- + println!("3. Wait busy after reset"); + epd.wait_busy(); + + // --- Step 4: SWRESET --- + println!("4. SWRESET (0x12)"); + epd.send_cmd(0x12); + println!("5. Wait busy after SWRESET"); + epd.wait_busy(); + + // --- Step 5: Driver output control --- + println!("6. Driver output control (0x01 + F9 00 00)"); + epd.send_cmd(0x01); + epd.send_data(0xF9); + epd.send_data(0x00); + epd.send_data(0x00); + + // --- Step 6: Data entry mode --- + println!("7. Data entry mode (0x11 + 03)"); + epd.send_cmd(0x11); + epd.send_data(0x03); + + // --- Step 7: Set window --- + println!("8. Set window (0x44/0x45)"); + // SetWindow(0, 0, 121, 249) + epd.send_cmd(0x44); + epd.send_data(0x00); // x_start >> 3 = 0 + epd.send_data(0x0F); // x_end >> 3 = 121 >> 3 = 15 + epd.send_cmd(0x45); + epd.send_data(0x00); // y_start low + epd.send_data(0x00); // y_start high + epd.send_data(0xF9); // y_end low = 249 + epd.send_data(0x00); // y_end high + + // --- Step 8: Set cursor --- + println!("9. Set cursor (0x4E/0x4F)"); + // SetCursor(0, 0) + epd.send_cmd(0x4E); + epd.send_data(0x00); + epd.send_cmd(0x4F); + epd.send_data(0x00); + epd.send_data(0x00); + + // --- Step 9: Border waveform --- + println!("10. Border waveform (0x3C + 05)"); + epd.send_cmd(0x3C); + epd.send_data(0x05); + + // --- Step 10: Display update control 1 --- + println!("11. Display update control 1 (0x21 + 00 80)"); + epd.send_cmd(0x21); + epd.send_data(0x00); + epd.send_data(0x80); + + // --- Step 11: Temperature sensor --- + println!("12. Temperature sensor (0x18 + 80)"); + epd.send_cmd(0x18); + epd.send_data(0x80); + + // --- Step 12: Wait busy --- + println!("13. Wait busy after init"); + epd.wait_busy(); + + println!("=== Init complete ==="); + + // --- Clear: send 0xFF to RAM --- + println!("14. Clear: write 4000 bytes of 0xFF to RAM (0x24)"); + epd.send_cmd(0x24); + let white_buf = vec![0xFFu8; 4000]; + epd.send_data_bulk(&white_buf); + + // --- Turn on display --- + println!("15. Turn on display (0x22+F7, 0x20)"); + epd.send_cmd(0x22); + epd.send_data(0xF7); + epd.send_cmd(0x20); + println!("16. Wait busy for display refresh..."); + epd.wait_busy(); + + println!("=== Display refresh complete ==="); + println!("Display should show all white."); + println!("Waiting 5s then exiting (no sleep, PWR stays high)..."); + thread::sleep(Duration::from_secs(5)); + + Ok(()) +} diff --git a/examples/epd2in13_v4_status.rs b/examples/epd2in13_v4_status.rs new file mode 100644 index 00000000..7d056857 --- /dev/null +++ b/examples/epd2in13_v4_status.rs @@ -0,0 +1,618 @@ +//! System status display for Pi Zero 2W on Waveshare 2.13" V4 (SSD1680). +//! Reads real system data via /proc + pisugar socket and renders to e-paper. +//! +//! Uses partial refresh on subsequent runs to minimize e-paper wear. +//! Two state files in `/tmp` (cleared on reboot) control the sequence: +//! +//! 1. First run — full refresh, creates `epd_status_initialized` +//! 2. Second run — `display_part_base_image` (establishes base in both RAM +//! banks with one full refresh), creates `epd_status_base_set` +//! 3. Third+ runs — `display_partial` only (true partial waveform, no flashing) +//! +//! Rotation changes automatically trigger a full refresh cycle +//! to clear ghosting from the previous orientation. + +use embedded_graphics::{ + mono_font::{ascii::FONT_6X10, MonoTextStyleBuilder}, + prelude::*, + primitives::{PrimitiveStyle, Rectangle}, + text::{Alignment, Baseline, Text, TextStyleBuilder}, +}; +use epd_waveshare::{ + epd2in13_v4::{Display2in13, Epd2in13, HEIGHT, WIDTH}, + graphics::DisplayRotation, + prelude::*, +}; +use linux_embedded_hal::{ + gpio_cdev::{Chip, LineRequestFlags}, + spidev::{self, SpidevOptions}, + CdevPin, Delay, SpidevDevice, +}; +use std::io::{BufRead, BufReader, Write as IoWrite}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +const STATE_DIR: &str = "/var/lib/epd-status"; +const STATE_FILE: &str = "/var/lib/epd-status/initialized"; +const BASE_FILE: &str = "/var/lib/epd-status/base_set"; +const ROTATION_FILE: &str = "/var/lib/epd-status/rotation"; + +// ---- Data collection ---- + +struct StatusData { + hostname: String, + ip: String, + datetime: String, + uptime: String, + cpu: String, + memory: String, + disk: String, + battery: String, + voltage: String, + refresh_mode: String, +} + +impl StatusData { + fn collect() -> Self { + let cpu = Self::read_cpu(); + let (battery, voltage) = Self::read_battery(); + + let refresh_mode = if !Path::new(STATE_FILE).exists() { + "full".into() + } else if !Path::new(BASE_FILE).exists() { + "base".into() + } else { + "partial".into() + }; + + StatusData { + hostname: Self::read_hostname(), + ip: Self::read_ip(), + datetime: Self::read_datetime(), + uptime: Self::read_uptime(), + cpu, + memory: Self::read_memory(), + disk: Self::read_disk(), + battery, + voltage, + refresh_mode, + } + } + + fn read_hostname() -> String { + std::fs::read_to_string("/etc/hostname") + .unwrap_or_else(|_| "unknown".into()) + .trim() + .to_uppercase() + } + + fn read_ip() -> String { + // UDP connect() sends no packet; it just picks the outbound interface + // so local_addr() reports this host's routable IP toward 8.8.8.8. + if let Ok(sock) = std::net::UdpSocket::bind("0.0.0.0:0") { + if sock.connect("8.8.8.8:53").is_ok() { + if let Ok(addr) = sock.local_addr() { + return addr.ip().to_string(); + } + } + } + "no IP".into() + } + + fn read_datetime() -> String { + let secs = match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(d) if d.as_secs() > 1_600_000_000 => d.as_secs(), + _ => { + eprintln!("epd-waveshare: system clock not synced (pre-2020), displaying CLK?"); + return "CLK? unsynced".into(); + } + }; + let days_since_epoch = secs / 86400; + let time_of_day = secs % 86400; + let hours = time_of_day / 3600; + let minutes = (time_of_day % 3600) / 60; + let (year, month, day) = days_to_ymd(days_since_epoch); + format!( + "{:04}-{:02}-{:02} {:02}:{:02}", + year, month, day, hours, minutes + ) + } + + fn read_uptime() -> String { + std::fs::read_to_string("/proc/uptime") + .ok() + .and_then(|s| { + s.split_whitespace() + .next() + .and_then(|v| v.parse::().ok()) + }) + .map(|secs| { + let total = secs as u64; + let days = total / 86400; + let hours = (total % 86400) / 3600; + let mins = (total % 3600) / 60; + if days > 0 { + format!("Up: {}d {}h {}m", days, hours, mins) + } else { + format!("Up: {}h {}m", hours, mins) + } + }) + .unwrap_or_else(|| "Up: ??".into()) + } + + fn read_cpu() -> String { + // CPU usage via /proc/stat delta + let usage = read_cpu_percent().map(|p| p as f32).unwrap_or(0.0); + let temp = std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .map(|t| format!("{:.1}C", t as f64 / 1000.0)) + .unwrap_or_else(|| "??C".into()); + format!("CPU: {:.0}% {}", usage, temp) + } + + fn read_memory() -> String { + let content = match std::fs::read_to_string("/proc/meminfo") { + Ok(c) => c, + Err(_) => return "RAM: ??".into(), + }; + let mut total_kb = 0u64; + let mut avail_kb = 0u64; + for line in content.lines() { + if line.starts_with("MemTotal:") { + total_kb = line + .split_whitespace() + .nth(1) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + } else if line.starts_with("MemAvailable:") { + avail_kb = line + .split_whitespace() + .nth(1) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + } + } + let used_mb = total_kb.saturating_sub(avail_kb) / 1024; + let total_mb = total_kb / 1024; + format!("RAM: {}/{}MB", used_mb, total_mb) + } + + fn read_disk() -> String { + let output = match std::process::Command::new("df").args(["-k", "/"]).output() { + Ok(o) => o, + Err(_) => return "DSK: ??".into(), + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let line = match stdout.lines().nth(1) { + Some(l) => l, + None => return "DSK: ??".into(), + }; + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 5 { + return "DSK: ??".into(); + } + let total_kb: f64 = fields[1].parse().unwrap_or(0.0); + let used_kb: f64 = fields[2].parse().unwrap_or(0.0); + let total_gb = total_kb / 1_048_576.0; + let used_gb = used_kb / 1_048_576.0; + format!("DSK: {:.1}/{:.1}GB", used_gb, total_gb) + } + + fn read_battery() -> (String, String) { + let bat_pct = query_pisugar("get battery").and_then(|r| parse_pisugar_float(&r)); + let charging = query_pisugar("get battery_charging") + .map(|r| r.contains("true")) + .unwrap_or(false); + + let bat_line = match bat_pct { + Some(pct) => { + let indicator = if charging { " CHG" } else { "" }; + format!("BAT: {:.0}%{}", pct, indicator) + } + None => "BAT: N/A".into(), + }; + + let volt_line = query_pisugar("get battery_v") + .and_then(|r| parse_pisugar_float(&r)) + .map(|v| format!("VOLT: {:.2}V", v)) + .unwrap_or_else(|| "VOLT: N/A".into()); + + (bat_line, volt_line) + } + + fn summary(&self, rotation_degrees: u16, color_invert: bool, reoriented: bool) -> String { + let reoriented_str = if reoriented { " | REORIENTED" } else { "" }; + format!( + "{} | ROT:{} | Inv:{}{} | {} | {} | {} | {} | {} | {} | {} | {}", + self.refresh_mode, + rotation_degrees, + color_invert, + reoriented_str, + self.hostname, + self.ip, + self.datetime, + self.uptime, + self.cpu, + self.memory, + self.disk, + self.battery + ) + } +} + +fn query_pisugar(cmd: &str) -> Option { + let mut stream = UnixStream::connect("/tmp/pisugar-server.sock").ok()?; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; + writeln!(stream, "{}", cmd).ok()?; + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader.read_line(&mut response).ok()?; + Some(response.trim().to_string()) +} + +fn parse_pisugar_float(response: &str) -> Option { + response.rsplit_once(':')?.1.trim().parse().ok() +} + +fn days_to_ymd(mut days: u64) -> (u64, u64, u64) { + let mut year = 1970u64; + loop { + let diy = if is_leap(year) { 366 } else { 365 }; + if days < diy { + break; + } + days -= diy; + year += 1; + } + let leap = is_leap(year); + let md: [u64; 12] = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + let mut month = 1u64; + for &m in &md { + if days < m { + break; + } + days -= m; + month += 1; + } + (year, month, days + 1) +} + +fn is_leap(y: u64) -> bool { + (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 +} + +/// Read CPU utilization by sampling /proc/stat twice with a 500ms gap. +fn read_cpu_percent() -> Option { + let parse_cpu_line = |s: &str| -> Option<(u64, u64)> { + let fields: Vec = s + .split_whitespace() + .skip(1) // skip "cpu" + .take(7) // user nice system idle iowait irq softirq + .filter_map(|v| v.parse().ok()) + .collect(); + if fields.len() < 7 { + return None; + } + let idle = fields[3] + fields[4]; // idle + iowait + let total: u64 = fields.iter().sum(); + Some((total, idle)) + }; + + let read_first_line = || -> Option { + std::fs::read_to_string("/proc/stat") + .ok() + .and_then(|s| s.lines().next().map(String::from)) + }; + + let line1 = read_first_line()?; + let (total1, idle1) = parse_cpu_line(&line1)?; + + std::thread::sleep(std::time::Duration::from_millis(500)); + + let line2 = read_first_line()?; + let (total2, idle2) = parse_cpu_line(&line2)?; + + let dt = total2.saturating_sub(total1); + let di = idle2.saturating_sub(idle1); + if dt == 0 { + return Some(0); + } + Some((100 * dt.saturating_sub(di) / dt) as u32) +} + +struct EpdConfig { + rotation: DisplayRotation, + color_invert: bool, +} + +fn parse_config() -> EpdConfig { + if !Path::new("/etc/epd-waveshare.conf").exists() { + eprintln!("epd-waveshare: no config at /etc/epd-waveshare.conf, using defaults"); + } + let mut rotation = DisplayRotation::Rotate0; + let mut color_invert = false; + let content = std::fs::read_to_string("/etc/epd-waveshare.conf").unwrap_or_default(); + for line in content.lines() { + let line = line.trim(); + if line.starts_with('#') || line.is_empty() { + continue; + } + if let Some((key, value)) = line.split_once('=') { + match key.trim() { + "rotation" => match value.trim() { + "0" => rotation = DisplayRotation::Rotate0, + "90" => rotation = DisplayRotation::Rotate90, + "180" => rotation = DisplayRotation::Rotate180, + "270" => rotation = DisplayRotation::Rotate270, + _ => eprintln!( + "epd-waveshare: invalid rotation value '{}', using 0", + value.trim() + ), + }, + "color_invert" => { + color_invert = value.trim() == "true"; + } + _ => {} + } + } + } + EpdConfig { + rotation, + color_invert, + } +} + +// ---- Rendering ---- + +fn render( + display: &mut Display2in13, + data: &StatusData, + w: u32, + _h: u32, +) -> Result<(), core::convert::Infallible> { + let fill_black = PrimitiveStyle::with_fill(Color::Black); + + let white_on_black = MonoTextStyleBuilder::new() + .font(&FONT_6X10) + .text_color(Color::White) + .background_color(Color::Black) + .build(); + let black_on_white = MonoTextStyleBuilder::new() + .font(&FONT_6X10) + .text_color(Color::Black) + .background_color(Color::White) + .build(); + let right_align = TextStyleBuilder::new() + .alignment(Alignment::Right) + .baseline(Baseline::Top) + .build(); + + // Header bar: hostname left, IP right + Rectangle::new(Point::new(0, 0), Size::new(w, 13)) + .into_styled(fill_black) + .draw(display)?; + Text::with_baseline( + &data.hostname, + Point::new(2, 2), + white_on_black, + Baseline::Top, + ) + .draw(display)?; + Text::with_text_style( + &data.ip, + Point::new((w - 2) as i32, 2), + white_on_black, + right_align, + ) + .draw(display)?; + + let mut y = 15; + + // Date/time + Text::with_baseline( + &data.datetime, + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display)?; + y += 12; + + // Uptime + Text::with_baseline( + &data.uptime, + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display)?; + y += 12; + + // CPU + Text::with_baseline(&data.cpu, Point::new(2, y), black_on_white, Baseline::Top) + .draw(display)?; + y += 12; + + // RAM + Text::with_baseline( + &data.memory, + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display)?; + y += 12; + + // Disk + Text::with_baseline(&data.disk, Point::new(2, y), black_on_white, Baseline::Top) + .draw(display)?; + y += 12; + + // Battery + Text::with_baseline( + &data.battery, + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display)?; + y += 12; + + // Voltage + Text::with_baseline( + &data.voltage, + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display)?; + y += 12; + + // Refresh mode + Text::with_baseline( + &format!("RFR: {}", data.refresh_mode), + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display)?; + + Ok(()) +} + +// ---- Main ---- + +fn main() -> Result<(), Box> { + std::fs::create_dir_all(STATE_DIR)?; + + let config = parse_config(); + let (logical_w, logical_h) = match config.rotation { + DisplayRotation::Rotate0 | DisplayRotation::Rotate180 => (WIDTH, HEIGHT), + DisplayRotation::Rotate90 | DisplayRotation::Rotate270 => (HEIGHT, WIDTH), + }; + let rotation_degrees: u16 = match config.rotation { + DisplayRotation::Rotate0 => 0, + DisplayRotation::Rotate90 => 90, + DisplayRotation::Rotate180 => 180, + DisplayRotation::Rotate270 => 270, + }; + + // Detect rotation changes and force full refresh if needed + let current_rotation = rotation_degrees.to_string(); + let reoriented = if Path::new(ROTATION_FILE).exists() { + let last_rotation = std::fs::read_to_string(ROTATION_FILE) + .unwrap_or_default() + .trim() + .to_string(); + if last_rotation != current_rotation { + eprintln!( + "epd-waveshare: rotation changed {} -> {}, forcing full refresh", + last_rotation, current_rotation + ); + true + } else { + false + } + } else { + false + }; + + let data = StatusData::collect(); + + // EPD setup + let mut spi = + SpidevDevice::open("/dev/spidev0.0").map_err(|e| format!("open /dev/spidev0.0: {e}"))?; + let options = SpidevOptions::new() + .bits_per_word(8) + .max_speed_hz(4_000_000) + .mode(spidev::SpiModeFlags::SPI_MODE_0) + .build(); + spi.configure(&options) + .map_err(|e| format!("configure /dev/spidev0.0: {e}"))?; + + let mut chip = Chip::new("/dev/gpiochip0").map_err(|e| format!("open /dev/gpiochip0: {e}"))?; + let busy = CdevPin::new( + chip.get_line(24) + .map_err(|e| format!("claim GPIO24 (BUSY): {e}"))? + .request(LineRequestFlags::INPUT, 0, "epd-busy") + .map_err(|e| format!("request GPIO24 (BUSY) as input: {e}"))?, + )?; + let dc = CdevPin::new( + chip.get_line(25) + .map_err(|e| format!("claim GPIO25 (DC): {e}"))? + .request(LineRequestFlags::OUTPUT, 0, "epd-dc") + .map_err(|e| format!("request GPIO25 (DC) as output: {e}"))?, + )?; + let rst = CdevPin::new( + chip.get_line(17) + .map_err(|e| format!("claim GPIO17 (RST): {e}"))? + .request(LineRequestFlags::OUTPUT, 1, "epd-rst") + .map_err(|e| format!("request GPIO17 (RST) as output: {e}"))?, + )?; + let pwr = CdevPin::new( + chip.get_line(18) + .map_err(|e| format!("claim GPIO18 (PWR): {e}"))? + .request(LineRequestFlags::OUTPUT, 0, "epd-pwr") + .map_err(|e| format!("request GPIO18 (PWR) as output: {e}"))?, + )?; + + let mut delay = Delay; + let mut epd = Epd2in13::new_with_pwr(&mut spi, busy, dc, rst, &mut delay, None, pwr) + .map_err(|e| format!("EPD init (SSD1680): {e}"))?; + + // Render to framebuffer + let mut display = Display2in13::default(); + display.set_rotation(config.rotation); + display.clear(Color::White).ok(); + render(&mut display, &data, logical_w, logical_h)?; + + let final_buffer: Vec = if config.color_invert { + display.buffer().iter().map(|&b| !b).collect() + } else { + display.buffer().to_vec() + }; + let buf = final_buffer.as_slice(); + let initialized = !reoriented && Path::new(STATE_FILE).exists(); + let base_set = !reoriented && Path::new(BASE_FILE).exists(); + + if !initialized { + // First run since boot (or rotation change) — full refresh + epd.update_frame(&mut spi, buf, &mut delay)?; + epd.display_frame(&mut spi, &mut delay)?; + let _ = std::fs::remove_file(BASE_FILE); + std::fs::write(STATE_FILE, "")?; + } else if !base_set { + // Second run — establish partial base (writes both RAM banks, one full refresh) + epd.display_part_base_image(&mut spi, buf, &mut delay)?; + std::fs::write(BASE_FILE, "")?; + } else { + // All subsequent runs — true partial refresh only + epd.display_partial(&mut spi, buf, &mut delay)?; + } + + let _ = std::fs::write(ROTATION_FILE, ¤t_rotation); + + epd.sleep(&mut spi, &mut delay)?; + + println!( + "{}", + data.summary(rotation_degrees, config.color_invert, reoriented) + ); + + Ok(()) +} diff --git a/examples/epd3in52.rs b/examples/epd3in52.rs new file mode 100644 index 00000000..03262c61 --- /dev/null +++ b/examples/epd3in52.rs @@ -0,0 +1,298 @@ +//! Carcinisation demo for the Waveshare 3.52" E-Paper HAT (UC8253). +//! +//! Carcinisation is the convergent evolution of non-crab crustaceans into +//! crab-like forms — it has happened independently at least five times in +//! the decapod lineage. This example is a visual pun on the observation +//! that large software rewrites tend to converge toward Rust: a lobster +//! dissolves frame-by-frame into Ferris the Rustacean, then the screen +//! resolves to the word "carcinisation" with an arrow pointing at the +//! result. The blending is a deterministic pseudo-random cross-dissolve +//! across 8 partial-refresh frames (Quick / DU LUT), bracketed by two +//! full refreshes (GC LUT). +//! +//! # GPIO backend +//! +//! Uses `gpio_cdev` rather than `sysfs_gpio`: sysfs is deprecated on +//! Raspberry Pi OS Bookworm and the Pi 5 requires `gpio_cdev` due to +//! BCM offset changes on `gpiochip0`. The rest of the upstream examples +//! still use `sysfs_gpio`; this example targets newer RPi OS releases. +//! +//! # Wiring (Waveshare 3.52" HAT, BCM numbering) +//! +//! | Signal | BCM | +//! |--------|-----| +//! | RST | 17 | +//! | DC | 25 | +//! | BUSY | 24 | +//! | SPI | CE0 on /dev/spidev0.0, 10 MHz, mode 0 | + +use embedded_graphics::{ + image::{Image, ImageRaw}, + mono_font::{ascii::FONT_10X20, MonoTextStyleBuilder}, + pixelcolor::BinaryColor, + prelude::*, + text::{Alignment, Baseline, Text, TextStyleBuilder}, +}; +use embedded_hal::delay::DelayNs; +use epd_waveshare::{ + epd3in52::{Display3in52, Epd3in52}, + graphics::DisplayRotation, + prelude::*, +}; +use linux_embedded_hal::{ + gpio_cdev::{Chip, LineRequestFlags}, + spidev::{self, SpidevOptions}, + CdevPin, Delay, SpidevDevice, +}; + +// --- Sprite geometry -------------------------------------------------------- +const SPRITE_W: u32 = 96; +const SPRITE_H: u32 = 96; +/// Horizontal position of the sprite on the 360-wide landscape canvas. +/// `(360 - 96) / 2 = 132`. +const SPRITE_X: i32 = 132; +/// Vertical position during the transformation sequence (vertically centred). +/// `(240 - 96) / 2 = 72`. +const SPRITE_Y_ANIM: i32 = 72; +/// Vertical position in the final frame — same as the animation position +/// so the sprite does not jump between phases. Caption text sits above +/// and below the sprite. +const SPRITE_Y_FINAL: i32 = 72; + +// --- Timing ---------------------------------------------------------------- +/// Number of cross-dissolve frames. +const FRAMES: u32 = 8; +/// Inter-frame delay during the cross-dissolve. +const FRAME_DELAY_MS: u32 = 400; +/// How long to hold the initial lobster before the dissolve starts. +const SHOW_DELAY_MS: u32 = 2000; + +// --- Asset bytes ----------------------------------------------------------- +const LOBSTER: &[u8] = include_bytes!("./assets/lobster_96x96.raw"); +const FERRIS: &[u8] = include_bytes!("./assets/ferris_96x96.raw"); + +fn main() -> Result<(), Box> { + // --- SPI setup --------------------------------------------------------- + let mut spi = + SpidevDevice::open("/dev/spidev0.0").map_err(|e| format!("open /dev/spidev0.0: {e}"))?; + let options = SpidevOptions::new() + .bits_per_word(8) + .max_speed_hz(10_000_000) + .mode(spidev::SpiModeFlags::SPI_MODE_0) + .build(); + spi.configure(&options) + .map_err(|e| format!("configure /dev/spidev0.0: {e}"))?; + + // --- GPIO setup (gpio_cdev) -------------------------------------------- + let mut chip = Chip::new("/dev/gpiochip0").map_err(|e| format!("open /dev/gpiochip0: {e}"))?; + let busy = CdevPin::new( + chip.get_line(24) + .map_err(|e| format!("claim GPIO24 (BUSY): {e}"))? + .request(LineRequestFlags::INPUT, 0, "epd3in52-busy") + .map_err(|e| format!("request GPIO24 (BUSY) as input: {e}"))?, + )?; + let dc = CdevPin::new( + chip.get_line(25) + .map_err(|e| format!("claim GPIO25 (DC): {e}"))? + .request(LineRequestFlags::OUTPUT, 0, "epd3in52-dc") + .map_err(|e| format!("request GPIO25 (DC) as output: {e}"))?, + )?; + let rst = CdevPin::new( + chip.get_line(17) + .map_err(|e| format!("claim GPIO17 (RST): {e}"))? + .request(LineRequestFlags::OUTPUT, 1, "epd3in52-rst") + .map_err(|e| format!("request GPIO17 (RST) as output: {e}"))?, + )?; + + let mut delay = Delay; + + // --- EPD init ---------------------------------------------------------- + let mut epd = Epd3in52::new(&mut spi, busy, dc, rst, &mut delay, None) + .map_err(|e| format!("EPD init (UC8253): {e}"))?; + + // ------------------------------------------------------------------ + // Phase 1 — show the lobster with a full refresh (GC LUT). + // ------------------------------------------------------------------ + println!("Phase 1: showing lobster (full refresh)..."); + epd.set_lut(&mut spi, &mut delay, Some(RefreshLut::Full))?; + + let lobster_frame = render_sprite_frame(LOBSTER, SPRITE_Y_ANIM); + epd.update_frame(&mut spi, &lobster_frame, &mut delay)?; + epd.display_frame(&mut spi, &mut delay)?; + delay.delay_ms(SHOW_DELAY_MS); + + // ------------------------------------------------------------------ + // Phase 2 — cross-dissolve lobster → Ferris with the Quick (DU) LUT. + // Each frame is a fresh full-buffer render with a blended sprite. + // ------------------------------------------------------------------ + println!("Phase 2: carcinisation ({FRAMES} frames, Quick LUT)..."); + epd.set_lut(&mut spi, &mut delay, Some(RefreshLut::Quick))?; + + for frame in 0..FRAMES { + let buf = blend_sprites(LOBSTER, FERRIS, frame); + epd.update_frame(&mut spi, &buf, &mut delay)?; + epd.display_frame(&mut spi, &mut delay)?; + delay.delay_ms(FRAME_DELAY_MS); + } + + // ------------------------------------------------------------------ + // Phase 3 — final full refresh: Ferris + caption, then sleep. + // ------------------------------------------------------------------ + println!("Phase 3: final frame + caption (full refresh)..."); + epd.set_lut(&mut spi, &mut delay, Some(RefreshLut::Full))?; + + let final_frame = render_final_frame(); + epd.update_frame(&mut spi, &final_frame, &mut delay)?; + epd.display_frame(&mut spi, &mut delay)?; + + println!("Sleeping..."); + epd.sleep(&mut spi, &mut delay)?; + + Ok(()) +} + +/// Draw "Carcinisation" at the top of the canvas, centred on x=180. +/// Used by every phase so the caption is present from the very first frame. +fn draw_top_caption(display: &mut Display3in52) { + let character_style = MonoTextStyleBuilder::new() + .font(&FONT_10X20) + .text_color(Color::Black) + .background_color(Color::White) + .build(); + let centred = TextStyleBuilder::new() + .baseline(Baseline::Top) + .alignment(Alignment::Center) + .build(); + Text::with_text_style( + "Carcinisation", + Point::new(180, 8), + character_style, + centred, + ) + .draw(display) + .ok(); +} + +/// Build a full 360×240 landscape display buffer with a 96×96 sprite pasted +/// at `(SPRITE_X, sprite_y)` on a white background, plus the "Carcinisation" +/// caption at the top. The sprite bytes are a 1-bit-packed (MSB-first) 96×96 +/// image where 0 = white and 1 = black. +fn render_sprite_frame(sprite: &[u8], sprite_y: i32) -> Vec { + let mut display = Display3in52::default(); + display.set_rotation(DisplayRotation::Rotate90); + display.clear(Color::White).ok(); + + let raw: ImageRaw = ImageRaw::new(sprite, SPRITE_W); + let image = Image::new(&raw, Point::new(SPRITE_X, sprite_y)); + image + .draw(&mut display.color_converted::()) + .ok(); + + draw_top_caption(&mut display); + + display.buffer().to_vec() +} + +/// Deterministic pseudo-random cross-dissolve between two 96×96 sprites. +/// +/// Produces a full 360×240 display buffer with the blended sprite pasted +/// at `(SPRITE_X, SPRITE_Y_ANIM)` on a white background. +/// +/// # Blending rule +/// +/// For each pixel `(x, y)` in the 96×96 sprite area: +/// +/// - `threshold = frame / (FRAMES - 1)` — 0.0 at frame 0, 1.0 at frame 7. +/// - A deterministic "random" value is derived from the pixel position and +/// the frame index: `(x * 31 + y * 17 + frame * 7) % 100`. +/// - If the random value is below `threshold * 100`, the pixel takes the +/// Ferris value; otherwise it takes the lobster value. +/// +/// This is a monotonic dissolve: at frame 0 every pixel is lobster, at +/// frame 7 every pixel is Ferris, and the fraction of ferris pixels grows +/// smoothly with the frame index. +fn blend_sprites(lobster: &[u8], ferris: &[u8], frame: u32) -> Vec { + let threshold = frame as f32 / (FRAMES - 1) as f32; + let flip_threshold = (threshold * 100.0) as u32; + + // 96 × 96 / 8 = 1152 bytes, MSB-packed rows. + let row_stride = (SPRITE_W / 8) as usize; + let mut blended = vec![0xFFu8; row_stride * SPRITE_H as usize]; + + for y in 0..SPRITE_H as usize { + for x in 0..SPRITE_W as usize { + let byte_idx = y * row_stride + (x / 8); + let bit_idx = 7 - (x % 8); + let mask = 1u8 << bit_idx; + + let lobster_bit = (lobster[byte_idx] & mask) != 0; + let ferris_bit = (ferris[byte_idx] & mask) != 0; + + // Deterministic pseudo-random in [0, 100). + let rnd = ((x as u32) * 31 + (y as u32) * 17 + frame * 7) % 100; + let pixel_black = if rnd < flip_threshold { + ferris_bit + } else { + lobster_bit + }; + + if pixel_black { + blended[byte_idx] |= mask; + } else { + blended[byte_idx] &= !mask; + } + } + } + + // Paint the blended sprite into a full display buffer with the + // "Carcinisation" caption at the top. + let mut display = Display3in52::default(); + display.set_rotation(DisplayRotation::Rotate90); + display.clear(Color::White).ok(); + + let raw: ImageRaw = ImageRaw::new(&blended, SPRITE_W); + let image = Image::new(&raw, Point::new(SPRITE_X, SPRITE_Y_ANIM)); + image + .draw(&mut display.color_converted::()) + .ok(); + + draw_top_caption(&mut display); + + display.buffer().to_vec() +} + +/// Compose the final frame: "Carcinisation" at the top (present since +/// the first animation frame), Ferris centred, and "Rustacean" below — +/// which only appears here, as the punch line. +fn render_final_frame() -> Vec { + let mut display = Display3in52::default(); + display.set_rotation(DisplayRotation::Rotate90); + display.clear(Color::White).ok(); + + // Ferris, vertically centred on the 240 px canvas (y=72..168). + let raw: ImageRaw = ImageRaw::new(FERRIS, SPRITE_W); + let image = Image::new(&raw, Point::new(SPRITE_X, SPRITE_Y_FINAL)); + image + .draw(&mut display.color_converted::()) + .ok(); + + // "Carcinisation" (top) reused across all phases. + draw_top_caption(&mut display); + + // "Rustacean" — punch line, only drawn on the final frame, at y=184 + // (sprite ends at y=168, gap of 16 px, caption occupies y=184..204). + let character_style = MonoTextStyleBuilder::new() + .font(&FONT_10X20) + .text_color(Color::Black) + .background_color(Color::White) + .build(); + let centred = TextStyleBuilder::new() + .baseline(Baseline::Top) + .alignment(Alignment::Center) + .build(); + Text::with_text_style("Rustacean", Point::new(180, 184), character_style, centred) + .draw(&mut display) + .ok(); + + display.buffer().to_vec() +} diff --git a/examples/epd3in52_status.rs b/examples/epd3in52_status.rs new file mode 100644 index 00000000..387a6bfe --- /dev/null +++ b/examples/epd3in52_status.rs @@ -0,0 +1,630 @@ +//! epd3in52_status — Waveshare 3.52" e-paper status display +//! +//! Target: Raspberry Pi 5 (aarch64, Raspberry Pi OS) +//! Display: Waveshare 3.52" HAT, UC8253 controller, 240x360px +//! GPIO: gpio_cdev backend (Pi 5 uses /dev/gpiochip0, no BCM offset) +//! BUSY polarity: active-low (IS_BUSY_LOW = true) +//! +//! IMPORTANT: Python never refreshes between display_NUM and display. +//! The Rust example must NOT call display_frame() between clear_frame() +//! and update_frame() — doing so advances lut_flag, causing the image +//! refresh to use swapped R22/R23 LUTs which inverts colors. +//! +//! Build and deploy: +//! cargo build --example epd3in52_status \ +//! --target aarch64-unknown-linux-gnu --release +//! scp target/aarch64-unknown-linux-gnu/release/examples/epd3in52_status \ +//! :~/ +//! ssh "sudo ./epd3in52_status" + +use embedded_graphics::{ + mono_font::{ascii::FONT_8X13, MonoTextStyleBuilder}, + prelude::*, + primitives::{PrimitiveStyle, Rectangle}, + text::{Baseline, Text}, +}; +use epd_waveshare::{ + color::Color, + epd3in52::{Display3in52, Epd3in52, HEIGHT, WIDTH}, + graphics::DisplayRotation, + prelude::*, +}; +use linux_embedded_hal::{ + gpio_cdev::{Chip, LineRequestFlags}, + spidev::{self, SpidevOptions}, + CdevPin, Delay, SpidevDevice, +}; +use std::io::{BufRead, BufReader, Write as IoWrite}; +use std::os::unix::net::UnixStream; +use std::path::Path; +use std::time::{Duration, SystemTime, UNIX_EPOCH}; + +// -- GPIO pin numbers (BCM, verified from epdconfig.py) --------------- +const PIN_BUSY: u32 = 24; +const PIN_RST: u32 = 17; +const PIN_DC: u32 = 25; + +// -- SPI device --------------------------------------------------------------- +const SPI_DEVICE: &str = "/dev/spidev0.0"; +const SPI_SPEED_HZ: u32 = 10_000_000; + +struct StatusData { + hostname: String, + ip: String, + timestamp: String, + temp: Option, + uptime: String, + used_mb: u64, + total_mb: u64, + cpu_percent: Option, + disk_used_gb: Option, + disk_total_gb: Option, + disk_percent: Option, + batt_percent: Option, + batt_voltage: Option, + batt_charging: bool, +} + +impl StatusData { + fn collect() -> Self { + let cpu_percent = read_cpu_percent(); + let (used_mb, total_mb) = read_ram_usage(); + let (disk_used_gb, disk_total_gb, disk_percent) = read_disk_usage(); + let (batt_percent, batt_voltage, batt_charging) = read_battery(); + StatusData { + hostname: read_hostname(), + ip: read_local_ip(), + timestamp: format_timestamp(), + temp: read_cpu_temp(), + uptime: read_uptime(), + used_mb, + total_mb, + cpu_percent, + disk_used_gb, + disk_total_gb, + disk_percent, + batt_percent, + batt_voltage, + batt_charging, + } + } +} + +struct EpdConfig { + rotation: DisplayRotation, + color_invert: bool, +} + +fn parse_config() -> EpdConfig { + if !Path::new("/etc/epd-waveshare.conf").exists() { + eprintln!("epd-waveshare: no config at /etc/epd-waveshare.conf, using defaults"); + } + let mut rotation = DisplayRotation::Rotate0; + let mut color_invert = false; + let content = std::fs::read_to_string("/etc/epd-waveshare.conf").unwrap_or_default(); + for line in content.lines() { + let line = line.trim(); + if line.starts_with('#') || line.is_empty() { + continue; + } + if let Some((key, value)) = line.split_once('=') { + match key.trim() { + "rotation" => match value.trim() { + "0" => rotation = DisplayRotation::Rotate0, + "90" => rotation = DisplayRotation::Rotate90, + "180" => rotation = DisplayRotation::Rotate180, + "270" => rotation = DisplayRotation::Rotate270, + _ => eprintln!( + "epd-waveshare: invalid rotation value '{}', using 0", + value.trim() + ), + }, + "color_invert" => { + color_invert = value.trim() == "true"; + } + _ => {} + } + } + } + EpdConfig { + rotation, + color_invert, + } +} + +fn main() -> Result<(), Box> { + println!("epd3in52_status -- Waveshare 3.52\""); + + const EXPECTED_BUF_LEN: usize = 240 / 8 * 360; + + // -- Read config ---------------------------------------------------------- + let config = parse_config(); + let (logical_w, _logical_h) = match config.rotation { + DisplayRotation::Rotate0 | DisplayRotation::Rotate180 => (WIDTH, HEIGHT), + DisplayRotation::Rotate90 | DisplayRotation::Rotate270 => (HEIGHT, WIDTH), + }; + let rotation_degrees: u16 = match config.rotation { + DisplayRotation::Rotate0 => 0, + DisplayRotation::Rotate90 => 90, + DisplayRotation::Rotate180 => 180, + DisplayRotation::Rotate270 => 270, + }; + + // -- Collect system stats (CPU read takes ~500ms) ------------------------- + let data = StatusData::collect(); + + // -- SPI setup (SpidevDevice, not Spidev) --------------------------------- + let mut spi = + SpidevDevice::open(SPI_DEVICE).map_err(|e| format!("open /dev/spidev0.0: {e}"))?; + let options = SpidevOptions::new() + .bits_per_word(8) + .max_speed_hz(SPI_SPEED_HZ) + .mode(spidev::SpiModeFlags::SPI_MODE_0) + .build(); + spi.configure(&options) + .map_err(|e| format!("configure /dev/spidev0.0: {e}"))?; + + // -- GPIO setup (gpio_cdev on Pi 5) --------------------------------------- + let mut chip = Chip::new("/dev/gpiochip0").map_err(|e| format!("open /dev/gpiochip0: {e}"))?; + + let busy = CdevPin::new( + chip.get_line(PIN_BUSY) + .map_err(|e| format!("claim GPIO24 (BUSY): {e}"))? + .request(LineRequestFlags::INPUT, 0, "epd3in52-busy") + .map_err(|e| format!("request GPIO24 (BUSY) as input: {e}"))?, + )?; + let dc = CdevPin::new( + chip.get_line(PIN_DC) + .map_err(|e| format!("claim GPIO25 (DC): {e}"))? + .request(LineRequestFlags::OUTPUT, 0, "epd3in52-dc") + .map_err(|e| format!("request GPIO25 (DC) as output: {e}"))?, + )?; + let rst = CdevPin::new( + chip.get_line(PIN_RST) + .map_err(|e| format!("claim GPIO17 (RST): {e}"))? + .request(LineRequestFlags::OUTPUT, 1, "epd3in52-rst") + .map_err(|e| format!("request GPIO17 (RST) as output: {e}"))?, + )?; + + let mut delay = Delay; + + // -- 1. Init display → lut_flag=false ------------------------------------- + println!("Initialising display..."); + let mut epd = Epd3in52::new(&mut spi, busy, dc, rst, &mut delay, None) + .map_err(|e| format!("EPD init (UC8253): {e}"))?; + + // -- 2. Clear display RAM (no refresh!) ----------------------------------- + println!("Clearing display RAM..."); + epd.clear_frame(&mut spi, &mut delay)?; + + let test_pattern = std::env::var("EPD_TEST_PATTERN").is_ok(); + if test_pattern { + println!("Sending test pattern (top white / bottom black)..."); + let mut buf = vec![0xFFu8; EXPECTED_BUF_LEN]; + for b in buf[5400..].iter_mut() { + *b = 0x00; + } + epd.update_frame(&mut spi, &buf, &mut delay)?; + } else { + // -- 3. Build frame buffer -------------------------------------------- + println!("Rendering..."); + let mut display = Display3in52::default(); + display.set_rotation(config.rotation); + + assert_eq!( + display.buffer().len(), + EXPECTED_BUF_LEN, + "buffer length must be 240/8 * 360 = 10800" + ); + + display.clear(Color::White).ok(); + assert!( + display.buffer().iter().all(|&b| b == 0xFF), + "buffer must be all-white (0xFF) after clear" + ); + + draw_status(&mut display, &data, logical_w, _logical_h)?; + + println!("Sending frame..."); + let final_buffer: Vec = if config.color_invert { + display.buffer().iter().map(|&b| !b).collect() + } else { + display.buffer().to_vec() + }; + epd.update_frame(&mut spi, &final_buffer, &mut delay)?; + } + + // -- 4. Single refresh (lut_flag=false, matching Python Flag=0) ----------- + epd.display_frame(&mut spi, &mut delay)?; + + // -- 5. Sleep ------------------------------------------------------------- + epd.sleep(&mut spi, &mut delay)?; + + // -- Summary line --------------------------------------------------------- + let temp_str = data + .temp + .map(|t| format!("{:.1}", t)) + .unwrap_or_else(|| "--".to_string()); + let cpu_str = data + .cpu_percent + .map(|p| format!("{}%", p)) + .unwrap_or_else(|| "--".to_string()); + let disk_str = match (data.disk_used_gb, data.disk_total_gb) { + (Some(u), Some(t)) => format!("{:.0}/{:.0}GB", u, t), + _ => "--".to_string(), + }; + let batt_str = data + .batt_percent + .map(|p| format!("{:.0}%", p)) + .unwrap_or_else(|| "--".to_string()); + println!( + "[{}] Display updated. Rotation {}° Inv:{} Temp {}°C CPU {} RAM {}/{}MB Disk {} Batt {} Up {}", + data.timestamp, + rotation_degrees, + config.color_invert, + temp_str, + cpu_str, + data.used_mb, + data.total_mb, + disk_str, + batt_str, + data.uptime + ); + + Ok(()) +} + +// -- Frame content ------------------------------------------------------------ +fn draw_status( + display: &mut Display3in52, + data: &StatusData, + w: u32, + _h: u32, +) -> Result<(), Box> { + let body = MonoTextStyleBuilder::new() + .font(&FONT_8X13) + .text_color(Color::Black) + .background_color(Color::White) + .build(); + let header_title = MonoTextStyleBuilder::new() + .font(&FONT_8X13) + .text_color(Color::White) + .background_color(Color::Black) + .build(); + + // ── Header bar (full width, 28px tall) ─────────────────────────────────── + Rectangle::new(Point::new(0, 0), Size::new(w, 28)) + .into_styled(PrimitiveStyle::with_fill(Color::Black)) + .draw(display)?; + Text::with_baseline( + "Raspberry Pi 5 8GB", + Point::new(6, 8), + header_title, + Baseline::Top, + ) + .draw(display)?; + let ts_short = format!( + "{} UTC", + data.timestamp.get(..16).unwrap_or(&data.timestamp) + ); + let ts_x = (w as i32) - (ts_short.len() as i32 * 8) - 6; + Text::with_baseline(&ts_short, Point::new(ts_x, 8), header_title, Baseline::Top) + .draw(display)?; + + // ── Stats (18px line spacing, all FONT_8X13) ───────────────────────────── + let x = 6; + + // y=30: UP + Text::with_baseline( + &format!("UP {}", data.uptime), + Point::new(x, 30), + body, + Baseline::Top, + ) + .draw(display)?; + + // y=48: TEMP + let temp_str = match data.temp { + Some(t) => format!("TEMP {:.1} C", t), + None => "TEMP --".to_string(), + }; + Text::with_baseline(&temp_str, Point::new(x, 48), body, Baseline::Top).draw(display)?; + + // y=66: CPU + let cpu_str = data + .cpu_percent + .map(|p| format!("CPU {}%", p)) + .unwrap_or_else(|| "CPU --%".to_string()); + Text::with_baseline(&cpu_str, Point::new(x, 66), body, Baseline::Top).draw(display)?; + + // y=84: RAM + Text::with_baseline( + &format!("RAM {} / {} MB", data.used_mb, data.total_mb), + Point::new(x, 84), + body, + Baseline::Top, + ) + .draw(display)?; + + // y=102: DISK + let disk_str = match (data.disk_used_gb, data.disk_total_gb, data.disk_percent) { + (Some(u), Some(t), Some(p)) => format!("DISK {:.1} / {:.1} GB {}%", u, t, p), + _ => "DISK --".to_string(), + }; + Text::with_baseline(&disk_str, Point::new(x, 102), body, Baseline::Top).draw(display)?; + + // y=120: HOST + Text::with_baseline( + &format!("HOST {}", data.hostname), + Point::new(x, 120), + body, + Baseline::Top, + ) + .draw(display)?; + + // y=138: IP + Text::with_baseline( + &format!("IP {}", data.ip), + Point::new(x, 138), + body, + Baseline::Top, + ) + .draw(display)?; + + // y=156: BATT + let batt_str = match (data.batt_percent, data.batt_voltage) { + (Some(pct), Some(v)) => { + let indicator = if data.batt_charging { " +" } else { "" }; + format!("BATT {:.0}% {:.2}V{}", pct, v, indicator) + } + _ => "BATT unavailable".to_string(), + }; + Text::with_baseline(&batt_str, Point::new(x, 156), body, Baseline::Top).draw(display)?; + + // y=170: battery progress bar (only if battery data available) + if let Some(pct) = data.batt_percent { + let bar_x = x; + let bar_y = 170; + let bar_w = w / 2; + let bar_h = 10u32; + // Outline + Rectangle::new(Point::new(bar_x, bar_y), Size::new(bar_w, bar_h)) + .into_styled(PrimitiveStyle::with_stroke(Color::Black, 1)) + .draw(display)?; + // Fill + let fill_w = ((bar_w - 2) as f64 * pct.clamp(0.0, 100.0) / 100.0) as u32; + if fill_w > 0 { + Rectangle::new( + Point::new(bar_x + 1, bar_y + 1), + Size::new(fill_w, bar_h - 2), + ) + .into_styled(PrimitiveStyle::with_fill(Color::Black)) + .draw(display)?; + } + } + + Ok(()) +} + +// -- System info helpers (read from /proc, no external deps) ------------------ + +fn read_hostname() -> String { + std::fs::read_to_string("/etc/hostname") + .unwrap_or_else(|_| "unknown".to_string()) + .trim() + .to_string() +} + +fn read_local_ip() -> String { + use std::net::UdpSocket; + // UDP connect() sends no packet; it just picks the outbound interface + // so local_addr() reports this host's routable IP toward 8.8.8.8. + UdpSocket::bind("0.0.0.0:0") + .and_then(|s| { + s.connect("8.8.8.8:53")?; + s.local_addr() + }) + .map(|a| a.ip().to_string()) + .unwrap_or_else(|_| "no network".to_string()) +} + +fn format_timestamp() -> String { + let secs = match SystemTime::now().duration_since(UNIX_EPOCH) { + Ok(d) if d.as_secs() > 1_600_000_000 => d.as_secs(), + _ => { + eprintln!("epd-waveshare: system clock not synced (pre-2020), displaying CLK?"); + return "CLK? unsynced".to_string(); + } + }; + let s = secs % 60; + let m = (secs / 60) % 60; + let h = (secs / 3600) % 24; + let days = secs / 86400; + let (year, month, day) = days_to_ymd(days); + format!( + "{:04}-{:02}-{:02} {:02}:{:02}:{:02} UTC", + year, month, day, h, m, s + ) +} + +fn days_to_ymd(mut days: u64) -> (u64, u64, u64) { + let mut year = 1970u64; + loop { + let diy = if is_leap(year) { 366 } else { 365 }; + if days < diy { + break; + } + days -= diy; + year += 1; + } + let leap = is_leap(year); + let md: [u64; 12] = [ + 31, + if leap { 29 } else { 28 }, + 31, + 30, + 31, + 30, + 31, + 31, + 30, + 31, + 30, + 31, + ]; + let mut month = 1u64; + for &m in &md { + if days < m { + break; + } + days -= m; + month += 1; + } + (year, month, days + 1) +} + +fn is_leap(y: u64) -> bool { + (y % 4 == 0 && y % 100 != 0) || y % 400 == 0 +} + +fn read_cpu_temp() -> Option { + std::fs::read_to_string("/sys/class/thermal/thermal_zone0/temp") + .ok() + .and_then(|s| s.trim().parse::().ok()) + .map(|v| v as f32 / 1000.0) +} + +fn read_ram_usage() -> (u64, u64) { + let content = match std::fs::read_to_string("/proc/meminfo") { + Ok(c) => c, + Err(_) => return (0, 0), + }; + let mut total_kb = 0u64; + let mut avail_kb = 0u64; + for line in content.lines() { + if line.starts_with("MemTotal:") { + total_kb = line + .split_whitespace() + .nth(1) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + } else if line.starts_with("MemAvailable:") { + avail_kb = line + .split_whitespace() + .nth(1) + .and_then(|v| v.parse().ok()) + .unwrap_or(0); + } + } + let used_mb = (total_kb.saturating_sub(avail_kb)) / 1024; + let total_mb = total_kb / 1024; + (used_mb, total_mb) +} + +fn read_uptime() -> String { + std::fs::read_to_string("/proc/uptime") + .ok() + .and_then(|s| { + s.split_whitespace() + .next() + .and_then(|v| v.parse::().ok()) + }) + .map(|secs| { + let secs = secs as u64; + let d = secs / 86400; + let h = (secs % 86400) / 3600; + let m = (secs % 3600) / 60; + if d > 0 { + format!("{}d {:02}h {:02}m", d, h, m) + } else { + format!("{:02}h {:02}m", h, m) + } + }) + .unwrap_or_else(|| "unknown".to_string()) +} + +/// Read CPU utilization by sampling /proc/stat twice with a 500ms gap. +fn read_cpu_percent() -> Option { + let parse_cpu_line = |s: &str| -> Option<(u64, u64)> { + let fields: Vec = s + .split_whitespace() + .skip(1) // skip "cpu" + .take(7) // user nice system idle iowait irq softirq + .filter_map(|v| v.parse().ok()) + .collect(); + if fields.len() < 7 { + return None; + } + let idle = fields[3] + fields[4]; // idle + iowait + let total: u64 = fields.iter().sum(); + Some((total, idle)) + }; + + let read_first_line = || -> Option { + std::fs::read_to_string("/proc/stat") + .ok() + .and_then(|s| s.lines().next().map(String::from)) + }; + + let line1 = read_first_line()?; + let (total1, idle1) = parse_cpu_line(&line1)?; + + std::thread::sleep(std::time::Duration::from_millis(500)); + + let line2 = read_first_line()?; + let (total2, idle2) = parse_cpu_line(&line2)?; + + let dt = total2.saturating_sub(total1); + let di = idle2.saturating_sub(idle1); + if dt == 0 { + return Some(0); + } + Some((100 * dt.saturating_sub(di) / dt) as u32) +} + +/// Read root filesystem usage by spawning `df -k /` and parsing output. +fn read_disk_usage() -> (Option, Option, Option) { + let output = match std::process::Command::new("df").args(["-k", "/"]).output() { + Ok(o) => o, + Err(_) => return (None, None, None), + }; + let stdout = String::from_utf8_lossy(&output.stdout); + let line = match stdout.lines().nth(1) { + Some(l) => l, + None => return (None, None, None), + }; + let fields: Vec<&str> = line.split_whitespace().collect(); + if fields.len() < 5 { + return (None, None, None); + } + let total_kb: f64 = fields[1].parse().unwrap_or(0.0); + let used_kb: f64 = fields[2].parse().unwrap_or(0.0); + let pct: u32 = fields[4].trim_end_matches('%').parse().unwrap_or(0); + let total_gb = total_kb / 1_048_576.0; + let used_gb = used_kb / 1_048_576.0; + (Some(used_gb), Some(total_gb), Some(pct)) +} + +/// Query pisugar-server via unix socket. Returns (percent, voltage, charging). +fn read_battery() -> (Option, Option, bool) { + let batt_pct = query_pisugar("get battery").and_then(|r| parse_pisugar_float(&r)); + let batt_v = query_pisugar("get battery_v").and_then(|r| parse_pisugar_float(&r)); + let charging = query_pisugar("get battery_charging") + .map(|r| r.contains("true")) + .unwrap_or(false); + (batt_pct, batt_v, charging) +} + +fn query_pisugar(cmd: &str) -> Option { + let mut stream = UnixStream::connect("/tmp/pisugar-server.sock").ok()?; + stream.set_read_timeout(Some(Duration::from_secs(2))).ok()?; + writeln!(stream, "{}", cmd).ok()?; + let mut reader = BufReader::new(stream); + let mut response = String::new(); + reader.read_line(&mut response).ok()?; + Some(response.trim().to_string()) +} + +fn parse_pisugar_float(response: &str) -> Option { + response.rsplit_once(':')?.1.trim().parse().ok() +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..4515f6ec --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,112 @@ +# EPD Status Display + +Systemd service and timer for the Waveshare 2.13" V4 e-paper status display on Pi Zero 2W nodes. + +## What it does + +Runs `epd2in13_v4_status` on a schedule to display system info on the e-paper: +hostname, IP, date/time, uptime, CPU/temp, RAM, disk, and PiSugar battery status. + +## Hardware + +- Waveshare 2.13" e-Paper HAT V4 (SSD1680 controller) +- GPIO: PWR=18, RST=17, DC=25, BUSY=24, SPI CE0 +- SPI: `/dev/spidev0.0`, 4MHz, mode 0 + +## Build + +From the repo root on the build host: + +```bash +cargo build --target aarch64-unknown-linux-gnu --example epd2in13_v4_status --features epd2in13_v4 --release +``` + +## Install + +Copy the repo (or at minimum `scripts/` and the built binary) to the Pi, then: + +```bash +sudo ./scripts/install_epd_status.sh +``` + +This installs all five components: + +1. The `epd2in13_v4_status` binary to `/usr/local/bin/` +2. `epd-status.service` and `epd-status.timer` to `/etc/systemd/system/` (the periodic refresh) +3. `epd-status-boot.service` to `/etc/systemd/system/` (boot-time state reset, see below) +4. `journald-volatile.conf` to `/etc/systemd/journald.conf.d/volatile.conf` (SD card wear protection, see below) + +The timer is enabled and started, the boot service is enabled, and `systemd-journald` is restarted to pick up the volatile config. + +## Boot state reset + +`epd-status-boot.service` is a `oneshot` unit that runs before `epd-status.timer` on every boot and removes the state files under `/var/lib/epd-status/` (`initialized`, `base_set`, `rotation`). + +Why: the e-paper's controller RAM is cleared on power cycle, but the state files on the SD card survive across reboots. Without the reset, the next run after a cold boot would skip the full refresh and try to resume partial-refresh updates against an uninitialized display, leaving garbage on screen. Clearing the state files forces the correct full → base → partial refresh cycle on the first run after every boot. + +The service is ordered `Before=epd-status.timer` and wanted by `sysinit.target`, so it always completes before the first scheduled refresh. + +## Journald volatile config + +`journald-volatile.conf` switches journald to RAM-only storage with a 10MB cap: + +```ini +[Journal] +Storage=volatile +RuntimeMaxUse=10M +``` + +Why: on Pi Zero 2W nodes the root filesystem lives on an SD card, and persistent journald writes are a meaningful source of write amplification over long deployments. Volatile storage keeps logs in `/run/log/journal` (tmpfs) so day-to-day logging never touches the card. Logs are lost on reboot, which is acceptable for these unattended status-display nodes. + +**Not recommended for development machines** (build hosts, Pi 5, anywhere with an SSD or where you debug across reboots) — persistent logs are valuable there. This config is specifically a deployment-node tradeoff. + +## Timing + +| Setting | Default | Description | +|---------|---------|-------------| +| `OnBootSec` | 45s | Delay after boot before first update (wait for network) | +| `OnUnitActiveSec` | 5min | Interval between updates | +| `AccuracySec` | 30s | Allows systemd to batch timer wakeups for power efficiency | + +To change the refresh interval, edit `/etc/systemd/system/epd-status.timer`: + +```bash +sudo systemctl edit epd-status.timer +``` + +Add an override: + +```ini +[Timer] +OnUnitActiveSec=10min +``` + +Then reload: `sudo systemctl daemon-reload` + +## Commands + +```bash +# Run immediately (don't wait for timer) +sudo systemctl start epd-status.service + +# Check timer status +systemctl status epd-status.timer + +# View recent logs +journalctl -u epd-status.service -n 20 + +# Stop the timer +sudo systemctl stop epd-status.timer + +# Disable (won't start on boot) +sudo systemctl disable epd-status.timer + +# Uninstall everything +sudo systemctl disable epd-status.timer epd-status-boot.service +sudo rm /etc/systemd/system/epd-status.{service,timer} +sudo rm /etc/systemd/system/epd-status-boot.service +sudo rm /etc/systemd/journald.conf.d/volatile.conf +sudo rm /usr/local/bin/epd2in13_v4_status +sudo systemctl daemon-reload +sudo systemctl restart systemd-journald +``` diff --git a/scripts/epd-status-boot.service b/scripts/epd-status-boot.service new file mode 100644 index 00000000..82580e43 --- /dev/null +++ b/scripts/epd-status-boot.service @@ -0,0 +1,13 @@ +[Unit] +Description=EPD Status Display — boot state reset +DefaultDependencies=no +Before=epd-status.timer + +[Service] +Type=oneshot +ExecStart=/bin/rm -f /var/lib/epd-status/initialized \ + /var/lib/epd-status/base_set \ + /var/lib/epd-status/rotation + +[Install] +WantedBy=sysinit.target diff --git a/scripts/epd-status.service b/scripts/epd-status.service new file mode 100644 index 00000000..2519963a --- /dev/null +++ b/scripts/epd-status.service @@ -0,0 +1,15 @@ +[Unit] +Description=EPD Status Display Update +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/epd2in13_v4_status +User=root +StateDirectory=epd-status +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/scripts/epd-status.timer b/scripts/epd-status.timer new file mode 100644 index 00000000..54b1f300 --- /dev/null +++ b/scripts/epd-status.timer @@ -0,0 +1,12 @@ +[Unit] +Description=EPD Status Display Timer +Requires=epd-status.service + +[Timer] +OnBootSec=45 +OnUnitActiveSec=5min +AccuracySec=30 +Persistent=false + +[Install] +WantedBy=timers.target diff --git a/scripts/epd3in52-daily.service b/scripts/epd3in52-daily.service new file mode 100644 index 00000000..abd3f60c --- /dev/null +++ b/scripts/epd3in52-daily.service @@ -0,0 +1,13 @@ +[Unit] +Description=EPD 3.52" Daily Panel Exercise (carcinisation demo) +After=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/epd3in52 +User=root +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/scripts/epd3in52-daily.timer b/scripts/epd3in52-daily.timer new file mode 100644 index 00000000..15ed2ab9 --- /dev/null +++ b/scripts/epd3in52-daily.timer @@ -0,0 +1,11 @@ +[Unit] +Description=EPD 3.52" Daily Panel Exercise Timer +Requires=epd3in52-daily.service + +[Timer] +OnCalendar=*-*-* 03:00:00 +AccuracySec=5min +Persistent=false + +[Install] +WantedBy=timers.target diff --git a/scripts/epd3in52-status.service b/scripts/epd3in52-status.service new file mode 100644 index 00000000..eff1f0c2 --- /dev/null +++ b/scripts/epd3in52-status.service @@ -0,0 +1,15 @@ +[Unit] +Description=EPD 3.52" Status Display Update +After=network-online.target +Wants=network-online.target + +[Service] +Type=oneshot +ExecStart=/usr/local/bin/epd3in52_status +User=root +StateDirectory=epd-status +StandardOutput=journal +StandardError=journal + +[Install] +WantedBy=multi-user.target diff --git a/scripts/epd3in52-status.timer b/scripts/epd3in52-status.timer new file mode 100644 index 00000000..a3e27ba6 --- /dev/null +++ b/scripts/epd3in52-status.timer @@ -0,0 +1,12 @@ +[Unit] +Description=EPD 3.52" Status Display Timer +Requires=epd3in52-status.service + +[Timer] +OnBootSec=45 +OnUnitActiveSec=1h +AccuracySec=30 +Persistent=false + +[Install] +WantedBy=timers.target diff --git a/scripts/install_epd_status.sh b/scripts/install_epd_status.sh new file mode 100755 index 00000000..3d239314 --- /dev/null +++ b/scripts/install_epd_status.sh @@ -0,0 +1,54 @@ +#!/bin/bash +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +BINARY="${SCRIPT_DIR}/../target/aarch64-unknown-linux-gnu/release/examples/epd2in13_v4_status" + +if [ ! -f "$BINARY" ]; then + echo "Error: Binary not found at $BINARY" + echo "Build first with:" + echo " cargo build --target aarch64-unknown-linux-gnu --example epd2in13_v4_status --features epd2in13_v4 --release" + exit 1 +fi + +echo "Installing epd-status display service..." + +echo " Creating state directory /var/lib/epd-status" +mkdir -p /var/lib/epd-status +chmod 755 /var/lib/epd-status + +echo " Copying binary to /usr/local/bin/epd2in13_v4_status" +cp "$BINARY" /usr/local/bin/epd2in13_v4_status +chmod 755 /usr/local/bin/epd2in13_v4_status + +echo " Copying unit files to /etc/systemd/system/" +cp "$SCRIPT_DIR/epd-status.service" /etc/systemd/system/epd-status.service +cp "$SCRIPT_DIR/epd-status.timer" /etc/systemd/system/epd-status.timer +cp "$SCRIPT_DIR/epd-status-boot.service" /etc/systemd/system/epd-status-boot.service + +echo " Installing journald volatile config to /etc/systemd/journald.conf.d/volatile.conf" +mkdir -p /etc/systemd/journald.conf.d +cp "$SCRIPT_DIR/journald-volatile.conf" /etc/systemd/journald.conf.d/volatile.conf + +echo " Reloading systemd daemon" +systemctl daemon-reload + +echo " Restarting systemd-journald to apply volatile config" +systemctl restart systemd-journald + +echo " Enabling boot state reset service" +systemctl enable epd-status-boot.service + +echo " Enabling and starting timer" +systemctl enable epd-status.timer +systemctl start epd-status.timer + +echo "" +echo "Installed. Status:" +systemctl status epd-status.timer --no-pager + +echo "" +echo "To run immediately: sudo systemctl start epd-status.service" +echo "To view logs: journalctl -u epd-status.service -n 20" +echo "To stop: sudo systemctl stop epd-status.timer" +echo "To uninstall: sudo systemctl disable epd-status.timer && sudo rm /etc/systemd/system/epd-status.{service,timer} /usr/local/bin/epd2in13_v4_status" diff --git a/scripts/journald-volatile.conf b/scripts/journald-volatile.conf new file mode 100644 index 00000000..b69e2623 --- /dev/null +++ b/scripts/journald-volatile.conf @@ -0,0 +1,3 @@ +[Journal] +Storage=volatile +RuntimeMaxUse=10M diff --git a/src/epd2in13_v4/command.rs b/src/epd2in13_v4/command.rs new file mode 100644 index 00000000..3f439e8a --- /dev/null +++ b/src/epd2in13_v4/command.rs @@ -0,0 +1,51 @@ +//! SPI Commands for the Waveshare 2.13" V4 (SSD1680) + +use crate::traits; + +/// EPD 2.13" V4 commands +#[allow(dead_code)] +#[derive(Copy, Clone)] +#[repr(u8)] +pub(crate) enum Command { + /// Software reset + SwReset = 0x12, + /// Driver output control + DriverOutputControl = 0x01, + /// Data entry mode setting + DataEntryModeSetting = 0x11, + /// Set RAM X address start/end position + SetRamXAddressStartEndPosition = 0x44, + /// Set RAM Y address start/end position + SetRamYAddressStartEndPosition = 0x45, + /// Border waveform control + BorderWaveformControl = 0x3C, + /// Display update control 1 + DisplayUpdateControl1 = 0x21, + /// Temperature sensor control + TemperatureSensorControl = 0x18, + /// Set RAM X address counter + SetRamXAddressCounter = 0x4E, + /// Set RAM Y address counter + SetRamYAddressCounter = 0x4F, + /// Display update control 2 + DisplayUpdateControl2 = 0x22, + /// Master activation + MasterActivation = 0x20, + /// Write RAM (BW) + WriteRam = 0x24, + /// Write RAM (RED/second) + WriteRam2 = 0x26, + /// Deep sleep mode + DeepSleepMode = 0x10, + /// Write temperature register + WriteTempRegister = 0x1A, + /// Read built-in temperature sensor (sent as command, not data) + ReadBuiltInTempSensor = 0x80, +} + +impl traits::Command for Command { + /// Returns the address of the command + fn address(self) -> u8 { + self as u8 + } +} diff --git a/src/epd2in13_v4/constants.rs b/src/epd2in13_v4/constants.rs new file mode 100644 index 00000000..d80d7a17 --- /dev/null +++ b/src/epd2in13_v4/constants.rs @@ -0,0 +1,3 @@ +//! Constants for the Waveshare 2.13" V4 (SSD1680) +//! +//! The V4 uses internal LUTs, so no waveform tables are needed here. diff --git a/src/epd2in13_v4/mod.rs b/src/epd2in13_v4/mod.rs new file mode 100644 index 00000000..3389e560 --- /dev/null +++ b/src/epd2in13_v4/mod.rs @@ -0,0 +1,633 @@ +//! A Driver for the Waveshare 2.13" E-Ink Display V4 via SPI (SSD1680 controller) +//! +//! # References +//! +//! - [Waveshare product page](https://www.waveshare.com/wiki/2.13inch_e-Paper_HAT_(V4)) +//! - [Waveshare Python driver](https://github.com/waveshare/e-Paper/blob/master/RaspberryPi_JetsonNano/python/lib/waveshare_epd/epd2in13_V4.py) +//! +//! # Power pin and constructors +//! +//! The V4 HAT has a power control pin (GPIO18) that must be driven HIGH before +//! the display will respond. Two constructors are available: +//! +//! - [`Epd2in13::new_with_pwr`] — accepts a power pin, drives it HIGH during +//! init. Use this when your board has the PWR pin wired (the common case for +//! the Waveshare HAT). +//! - [`WaveshareDisplay::new`] — no power pin, uses the [`NoPwrPin`] no-op +//! default. Use this on custom boards where power is always on. +//! +//! The [`WaveshareDisplay`] trait is implemented with a `PWR: OutputPin + Default` +//! bound so that `new()` can construct the pin type from nothing. Real GPIO pin +//! types typically do not implement `Default`, so `new_with_pwr()` users call +//! the equivalent inherent methods (`update_frame`, `display_frame`, `sleep`, +//! etc.) directly rather than going through the trait. +//! +//! To fully power down the display after sleep, call [`Epd2in13::power_off`] +//! which drives the PWR pin LOW (matching the Python driver's `module_exit()`). +//! +//! # Example +//! +//! ```rust,ignore +//! use epd_waveshare::epd2in13_v4::{Display2in13, Epd2in13}; +//! use epd_waveshare::prelude::*; +//! +//! // Setup SPI, GPIO, and delay via linux-embedded-hal (omitted) +//! +//! let mut epd = Epd2in13::new_with_pwr( +//! &mut spi, busy, dc, rst, &mut delay, None, pwr, +//! )?; +//! +//! let mut display = Display2in13::default(); +//! display.clear(Color::White).ok(); +//! // ... draw with embedded-graphics ... +//! +//! epd.update_frame(&mut spi, display.buffer(), &mut delay)?; +//! epd.display_frame(&mut spi, &mut delay)?; +//! epd.sleep(&mut spi, &mut delay)?; +//! ``` + +/// Width of the display in pixels +pub const WIDTH: u32 = 122; + +/// Height of the display in pixels +pub const HEIGHT: u32 = 250; + +/// Default Background Color +pub const DEFAULT_BACKGROUND_COLOR: Color = Color::White; +const IS_BUSY_LOW: bool = false; +const SINGLE_BYTE_WRITE: bool = true; + +use embedded_hal::{ + delay::DelayNs, + digital::{ErrorType, InputPin, OutputPin}, + spi::SpiDevice, +}; + +use crate::buffer_len; +use crate::color::Color; +use crate::interface::DisplayInterface; +use crate::traits::{InternalWiAdditions, RefreshLut, WaveshareDisplay}; + +pub(crate) mod command; +use self::command::Command; + +pub(crate) mod constants; + +/// Full size buffer for use with the 2.13" V4 EPD +#[cfg(feature = "graphics")] +pub type Display2in13 = crate::graphics::Display< + WIDTH, + HEIGHT, + false, + { buffer_len(WIDTH as usize, HEIGHT as usize) }, + Color, +>; + +/// No-op output pin used when no power pin is provided. +#[derive(Default)] +pub struct NoPwrPin; + +impl ErrorType for NoPwrPin { + type Error = core::convert::Infallible; +} + +impl OutputPin for NoPwrPin { + fn set_low(&mut self) -> Result<(), Self::Error> { + Ok(()) + } + fn set_high(&mut self) -> Result<(), Self::Error> { + Ok(()) + } +} + +/// Epd2in13 V4 driver (SSD1680) +/// +/// The optional `PWR` type parameter supports the V4 HAT's power pin (GPIO18). +/// Use [`Epd2in13::new_with_pwr`] to supply one, or [`WaveshareDisplay::new`] without. +pub struct Epd2in13 { + /// Connection Interface + interface: DisplayInterface, + /// Background Color + background_color: Color, + /// Optional power control pin (PWR_PIN / GPIO18 on V4 HAT) + pwr_pin: PWR, +} + +impl Epd2in13 +where + SPI: SpiDevice, + BUSY: InputPin, + DC: OutputPin, + RST: OutputPin, + DELAY: DelayNs, + PWR: OutputPin, +{ + /// Create a new driver with a power control pin. + /// + /// The V4 HAT requires GPIO18 (PWR_PIN) to be driven HIGH before the + /// display will respond. This constructor drives the pin HIGH, then + /// performs the normal init sequence. + pub fn new_with_pwr( + spi: &mut SPI, + busy: BUSY, + dc: DC, + rst: RST, + delay: &mut DELAY, + delay_us: Option, + mut pwr_pin: PWR, + ) -> Result { + let _ = pwr_pin.set_high(); + + let interface = DisplayInterface::new(busy, dc, rst, delay_us); + + let mut epd = Epd2in13 { + interface, + background_color: DEFAULT_BACKGROUND_COLOR, + pwr_pin, + }; + + epd.init(spi, delay)?; + Ok(epd) + } + + fn set_ram_area( + &mut self, + spi: &mut SPI, + start_x: u32, + start_y: u32, + end_x: u32, + end_y: u32, + ) -> Result<(), SPI::Error> { + self.interface.cmd_with_data( + spi, + Command::SetRamXAddressStartEndPosition, + &[(start_x >> 3) as u8, (end_x >> 3) as u8], + )?; + + self.interface.cmd_with_data( + spi, + Command::SetRamYAddressStartEndPosition, + &[ + start_y as u8, + (start_y >> 8) as u8, + end_y as u8, + (end_y >> 8) as u8, + ], + ) + } + + fn set_ram_counter(&mut self, spi: &mut SPI, x: u32, y: u32) -> Result<(), SPI::Error> { + // Python SetCursor: sends x & 0xFF directly (no shift, no wait_until_idle) + self.interface + .cmd_with_data(spi, Command::SetRamXAddressCounter, &[(x >> 3) as u8])?; + + self.interface.cmd_with_data( + spi, + Command::SetRamYAddressCounter, + &[y as u8, (y >> 8) as u8], + ) + } + + fn turn_on_display(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + // 0xF7: full refresh master activation sequence (normal) + self.interface + .cmd_with_data(spi, Command::DisplayUpdateControl2, &[0xF7])?; + self.interface.cmd(spi, Command::MasterActivation)?; + self.wait_until_idle(spi, delay) + } + + fn turn_on_display_fast(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + // 0xC7: full refresh master activation sequence (fast) + self.interface + .cmd_with_data(spi, Command::DisplayUpdateControl2, &[0xC7])?; + self.interface.cmd(spi, Command::MasterActivation)?; + self.wait_until_idle(spi, delay) + } + + fn turn_on_display_partial( + &mut self, + spi: &mut SPI, + delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + // 0xFF: partial refresh master activation sequence + self.interface + .cmd_with_data(spi, Command::DisplayUpdateControl2, &[0xFF])?; + self.interface.cmd(spi, Command::MasterActivation)?; + self.wait_until_idle(spi, delay) + } + + fn use_full_frame(&mut self, spi: &mut SPI) -> Result<(), SPI::Error> { + self.set_ram_area(spi, 0, 0, WIDTH - 1, HEIGHT - 1)?; + self.set_ram_counter(spi, 0, 0) + } + + /// Initialize the display for fast refresh mode. + /// + /// After calling this, use `display_fast()` or `update_and_display_fast_frame()`. + pub fn init_fast(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + // SSD1680: 20 ms initial HIGH, 2 ms LOW pulse — matches Waveshare + // Python reference driver (epd2in13_V4.py reset sequence) + self.interface.reset(delay, 20_000, 2_000); + + self.interface.cmd(spi, Command::SwReset)?; + self.wait_until_idle(spi, delay)?; + + // Temperature sensor control: Python sends 0x18 and 0x80 both as commands + self.interface.cmd(spi, Command::TemperatureSensorControl)?; + self.interface.cmd(spi, Command::ReadBuiltInTempSensor)?; + + // Data entry mode: X incr, Y incr + self.interface + .cmd_with_data(spi, Command::DataEntryModeSetting, &[0x03])?; + + self.set_ram_area(spi, 0, 0, WIDTH - 1, HEIGHT - 1)?; + self.set_ram_counter(spi, 0, 0)?; + + // Load temperature and set display mode for fast + self.interface + .cmd_with_data(spi, Command::DisplayUpdateControl2, &[0xB1])?; + self.interface.cmd(spi, Command::MasterActivation)?; + self.wait_until_idle(spi, delay)?; + + // Write temperature register + self.interface + .cmd_with_data(spi, Command::WriteTempRegister, &[0x64, 0x00])?; + + self.interface + .cmd_with_data(spi, Command::DisplayUpdateControl2, &[0x91])?; + self.interface.cmd(spi, Command::MasterActivation)?; + self.wait_until_idle(spi, delay)?; + + Ok(()) + } + + /// Display an image buffer using fast refresh. + pub fn display_fast( + &mut self, + spi: &mut SPI, + buffer: &[u8], + delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + self.interface + .cmd_with_data(spi, Command::WriteRam, buffer)?; + self.turn_on_display_fast(spi, delay) + } + + /// Update and display a frame using fast refresh. + pub fn update_and_display_fast_frame( + &mut self, + spi: &mut SPI, + buffer: &[u8], + delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + self.use_full_frame(spi)?; + self.display_fast(spi, buffer, delay) + } + + /// Display partial update of the frame. + /// + /// This performs a soft reset and reconfigures for partial refresh + /// before writing the buffer. + pub fn display_partial( + &mut self, + spi: &mut SPI, + buffer: &[u8], + delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + // Soft reset: RST LOW 1ms then HIGH — no trailing delay. + // Using soft_reset() instead of reset() which adds 200ms that + // would cause the controller to perform a full reset. + self.interface.soft_reset(delay, 1_000); + + self.interface + .cmd_with_data(spi, Command::BorderWaveformControl, &[0x80])?; + + self.interface + .cmd_with_data(spi, Command::DriverOutputControl, &[0xF9, 0x00, 0x00])?; + + self.interface + .cmd_with_data(spi, Command::DataEntryModeSetting, &[0x03])?; + + self.set_ram_area(spi, 0, 0, WIDTH - 1, HEIGHT - 1)?; + self.set_ram_counter(spi, 0, 0)?; + + self.interface + .cmd_with_data(spi, Command::WriteRam, buffer)?; + + self.turn_on_display_partial(spi, delay) + } + + /// Transmit a full frame to the display RAM. + pub fn update_frame( + &mut self, + spi: &mut SPI, + buffer: &[u8], + _delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + assert!(buffer.len() == buffer_len(WIDTH as usize, HEIGHT as usize)); + self.use_full_frame(spi)?; + self.interface + .cmd_with_data(spi, Command::WriteRam, buffer)?; + Ok(()) + } + + /// Display the frame data from RAM. + pub fn display_frame(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.turn_on_display(spi, delay) + } + + /// Update and display a frame in one call. + pub fn update_and_display_frame( + &mut self, + spi: &mut SPI, + buffer: &[u8], + delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + self.update_frame(spi, buffer, delay)?; + self.display_frame(spi, delay) + } + + /// Clear the display RAM with the background color. + /// + /// This only writes to RAM. Call `display_frame()` afterwards to + /// trigger a refresh, matching the behavior of other drivers. + pub fn clear_frame(&mut self, spi: &mut SPI, _delay: &mut DELAY) -> Result<(), SPI::Error> { + self.use_full_frame(spi)?; + + let color = self.background_color.get_byte_value(); + + self.interface.cmd(spi, Command::WriteRam)?; + self.interface.data_x_times( + spi, + color, + buffer_len(WIDTH as usize, HEIGHT as usize) as u32, + )?; + + Ok(()) + } + + /// Enter deep sleep mode. + /// + /// The display retains its image and can be woken with `wake_up()`. + /// To fully power down, call `power_off()` after this. + pub fn sleep(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.wait_until_idle(spi, delay)?; + self.interface + .cmd_with_data(spi, Command::DeepSleepMode, &[0x01])?; + Ok(()) + } + + /// Drive the power pin LOW, fully powering down the display. + /// + /// Matches Python's `module_exit()`. Call after `sleep()` when the + /// display is no longer needed. A subsequent `wake_up()` will drive + /// PWR HIGH again during init. + pub fn power_off(&mut self) { + let _ = self.pwr_pin.set_low(); + } + + /// Reinitialize the display. Matches Python's `epd.init()`. + /// + /// Call this after `clear_frame()` and before writing new frame data, + /// following the V4 init-clear-init-display pattern. + pub fn reinit(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.init(spi, delay) + } + + /// Wake up from deep sleep and reinitialize. + pub fn wake_up(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.init(spi, delay) + } + + /// Wait until the display is idle. + pub fn wait_until_idle(&mut self, _spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.interface.wait_until_idle(delay, IS_BUSY_LOW); + Ok(()) + } + + /// Set the background color. + pub fn set_background_color(&mut self, background_color: Color) { + self.background_color = background_color; + } + + /// Get the current background color. + pub fn background_color(&self) -> &Color { + &self.background_color + } + + /// Write the buffer to both RAM and RAM2 (base image for partial refresh). + pub fn display_part_base_image( + &mut self, + spi: &mut SPI, + buffer: &[u8], + delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + self.use_full_frame(spi)?; + + self.interface + .cmd_with_data(spi, Command::WriteRam, buffer)?; + self.interface + .cmd_with_data(spi, Command::WriteRam2, buffer)?; + + self.turn_on_display(spi, delay) + } +} + +impl InternalWiAdditions + for Epd2in13 +where + SPI: SpiDevice, + BUSY: InputPin, + DC: OutputPin, + RST: OutputPin, + DELAY: DelayNs, + PWR: OutputPin, +{ + fn init(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + // Drive power pin HIGH before any SPI communication (V4 requirement) + let _ = self.pwr_pin.set_high(); + + // SSD1680: 20 ms initial HIGH, 2 ms LOW pulse — matches Waveshare + // Python reference driver (epd2in13_V4.py reset sequence) + self.interface.reset(delay, 20_000, 2_000); + self.wait_until_idle(spi, delay)?; + + // Software reset + self.interface.cmd(spi, Command::SwReset)?; + self.wait_until_idle(spi, delay)?; + + // Driver output control: set gate lines = HEIGHT - 1 = 0xF9 + self.interface + .cmd_with_data(spi, Command::DriverOutputControl, &[0xF9, 0x00, 0x00])?; + + // Data entry mode: X incr, Y incr + self.interface + .cmd_with_data(spi, Command::DataEntryModeSetting, &[0x03])?; + + // Set RAM window and cursor + self.set_ram_area(spi, 0, 0, WIDTH - 1, HEIGHT - 1)?; + self.set_ram_counter(spi, 0, 0)?; + + // Border waveform control + self.interface + .cmd_with_data(spi, Command::BorderWaveformControl, &[0x05])?; + + // Display update control 1: 0x00, 0x80 -- critical V4 difference from V2/V3 + self.interface + .cmd_with_data(spi, Command::DisplayUpdateControl1, &[0x00, 0x80])?; + + // Temperature sensor control: use internal sensor + self.interface + .cmd_with_data(spi, Command::TemperatureSensorControl, &[0x80])?; + + self.wait_until_idle(spi, delay)?; + Ok(()) + } +} + +impl WaveshareDisplay + for Epd2in13 +where + SPI: SpiDevice, + BUSY: InputPin, + DC: OutputPin, + RST: OutputPin, + DELAY: DelayNs, + PWR: OutputPin + Default, +{ + type DisplayColor = Color; + + fn new( + spi: &mut SPI, + busy: BUSY, + dc: DC, + rst: RST, + delay: &mut DELAY, + delay_us: Option, + ) -> Result { + let interface = DisplayInterface::new(busy, dc, rst, delay_us); + + let mut epd = Epd2in13 { + interface, + background_color: DEFAULT_BACKGROUND_COLOR, + pwr_pin: PWR::default(), + }; + + epd.init(spi, delay)?; + Ok(epd) + } + + fn wake_up(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.init(spi, delay) + } + + fn sleep(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.wait_until_idle(spi, delay)?; + self.interface + .cmd_with_data(spi, Command::DeepSleepMode, &[0x01])?; + Ok(()) + } + + fn update_frame( + &mut self, + spi: &mut SPI, + buffer: &[u8], + _delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + assert!(buffer.len() == buffer_len(WIDTH as usize, HEIGHT as usize)); + self.use_full_frame(spi)?; + self.interface + .cmd_with_data(spi, Command::WriteRam, buffer)?; + Ok(()) + } + + fn update_partial_frame( + &mut self, + spi: &mut SPI, + _delay: &mut DELAY, + buffer: &[u8], + x: u32, + y: u32, + width: u32, + height: u32, + ) -> Result<(), SPI::Error> { + self.set_ram_area(spi, x, y, x + width, y + height)?; + self.set_ram_counter(spi, x, y)?; + self.interface + .cmd_with_data(spi, Command::WriteRam, buffer)?; + Ok(()) + } + + fn display_frame(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.turn_on_display(spi, delay) + } + + fn update_and_display_frame( + &mut self, + spi: &mut SPI, + buffer: &[u8], + delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + self.update_frame(spi, buffer, delay)?; + self.display_frame(spi, delay) + } + + fn clear_frame(&mut self, spi: &mut SPI, _delay: &mut DELAY) -> Result<(), SPI::Error> { + self.use_full_frame(spi)?; + + let color = self.background_color.get_byte_value(); + + self.interface.cmd(spi, Command::WriteRam)?; + self.interface.data_x_times( + spi, + color, + buffer_len(WIDTH as usize, HEIGHT as usize) as u32, + )?; + + Ok(()) + } + + fn set_background_color(&mut self, background_color: Color) { + self.background_color = background_color; + } + + fn background_color(&self) -> &Color { + &self.background_color + } + + fn width(&self) -> u32 { + WIDTH + } + + fn height(&self) -> u32 { + HEIGHT + } + + fn set_lut( + &mut self, + _spi: &mut SPI, + _delay: &mut DELAY, + _refresh_rate: Option, + ) -> Result<(), SPI::Error> { + // V4 uses internal LUT, no custom LUT needed + Ok(()) + } + + fn wait_until_idle(&mut self, _spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.interface.wait_until_idle(delay, IS_BUSY_LOW); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn epd_size() { + assert_eq!(WIDTH, 122); + assert_eq!(HEIGHT, 250); + assert_eq!(DEFAULT_BACKGROUND_COLOR, Color::White); + assert_eq!(buffer_len(WIDTH as usize, HEIGHT as usize), 16 * 250); + } +} diff --git a/src/epd3in52/command.rs b/src/epd3in52/command.rs new file mode 100644 index 00000000..77064140 --- /dev/null +++ b/src/epd3in52/command.rs @@ -0,0 +1,65 @@ +//! SPI Commands for the Waveshare 3.52" E-Ink Display + +use crate::traits; + +/// EPD3IN52 commands +/// +/// Should rarely (never?) be needed directly. +/// +/// For more infos about the addresses and what they are doing look into the pdfs +#[allow(dead_code)] +#[derive(Copy, Clone)] +#[repr(u8)] +pub(crate) enum Command { + PanelSetting = 0x00, + PowerSetting = 0x01, + BoosterSoftStart = 0x06, + DataStartTransmission = 0x13, + Refresh = 0x17, + LutVcom = 0x20, + LutBlue = 0x21, + LutWhite = 0x22, + LutGray1 = 0x23, + LutGray2 = 0x24, + PllControl = 0x30, + VcomDataSetting = 0x50, + TconSetting = 0x60, + ResolutionSetting = 0x61, + VcomDcSetting = 0x82, + PowerSaving = 0xE3, + Sleep = 0x07, +} + +impl traits::Command for Command { + /// Returns the address of the command + fn address(self) -> u8 { + self as u8 + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::traits::Command as CommandTrait; + + #[test] + fn command_addr() { + assert_eq!(Command::PanelSetting.address(), 0x00); + assert_eq!(Command::PowerSetting.address(), 0x01); + assert_eq!(Command::BoosterSoftStart.address(), 0x06); + assert_eq!(Command::DataStartTransmission.address(), 0x13); + assert_eq!(Command::Refresh.address(), 0x17); + assert_eq!(Command::LutVcom.address(), 0x20); + assert_eq!(Command::LutBlue.address(), 0x21); + assert_eq!(Command::LutWhite.address(), 0x22); + assert_eq!(Command::LutGray1.address(), 0x23); + assert_eq!(Command::LutGray2.address(), 0x24); + assert_eq!(Command::PllControl.address(), 0x30); + assert_eq!(Command::VcomDataSetting.address(), 0x50); + assert_eq!(Command::TconSetting.address(), 0x60); + assert_eq!(Command::ResolutionSetting.address(), 0x61); + assert_eq!(Command::VcomDcSetting.address(), 0x82); + assert_eq!(Command::PowerSaving.address(), 0xE3); + assert_eq!(Command::Sleep.address(), 0x07); + } +} diff --git a/src/epd3in52/constants.rs b/src/epd3in52/constants.rs new file mode 100644 index 00000000..6e733fc4 --- /dev/null +++ b/src/epd3in52/constants.rs @@ -0,0 +1,71 @@ +// Hardware-verified waveform LUT tables from the Waveshare Python reference driver. + +// --- Global Clear (GC) LUTs --- + +pub(crate) const LUT_R20_GC: [u8; 56] = [ + 0x01, 0x0f, 0x0f, 0x0f, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +pub(crate) const LUT_R21_GC: [u8; 42] = [ + 0x01, 0x4f, 0x8f, 0x0f, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +pub(crate) const LUT_R22_GC: [u8; 56] = [ + 0x01, 0x0f, 0x8f, 0x0f, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +pub(crate) const LUT_R23_GC: [u8; 56] = [ + 0x01, 0x4f, 0x8f, 0x4f, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +pub(crate) const LUT_R24_GC: [u8; 42] = [ + 0x01, 0x0f, 0x8f, 0x4f, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +// --- Differential Update (DU) LUTs --- + +pub(crate) const LUT_R20_DU: [u8; 56] = [ + 0x01, 0x0f, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +pub(crate) const LUT_R21_DU: [u8; 42] = [ + 0x01, 0x0f, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +pub(crate) const LUT_R22_DU: [u8; 56] = [ + 0x01, 0x8f, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +pub(crate) const LUT_R23_DU: [u8; 56] = [ + 0x01, 0x4f, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; + +pub(crate) const LUT_R24_DU: [u8; 42] = [ + 0x01, 0x0f, 0x01, 0x00, 0x00, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, +]; diff --git a/src/epd3in52/mod.rs b/src/epd3in52/mod.rs new file mode 100644 index 00000000..5f1546db --- /dev/null +++ b/src/epd3in52/mod.rs @@ -0,0 +1,352 @@ +//! A Driver for the Waveshare 3.52" E-Ink Display via SPI (UC8253 controller) +//! +//! # References +//! +//! - [Waveshare product page](https://www.waveshare.com/wiki/3.52inch_e-Paper_HAT) +//! - [Waveshare Python reference driver](https://www.waveshare.com/wiki/3.52inch_e-Paper_HAT#Demo_code) +//! +//! # LUT flag alternation +//! +//! The UC8253 requires alternating between two internal LUT flag states +//! across consecutive [`WaveshareDisplay::display_frame`] calls. The driver +//! tracks this automatically via the `lut_flag` field and swaps R22/R23 +//! LUT register assignments on each refresh. +//! +//! **Callers must not call `display_frame()` twice in a single refresh +//! cycle** — doing so advances `lut_flag` out of sync with the panel state +//! and causes the next image to refresh with swapped waveforms (visible as +//! inverted colours). +//! +//! # Example +//! +//! ```rust,ignore +//! use epd_waveshare::epd3in52::{Display3in52, Epd3in52}; +//! use epd_waveshare::prelude::*; +//! +//! // Setup SPI, GPIO, and delay via linux-embedded-hal (omitted) +//! +//! let mut epd = Epd3in52::new(&mut spi, busy, dc, rst, &mut delay, None)?; +//! +//! let mut display = Display3in52::default(); +//! display.clear(Color::White).ok(); +//! // ... draw with embedded-graphics ... +//! +//! epd.update_frame(&mut spi, display.buffer(), &mut delay)?; +//! epd.display_frame(&mut spi, &mut delay)?; +//! epd.sleep(&mut spi, &mut delay)?; +//! ``` + +use embedded_hal::{ + delay::DelayNs, + digital::{InputPin, OutputPin}, + spi::SpiDevice, +}; + +pub(crate) mod command; +mod constants; + +use self::command::Command; +use self::constants::*; + +use crate::buffer_len; +use crate::color::Color; +use crate::interface::DisplayInterface; +use crate::traits::{InternalWiAdditions, RefreshLut, WaveshareDisplay}; + +/// Width of the display. +pub const WIDTH: u32 = 240; + +/// Height of the display +pub const HEIGHT: u32 = 360; + +/// Default Background Color +pub const DEFAULT_BACKGROUND_COLOR: Color = Color::White; + +const IS_BUSY_LOW: bool = true; + +const SINGLE_BYTE_WRITE: bool = true; + +/// Display with Fullsize buffer for use with the 3in52 EPD +#[cfg(feature = "graphics")] +pub type Display3in52 = crate::graphics::Display< + WIDTH, + HEIGHT, + false, + { buffer_len(WIDTH as usize, HEIGHT as usize) }, + Color, +>; + +/// Epd3in52 driver (UC8253) +/// +/// Generic over the SPI device, BUSY/DC/RST pins, and delay provider. +/// Construct via [`WaveshareDisplay::new`]; partial-refresh users should +/// switch LUT modes via [`WaveshareDisplay::set_lut`] with +/// [`RefreshLut::Quick`] before calling [`Epd3in52::display_frame`]. +pub struct Epd3in52 { + /// Connection Interface + interface: DisplayInterface, + /// Background Color + background_color: Color, + /// Alternates waveform tables each refresh + lut_flag: bool, + /// Controls which LUT waveform set is used: Full (GC) or Quick (DU) + refresh_lut: RefreshLut, +} + +impl InternalWiAdditions + for Epd3in52 +where + SPI: SpiDevice, + BUSY: InputPin, + DC: OutputPin, + RST: OutputPin, + DELAY: DelayNs, +{ + fn init(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + // reset the device + // UC8253: 200 ms initial HIGH, 10 ms LOW pulse — matches Waveshare + // Python reference driver (epdconfig.delay_ms values) + self.interface.reset(delay, 200_000, 10_000); + + // Panel setting (PSR): LUT from register, BWR mode, scan direction + self.interface + .cmd_with_data(spi, Command::PanelSetting, &[0xFF, 0x01])?; + // Power setting (PWR): VDS_EN/VDG_EN, VCOM/source voltages + self.interface.cmd_with_data( + spi, + Command::PowerSetting, + &[0x03, 0x10, 0x3F, 0x3F, 0x03], + )?; + // Booster soft start (BTST): phase A/B/C drive strengths + self.interface + .cmd_with_data(spi, Command::BoosterSoftStart, &[0x37, 0x3D, 0x3D])?; + // TCON setting: source/gate non-overlap period + self.interface + .cmd_with_data(spi, Command::TconSetting, &[0x22])?; + // VCOM DC setting: VCOM voltage level + self.interface + .cmd_with_data(spi, Command::VcomDcSetting, &[0x07])?; + // PLL control: frame rate (50 Hz nominal) + self.interface + .cmd_with_data(spi, Command::PllControl, &[0x09])?; + // Power saving / gate EQ + self.interface + .cmd_with_data(spi, Command::PowerSaving, &[0x88])?; + // Resolution setting (TRES): 240 × 360 (0xF0 = 240, 0x0168 = 360) + self.interface + .cmd_with_data(spi, Command::ResolutionSetting, &[0xF0, 0x01, 0x68])?; + // VCOM data interval setting: border waveform + data polarity + self.interface + .cmd_with_data(spi, Command::VcomDataSetting, &[0xB7])?; + + self.lut_flag = false; + self.refresh_lut = RefreshLut::Full; + + Ok(()) + } +} + +impl WaveshareDisplay + for Epd3in52 +where + SPI: SpiDevice, + BUSY: InputPin, + DC: OutputPin, + RST: OutputPin, + DELAY: DelayNs, +{ + type DisplayColor = Color; + + fn new( + spi: &mut SPI, + busy: BUSY, + dc: DC, + rst: RST, + delay: &mut DELAY, + delay_us: Option, + ) -> Result { + let mut epd = Epd3in52 { + interface: DisplayInterface::new(busy, dc, rst, delay_us), + background_color: DEFAULT_BACKGROUND_COLOR, + lut_flag: false, + refresh_lut: RefreshLut::Full, + }; + + epd.init(spi, delay)?; + Ok(epd) + } + + fn wake_up(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.init(spi, delay) + } + + fn sleep(&mut self, spi: &mut SPI, _delay: &mut DELAY) -> Result<(), SPI::Error> { + self.interface.cmd_with_data(spi, Command::Sleep, &[0xA5])?; + Ok(()) + } + + fn set_background_color(&mut self, color: Self::DisplayColor) { + self.background_color = color; + } + + fn background_color(&self) -> &Self::DisplayColor { + &self.background_color + } + + fn width(&self) -> u32 { + WIDTH + } + + fn height(&self) -> u32 { + HEIGHT + } + + fn update_frame( + &mut self, + spi: &mut SPI, + buffer: &[u8], + _delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + assert!(buffer.len() == buffer_len(WIDTH as usize, HEIGHT as usize)); + self.interface + .cmd_with_data(spi, Command::DataStartTransmission, buffer)?; + Ok(()) + } + + /// The UC8253 controller does not support partial frame updates + /// in the same way as SSD1680-based panels. This implementation + /// performs a full-frame update for API compatibility with the + /// [`WaveshareDisplay`] trait. The x, y, width, and height + /// parameters are accepted but ignored. + /// + /// For true partial refresh on this display, use + /// [`Epd3in52::display_frame`] with [`RefreshLut::Quick`] (DU mode). + fn update_partial_frame( + &mut self, + spi: &mut SPI, + delay: &mut DELAY, + buffer: &[u8], + _x: u32, + _y: u32, + _width: u32, + _height: u32, + ) -> Result<(), SPI::Error> { + self.update_frame(spi, buffer, delay) + } + + fn display_frame(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + match self.refresh_lut { + RefreshLut::Full => { + // GC (global clear) waveform — full quality, ~0.9s + self.interface + .cmd_with_data(spi, Command::LutVcom, &LUT_R20_GC)?; + self.interface + .cmd_with_data(spi, Command::LutBlue, &LUT_R21_GC)?; + self.interface + .cmd_with_data(spi, Command::LutGray2, &LUT_R24_GC)?; + + if !self.lut_flag { + self.interface + .cmd_with_data(spi, Command::LutWhite, &LUT_R22_GC)?; + self.interface + .cmd_with_data(spi, Command::LutGray1, &LUT_R23_GC[..42])?; + } else { + self.interface + .cmd_with_data(spi, Command::LutWhite, &LUT_R23_GC)?; + self.interface + .cmd_with_data(spi, Command::LutGray1, &LUT_R22_GC[..42])?; + } + } + RefreshLut::Quick => { + // WARNING: DU (differential update) fast refresh. + // Waveshare note: "Quick refresh is supported, but the refresh + // effect is not good, but it is not recommended." + // Use RefreshLut::Full (GC) for normal operation. + self.interface + .cmd_with_data(spi, Command::LutVcom, &LUT_R20_DU)?; + self.interface + .cmd_with_data(spi, Command::LutBlue, &LUT_R21_DU)?; + self.interface + .cmd_with_data(spi, Command::LutGray2, &LUT_R24_DU)?; + + if !self.lut_flag { + self.interface + .cmd_with_data(spi, Command::LutWhite, &LUT_R22_DU)?; + self.interface + .cmd_with_data(spi, Command::LutGray1, &LUT_R23_DU[..42])?; + } else { + self.interface + .cmd_with_data(spi, Command::LutWhite, &LUT_R23_DU)?; + self.interface + .cmd_with_data(spi, Command::LutGray1, &LUT_R22_DU[..42])?; + } + } + } + + self.lut_flag = !self.lut_flag; + + self.interface + .cmd_with_data(spi, Command::Refresh, &[0xA5])?; + self.interface.wait_until_idle(delay, IS_BUSY_LOW); + delay.delay_us(200_000); + Ok(()) + } + + fn update_and_display_frame( + &mut self, + spi: &mut SPI, + buffer: &[u8], + delay: &mut DELAY, + ) -> Result<(), SPI::Error> { + self.update_frame(spi, buffer, delay)?; + self.display_frame(spi, delay)?; + Ok(()) + } + + fn clear_frame(&mut self, spi: &mut SPI, _delay: &mut DELAY) -> Result<(), SPI::Error> { + let color = self.background_color.get_byte_value(); + self.interface.cmd(spi, Command::DataStartTransmission)?; + self.interface.data_x_times( + spi, + color, + buffer_len(WIDTH as usize, HEIGHT as usize) as u32, + )?; + Ok(()) + } + + fn set_lut( + &mut self, + _spi: &mut SPI, + _delay: &mut DELAY, + refresh_rate: Option, + ) -> Result<(), SPI::Error> { + if let Some(lut) = refresh_rate { + self.refresh_lut = lut; + } + Ok(()) + } + + fn wait_until_idle(&mut self, _spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + self.interface.wait_until_idle(delay, IS_BUSY_LOW); + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn epd_size() { + assert_eq!(WIDTH, 240); + assert_eq!(HEIGHT, 360); + assert_eq!(buffer_len(WIDTH as usize, HEIGHT as usize), 240 / 8 * 360); + } + + #[test] + fn lut_selection_default_is_full() { + // RefreshLut::Full is the default — DU must be explicitly requested + assert!(matches!(RefreshLut::Full, RefreshLut::Full)); + assert!(matches!(RefreshLut::Quick, RefreshLut::Quick)); + } +} diff --git a/src/interface.rs b/src/interface.rs index f3a9a3d7..f136bcc2 100644 --- a/src/interface.rs +++ b/src/interface.rs @@ -100,7 +100,11 @@ where ) -> Result<(), SPI::Error> { // high for data let _ = self.dc.set_high(); - // Transfer data (u8) over spi + // KNOWN-LIMITATION: one spi.write() per byte — each issues a full + // ioctl with CS toggle on linux-embedded-hal. Acceptable for + // clear_frame() which is not on the hot path for status displays. + // A future optimisation would batch into a heap-allocated Vec or + // use a fixed-size stack buffer with chunked writes. for _ in 0..repetitions { self.write(spi, &[val])?; } @@ -205,4 +209,15 @@ where // 10ms works fine with just for the 7in5_v2 but this needs to be validated for other devices delay.delay_us(200_000); } + + /// Minimal reset pulse with no trailing delay. + /// + /// Used by partial refresh sequences where the 200ms post-reset delay + /// in [`reset`] would cause the controller to perform a full reset + /// instead of a soft reset. + pub(crate) fn soft_reset(&mut self, delay: &mut DELAY, duration: u32) { + let _ = self.rst.set_low(); + delay.delay_us(duration); + let _ = self.rst.set_high(); + } } diff --git a/src/lib.rs b/src/lib.rs index 3ef7291a..7db46fd1 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -81,6 +81,7 @@ pub mod epd1in54_v2; pub mod epd1in54b; pub mod epd1in54c; pub mod epd2in13_v2; +pub mod epd2in13_v4; pub mod epd2in13b_v4; pub mod epd2in13bc; pub mod epd2in66b; @@ -92,6 +93,7 @@ pub mod epd2in9_v2; pub mod epd2in9b_v4; pub mod epd2in9bc; pub mod epd2in9d; +pub mod epd3in52; pub mod epd3in7; pub mod epd4in2; pub mod epd5in65f;