From c1260e275e70e31c311834cf2ad3485b99baabdb Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 01:53:35 -0400 Subject: [PATCH 01/27] feat: add support for Waveshare 3.52" EPD (EPD3IN52) - 240x360 resolution, UC8179 controller - Hardware-verified LUT waveform tables (GC and DU) - Alternating R22/R23 waveform tables in display_frame() - IS_BUSY_LOW: true, verified on Raspberry Pi 5 with lgpio - Includes Display3in52 type alias for graphics feature - 56 unit tests passing (54 original + 2 new) --- src/epd3in52/command.rs | 65 ++++++++++ src/epd3in52/constants.rs | 112 +++++++++++++++++ src/epd3in52/mod.rs | 251 ++++++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 4 files changed, 429 insertions(+) create mode 100644 src/epd3in52/command.rs create mode 100644 src/epd3in52/constants.rs create mode 100644 src/epd3in52/mod.rs diff --git a/src/epd3in52/command.rs b/src/epd3in52/command.rs new file mode 100644 index 00000000..b9f6ac57 --- /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 = 0x12, + 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(), 0x12); + 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..bdeba2ab --- /dev/null +++ b/src/epd3in52/constants.rs @@ -0,0 +1,112 @@ +// 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 --- + +#[allow(dead_code)] +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, +]; + +#[allow(dead_code)] +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, +]; + +#[allow(dead_code)] +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, +]; + +#[allow(dead_code)] +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, +]; + +#[allow(dead_code)] +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..0a19c215 --- /dev/null +++ b/src/epd3in52/mod.rs @@ -0,0 +1,251 @@ +//! A simple Driver for the Waveshare 3.52" E-Ink Display via SPI +//! +//! +//! Build with the help of documentation/code from [Waveshare](https://www.waveshare.com/wiki/3.52inch_e-Paper_HAT), + +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 +pub struct EPD3in52 { + /// Connection Interface + interface: DisplayInterface, + /// Background Color + background_color: Color, + /// Alternates waveform tables each refresh + lut_flag: bool, +} + +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 + self.interface.reset(delay, 30, 10); + + self.interface + .cmd_with_data(spi, Command::PanelSetting, &[0xFF, 0x01])?; + self.interface + .cmd_with_data(spi, Command::PowerSetting, &[0x03, 0x10, 0x3F, 0x3F, 0x03])?; + self.interface + .cmd_with_data(spi, Command::BoosterSoftStart, &[0x37, 0x3D, 0x3D])?; + self.interface + .cmd_with_data(spi, Command::TconSetting, &[0x22])?; + self.interface + .cmd_with_data(spi, Command::VcomDcSetting, &[0x07])?; + self.interface + .cmd_with_data(spi, Command::PllControl, &[0x09])?; + self.interface + .cmd_with_data(spi, Command::PowerSaving, &[0x88])?; + self.interface + .cmd_with_data(spi, Command::ResolutionSetting, &[0xF0, 0x01, 0x68])?; + self.interface + .cmd_with_data(spi, Command::VcomDataSetting, &[0xB7])?; + + self.lut_flag = false; + + 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, + }; + + 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(()) + } + + #[allow(unused)] + 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> { + todo!() + } + + fn display_frame(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + 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])?; + } + + self.lut_flag = !self.lut_flag; + + self.interface.cmd(spi, Command::Refresh)?; + self.interface.wait_until_idle(delay, IS_BUSY_LOW); + 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, WIDTH * HEIGHT)?; + Ok(()) + } + + fn set_lut( + &mut self, + _spi: &mut SPI, + _delay: &mut DELAY, + _refresh_rate: Option, + ) -> Result<(), SPI::Error> { + // LUTs are sent during display_frame with alternating waveform tables + 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 + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 3ef7291a..a478e88d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -92,6 +92,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; From 9b66389c38ff81a76bd90fdd0343afef807b8005 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 02:00:19 -0400 Subject: [PATCH 02/27] style: apply cargo fmt --- src/epd3in52/constants.rs | 108 +++++++++++++------------------------- src/epd3in52/mod.rs | 21 ++++---- 2 files changed, 45 insertions(+), 84 deletions(-) diff --git a/src/epd3in52/constants.rs b/src/epd3in52/constants.rs index bdeba2ab..6b7fe347 100644 --- a/src/epd3in52/constants.rs +++ b/src/epd3in52/constants.rs @@ -3,110 +3,74 @@ // --- 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, + 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, + 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, + 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, + 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, + 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 --- #[allow(dead_code)] 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, + 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, ]; #[allow(dead_code)] 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, + 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, ]; #[allow(dead_code)] 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, + 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, ]; #[allow(dead_code)] 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, + 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, ]; #[allow(dead_code)] 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, + 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 index 0a19c215..f7ded5f3 100644 --- a/src/epd3in52/mod.rs +++ b/src/epd3in52/mod.rs @@ -68,8 +68,11 @@ where self.interface .cmd_with_data(spi, Command::PanelSetting, &[0xFF, 0x01])?; - self.interface - .cmd_with_data(spi, Command::PowerSetting, &[0x03, 0x10, 0x3F, 0x3F, 0x03])?; + self.interface.cmd_with_data( + spi, + Command::PowerSetting, + &[0x03, 0x10, 0x3F, 0x3F, 0x03], + )?; self.interface .cmd_with_data(spi, Command::BoosterSoftStart, &[0x37, 0x3D, 0x3D])?; self.interface @@ -125,8 +128,7 @@ where } fn sleep(&mut self, spi: &mut SPI, _delay: &mut DELAY) -> Result<(), SPI::Error> { - self.interface - .cmd_with_data(spi, Command::Sleep, &[0xA5])?; + self.interface.cmd_with_data(spi, Command::Sleep, &[0xA5])?; Ok(()) } @@ -212,10 +214,8 @@ where 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, WIDTH * HEIGHT)?; + self.interface.cmd(spi, Command::DataStartTransmission)?; + self.interface.data_x_times(spi, color, WIDTH * HEIGHT)?; Ok(()) } @@ -243,9 +243,6 @@ mod tests { fn epd_size() { assert_eq!(WIDTH, 240); assert_eq!(HEIGHT, 360); - assert_eq!( - buffer_len(WIDTH as usize, HEIGHT as usize), - 240 / 8 * 360 - ); + assert_eq!(buffer_len(WIDTH as usize, HEIGHT as usize), 240 / 8 * 360); } } From 811b95a8780b1456981a51b2fbf4a28da99a261a Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 17:34:44 -0400 Subject: [PATCH 03/27] feat: add support for Waveshare 2.13" EPD V4 (SSD1680) - Add epd2in13_v4 module with full SSD1680 driver - Supports full refresh, fast refresh, and partial refresh - Optional PWR pin support (GPIO18 required on V4 HAT) - Tested on Raspberry Pi Zero 2W with physical hardware - Fixes: remove spurious wait_until_idle in set_ram_counter - Fixes: init_fast 0x80 sent as command not data - Add epd2in13_v4_raw example for low-level hardware validation - Closes #207 --- .cargo/config.toml | 2 + Cargo.toml | 9 + examples/epd2in13_v4.rs | 142 +++++++++ examples/epd2in13_v4_raw.rs | 192 ++++++++++++ src/epd2in13_v4/command.rs | 50 +++ src/epd2in13_v4/constants.rs | 3 + src/epd2in13_v4/mod.rs | 573 +++++++++++++++++++++++++++++++++++ src/lib.rs | 1 + 8 files changed, 972 insertions(+) create mode 100644 .cargo/config.toml create mode 100644 examples/epd2in13_v4.rs create mode 100644 examples/epd2in13_v4_raw.rs create mode 100644 src/epd2in13_v4/command.rs create mode 100644 src/epd2in13_v4/constants.rs create mode 100644 src/epd2in13_v4/mod.rs 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/Cargo.toml b/Cargo.toml index 73c33370..9b552ea1 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -43,6 +43,14 @@ 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 = "epd4in2" required-features = ["linux-dev"] @@ -54,6 +62,7 @@ default = ["graphics", "linux-dev", "epd2in13_v3"] graphics = ["embedded-graphics-core"] epd2in13_v2 = [] epd2in13_v3 = [] +epd2in13_v4 = [] linux-dev = [] # Offers an alternative fast full lut for type_a displays, but the refreshed screen isnt as clean looking diff --git a/examples/epd2in13_v4.rs b/examples/epd2in13_v4.rs new file mode 100644 index 00000000..e5ca8cd3 --- /dev/null +++ b/examples/epd2in13_v4.rs @@ -0,0 +1,142 @@ +use embedded_graphics::{ + mono_font::{ascii::FONT_6X10, MonoTextStyleBuilder}, + prelude::*, + primitives::{Circle, Line, PrimitiveStyle, Rectangle}, + text::{Alignment, Baseline, Text, TextStyleBuilder}, +}; +use embedded_hal::delay::DelayNs; +use epd_waveshare::{ + epd2in13_v4::{Display2in13, Epd2in13}, + prelude::*, +}; +use linux_embedded_hal::{ + gpio_cdev::{Chip, LineRequestFlags}, + spidev::{self, SpidevOptions}, + CdevPin, Delay, SpidevDevice, +}; + +fn main() -> Result<(), Box> { + let mut spi = SpidevDevice::open("/dev/spidev0.0")?; + let options = SpidevOptions::new() + .bits_per_word(8) + .max_speed_hz(4_000_000) + .mode(spidev::SpiModeFlags::SPI_MODE_0) + .build(); + spi.configure(&options)?; + + let mut chip = Chip::new("/dev/gpiochip0")?; + let busy = CdevPin::new( + chip.get_line(24)? + .request(LineRequestFlags::INPUT, 0, "epd-busy")?, + )?; + let dc = CdevPin::new( + chip.get_line(25)? + .request(LineRequestFlags::OUTPUT, 0, "epd-dc")?, + )?; + let rst = CdevPin::new( + chip.get_line(17)? + .request(LineRequestFlags::OUTPUT, 1, "epd-rst")?, + )?; + let pwr = CdevPin::new( + chip.get_line(18)? + .request(LineRequestFlags::OUTPUT, 0, "epd-pwr")?, + )?; + + let mut delay = Delay; + + let mut epd = Epd2in13::new_with_pwr(&mut spi, busy, dc, rst, &mut delay, None, pwr)?; + delay.delay_ms(100); + + epd.reinit(&mut spi, &mut delay)?; + delay.delay_ms(100); + + // Prepare framebuffer — white background + let mut display = Display2in13::default(); + display.clear(Color::White).ok(); + + let stroke = PrimitiveStyle::with_stroke(Color::Black, 1); + let fill_black = PrimitiveStyle::with_fill(Color::Black); + + // --- Bottom border: outline around full display --- + Rectangle::new(Point::new(0, 0), Size::new(122, 250)) + .into_styled(stroke) + .draw(&mut display)?; + + // --- Header bar: filled black with white text --- + Rectangle::new(Point::new(0, 0), Size::new(122, 21)) + .into_styled(fill_black) + .draw(&mut display)?; + + let white_text = MonoTextStyleBuilder::new() + .font(&FONT_6X10) + .text_color(Color::White) + .background_color(Color::Black) + .build(); + let center = TextStyleBuilder::new() + .alignment(Alignment::Center) + .baseline(Baseline::Top) + .build(); + Text::with_text_style("EPD V4 RUST", Point::new(61, 6), white_text, center) + .draw(&mut display)?; + + // --- Horizontal divider --- + Line::new(Point::new(0, 21), Point::new(121, 21)) + .into_styled(stroke) + .draw(&mut display)?; + + // --- Outline rectangle --- + Rectangle::new(Point::new(0, 25), Size::new(51, 51)) + .into_styled(stroke) + .draw(&mut display)?; + + // --- Diagonal lines through outline rectangle --- + Line::new(Point::new(0, 25), Point::new(50, 75)) + .into_styled(stroke) + .draw(&mut display)?; + Line::new(Point::new(50, 25), Point::new(0, 75)) + .into_styled(stroke) + .draw(&mut display)?; + + // --- Filled rectangle --- + Rectangle::new(Point::new(55, 25), Size::new(51, 51)) + .into_styled(fill_black) + .draw(&mut display)?; + + // --- Filled circle --- + Circle::new(Point::new(5, 80), 40) + .into_styled(fill_black) + .draw(&mut display)?; + + // --- Outline circle --- + Circle::new(Point::new(55, 80), 40) + .into_styled(stroke) + .draw(&mut display)?; + + // --- Text rows --- + let black_text = MonoTextStyleBuilder::new() + .font(&FONT_6X10) + .text_color(Color::Black) + .background_color(Color::White) + .build(); + + Text::with_baseline("SSD1680 OK", Point::new(2, 130), black_text, Baseline::Top) + .draw(&mut display)?; + Text::with_baseline("122x250px", Point::new(2, 145), black_text, Baseline::Top) + .draw(&mut display)?; + Text::with_baseline("Driver test", Point::new(2, 160), black_text, Baseline::Top) + .draw(&mut display)?; + + // Buffer summary + let buf = display.buffer(); + let non_white = buf.iter().filter(|&&b| b != 0xFF).count(); + println!("Buffer: {} bytes, {} non-white", buf.len(), non_white); + + epd.update_frame(&mut spi, buf, &mut delay)?; + epd.display_frame(&mut spi, &mut delay)?; + + // No sleep — PWR stays high, image persists + println!("Done. Waiting 10s..."); + delay.delay_ms(10_000); + + 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/src/epd2in13_v4/command.rs b/src/epd2in13_v4/command.rs new file mode 100644 index 00000000..d106f2f8 --- /dev/null +++ b/src/epd2in13_v4/command.rs @@ -0,0 +1,50 @@ +//! SPI Commands for the Waveshare 2.13" V4 (SSD1680) + +use crate::traits; + +/// EPD 2.13" V4 commands +#[allow(dead_code)] +#[derive(Copy, Clone)] +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..f491b91e --- /dev/null +++ b/src/epd2in13_v4/mod.rs @@ -0,0 +1,573 @@ +//! 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) + +/// 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> { + 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> { + 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> { + 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> { + 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 + self.interface.reset(delay, 0, 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> { + 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 with the background color. + 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, + )?; + + self.turn_on_display(spi, delay) + } + + /// Enter deep sleep mode. Drives PWR_PIN LOW if present. + 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])?; + delay.delay_us(2_000_000); + // Drive power pin LOW after deep sleep (V4 module_exit behavior) + let _ = self.pwr_pin.set_low(); + Ok(()) + } + + /// 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(); + + // HW reset: HIGH 20ms -> LOW 2ms -> HIGH 20ms + 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])?; + delay.delay_us(2_000_000); + // Drive power pin LOW after deep sleep (V4 module_exit behavior) + let _ = self.pwr_pin.set_low(); + Ok(()) + } + + fn update_frame( + &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)?; + 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, + )?; + + self.turn_on_display(spi, delay) + } + + 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/lib.rs b/src/lib.rs index a478e88d..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; From 9f332b4e6897bc846f91f9fe92c5a4070f3417e3 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 19:20:21 -0400 Subject: [PATCH 04/27] feat: add epd2in13_v4 (SSD1680), fix epd3in52 bugs, add soft_reset MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit epd2in13_v4 (new): - Full SSD1680 driver with optional PWR pin (GPIO18) support - Full, fast, and partial refresh modes - Correct sleep() — does not drive PWR low (use power_off() instead) - Tested on Raspberry Pi Zero 2W with physical hardware - Status display example with sysinfo, PiSugar socket, systemd timer epd3in52 (bug fixes found during review): - Fix clear_frame() sending 8x too many bytes (WIDTH*HEIGHT → buffer_len) - Fix refresh command: 0x12 → 0x17 + 0xA5 matching Python reference - Rename EPD3in52 → Epd3in52 (naming convention) interface: - Add soft_reset() — RST LOW → delay → RST HIGH, no trailing 200ms - Used by epd2in13_v4 display_partial() for correct partial refresh Closes #207 --- Cargo.toml | 5 + examples/epd2in13_v4_status.rs | 422 +++++++++++++++++++++++++++++++++ scripts/README.md | 80 +++++++ scripts/epd-status.service | 14 ++ scripts/epd-status.timer | 12 + scripts/install_epd_status.sh | 39 +++ src/epd2in13_v4/mod.rs | 61 +++-- src/epd3in52/command.rs | 4 +- src/epd3in52/mod.rs | 20 +- src/interface.rs | 11 + 10 files changed, 645 insertions(+), 23 deletions(-) create mode 100644 examples/epd2in13_v4_status.rs create mode 100644 scripts/README.md create mode 100644 scripts/epd-status.service create mode 100644 scripts/epd-status.timer create mode 100755 scripts/install_epd_status.sh diff --git a/Cargo.toml b/Cargo.toml index 9b552ea1..8b9b6cfd 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,6 +23,7 @@ embedded-graphics = "0.8" embedded-hal-mock = { version = "0.11", default-features = false, features = [ "eh1", ] } +sysinfo = "0.33" [target.'cfg(unix)'.dev-dependencies] linux-embedded-hal = "0.4.0" @@ -51,6 +52,10 @@ required-features = ["linux-dev"] name = "epd2in13_v4_raw" required-features = ["linux-dev"] +[[example]] +name = "epd2in13_v4_status" +required-features = ["linux-dev"] + [[example]] name = "epd4in2" required-features = ["linux-dev"] diff --git a/examples/epd2in13_v4_status.rs b/examples/epd2in13_v4_status.rs new file mode 100644 index 00000000..bc08649a --- /dev/null +++ b/examples/epd2in13_v4_status.rs @@ -0,0 +1,422 @@ +//! System status display for Pi Zero 2W on Waveshare 2.13" V4 (SSD1680). +//! Reads real system data via sysinfo + pisugar socket and renders to e-paper. +//! +//! Uses partial refresh on subsequent runs to minimize e-paper wear. +//! State file `/tmp/epd_status_initialized` tracks whether a full refresh +//! has been performed since boot. + +use embedded_graphics::{ + mono_font::{ascii::FONT_6X10, MonoTextStyleBuilder}, + prelude::*, + primitives::{Line, PrimitiveStyle, Rectangle}, + text::{Alignment, Baseline, Text, TextStyleBuilder}, +}; +use epd_waveshare::{ + epd2in13_v4::{Display2in13, Epd2in13}, + 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}; +use sysinfo::{Components, Disks, System}; + +const STATE_FILE: &str = "/tmp/epd_status_initialized"; + +// ---- Data collection ---- + +struct StatusData { + hostname: String, + ip: String, + datetime: String, + uptime: String, + cpu: String, + memory: String, + disk: String, + battery: String, + voltage: String, +} + +impl StatusData { + fn collect() -> Self { + let mut sys = System::new_all(); + std::thread::sleep(Duration::from_millis(200)); + sys.refresh_cpu_usage(); + + let (battery, voltage) = Self::read_battery(); + + StatusData { + hostname: Self::read_hostname(), + ip: Self::read_ip(), + datetime: Self::read_datetime(), + uptime: Self::read_uptime(), + cpu: Self::read_cpu(&sys), + memory: Self::read_memory(&sys), + disk: Self::read_disk(), + battery, + voltage, + } + } + + fn read_hostname() -> String { + System::host_name() + .unwrap_or_else(|| "unknown".into()) + .to_uppercase() + } + + fn read_ip() -> String { + 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 = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + 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 { + let total = System::uptime(); + 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) + } + } + + fn read_cpu(sys: &System) -> String { + let usage = sys.global_cpu_usage(); + let temp = Components::new_with_refreshed_list() + .iter() + .find(|c| c.label().contains("cpu") || c.label().contains("thermal")) + .and_then(|c| c.temperature()) + .map(|t| format!("{:.1}C", t)) + .unwrap_or_else(|| { + 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 / 1000.0)) + .unwrap_or_else(|| "??C".into()) + }); + format!("CPU: {:.0}% {}", usage, temp) + } + + fn read_memory(sys: &System) -> String { + let used_mb = sys.used_memory() / (1024 * 1024); + let total_mb = sys.total_memory() / (1024 * 1024); + format!("RAM: {}/{}MB", used_mb, total_mb) + } + + fn read_disk() -> String { + let disks = Disks::new_with_refreshed_list(); + for disk in disks.list() { + if disk.mount_point() == Path::new("/") { + let total = disk.total_space() as f64 / 1_073_741_824.0; + let used = (disk.total_space() - disk.available_space()) as f64 / 1_073_741_824.0; + return format!("DSK: {:.1}/{:.1}GB", used, total); + } + } + "DSK: ??".into() + } + + 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) -> String { + format!( + "{} | {} | {} | {} | {} | {} | {} | {}", + 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()?; + write!(stream, "{}\n", 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.split(':').nth(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 +} + +// ---- Rendering ---- + +fn render(display: &mut Display2in13, data: &StatusData) { + let stroke = PrimitiveStyle::with_stroke(Color::Black, 1); + 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(122, 13)) + .into_styled(fill_black) + .draw(display) + .ok(); + Text::with_baseline( + &data.hostname, + Point::new(2, 2), + white_on_black, + Baseline::Top, + ) + .draw(display) + .ok(); + Text::with_text_style(&data.ip, Point::new(120, 2), white_on_black, right_align) + .draw(display) + .ok(); + + let mut y = 14; + + // Divider + Line::new(Point::new(0, y), Point::new(121, y)) + .into_styled(stroke) + .draw(display) + .ok(); + y += 2; + + // Date/time + Text::with_baseline( + &data.datetime, + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display) + .ok(); + y += 14; + + // Uptime + Text::with_baseline( + &data.uptime, + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display) + .ok(); + y += 14; + + // Divider + Line::new(Point::new(0, y), Point::new(121, y)) + .into_styled(stroke) + .draw(display) + .ok(); + y += 2; + + // CPU + Text::with_baseline(&data.cpu, Point::new(2, y), black_on_white, Baseline::Top) + .draw(display) + .ok(); + y += 14; + + // RAM + Text::with_baseline( + &data.memory, + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display) + .ok(); + y += 14; + + // Disk + Text::with_baseline(&data.disk, Point::new(2, y), black_on_white, Baseline::Top) + .draw(display) + .ok(); + y += 14; + + // Divider + Line::new(Point::new(0, y), Point::new(121, y)) + .into_styled(stroke) + .draw(display) + .ok(); + y += 2; + + // Battery + Text::with_baseline( + &data.battery, + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display) + .ok(); + y += 14; + + // Voltage + Text::with_baseline( + &data.voltage, + Point::new(2, y), + black_on_white, + Baseline::Top, + ) + .draw(display) + .ok(); +} + +// ---- Main ---- + +fn main() -> Result<(), Box> { + let data = StatusData::collect(); + + // EPD setup + let mut spi = SpidevDevice::open("/dev/spidev0.0")?; + let options = SpidevOptions::new() + .bits_per_word(8) + .max_speed_hz(4_000_000) + .mode(spidev::SpiModeFlags::SPI_MODE_0) + .build(); + spi.configure(&options)?; + + let mut chip = Chip::new("/dev/gpiochip0")?; + let busy = CdevPin::new( + chip.get_line(24)? + .request(LineRequestFlags::INPUT, 0, "epd-busy")?, + )?; + let dc = CdevPin::new( + chip.get_line(25)? + .request(LineRequestFlags::OUTPUT, 0, "epd-dc")?, + )?; + let rst = CdevPin::new( + chip.get_line(17)? + .request(LineRequestFlags::OUTPUT, 1, "epd-rst")?, + )?; + let pwr = CdevPin::new( + chip.get_line(18)? + .request(LineRequestFlags::OUTPUT, 0, "epd-pwr")?, + )?; + + let mut delay = Delay; + let mut epd = Epd2in13::new_with_pwr(&mut spi, busy, dc, rst, &mut delay, None, pwr)?; + + // Render to framebuffer + let mut display = Display2in13::default(); + display.clear(Color::White).ok(); + render(&mut display, &data); + + let buf = display.buffer(); + let initialized = Path::new(STATE_FILE).exists(); + + if !initialized { + // First run since boot — full refresh establishes clean base image + epd.update_frame(&mut spi, buf, &mut delay)?; + epd.display_frame(&mut spi, &mut delay)?; + std::fs::write(STATE_FILE, "")?; + println!("full | {}", data.summary()); + } else { + // Subsequent runs — partial refresh minimizes wear + epd.display_part_base_image(&mut spi, buf, &mut delay)?; + epd.display_partial(&mut spi, buf, &mut delay)?; + println!("partial | {}", data.summary()); + } + + Ok(()) +} diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 00000000..d37a4426 --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,80 @@ +# 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 the binary to `/usr/local/bin/`, copies the systemd units, and enables the timer. + +## 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 +sudo rm /etc/systemd/system/epd-status.{service,timer} +sudo rm /usr/local/bin/epd2in13_v4_status +sudo systemctl daemon-reload +``` diff --git a/scripts/epd-status.service b/scripts/epd-status.service new file mode 100644 index 00000000..0ea3a6bb --- /dev/null +++ b/scripts/epd-status.service @@ -0,0 +1,14 @@ +[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 +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/install_epd_status.sh b/scripts/install_epd_status.sh new file mode 100755 index 00000000..2ce812ec --- /dev/null +++ b/scripts/install_epd_status.sh @@ -0,0 +1,39 @@ +#!/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 " 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 + +echo " Reloading systemd daemon" +systemctl daemon-reload + +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/src/epd2in13_v4/mod.rs b/src/epd2in13_v4/mod.rs index f491b91e..672849ab 100644 --- a/src/epd2in13_v4/mod.rs +++ b/src/epd2in13_v4/mod.rs @@ -4,6 +4,26 @@ //! //! - [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()`). /// Width of the display in pixels pub const WIDTH: u32 = 122; @@ -248,8 +268,10 @@ where buffer: &[u8], delay: &mut DELAY, ) -> Result<(), SPI::Error> { - // Soft reset: RST LOW 1ms then HIGH - self.interface.reset(delay, 0, 1_000); + // 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])?; @@ -276,6 +298,7 @@ where 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)?; @@ -298,8 +321,11 @@ where self.display_frame(spi, delay) } - /// Clear the display with the background color. - pub fn clear_frame(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + /// 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(); @@ -311,20 +337,29 @@ where buffer_len(WIDTH as usize, HEIGHT as usize) as u32, )?; - self.turn_on_display(spi, delay) + Ok(()) } - /// Enter deep sleep mode. Drives PWR_PIN LOW if present. + /// 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])?; - delay.delay_us(2_000_000); - // Drive power pin LOW after deep sleep (V4 module_exit behavior) - let _ = self.pwr_pin.set_low(); 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, @@ -463,9 +498,6 @@ where self.wait_until_idle(spi, delay)?; self.interface .cmd_with_data(spi, Command::DeepSleepMode, &[0x01])?; - delay.delay_us(2_000_000); - // Drive power pin LOW after deep sleep (V4 module_exit behavior) - let _ = self.pwr_pin.set_low(); Ok(()) } @@ -475,6 +507,7 @@ where 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)?; @@ -512,7 +545,7 @@ where self.display_frame(spi, delay) } - fn clear_frame(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { + 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(); @@ -524,7 +557,7 @@ where buffer_len(WIDTH as usize, HEIGHT as usize) as u32, )?; - self.turn_on_display(spi, delay) + Ok(()) } fn set_background_color(&mut self, background_color: Color) { diff --git a/src/epd3in52/command.rs b/src/epd3in52/command.rs index b9f6ac57..77064140 100644 --- a/src/epd3in52/command.rs +++ b/src/epd3in52/command.rs @@ -15,7 +15,7 @@ pub(crate) enum Command { PowerSetting = 0x01, BoosterSoftStart = 0x06, DataStartTransmission = 0x13, - Refresh = 0x12, + Refresh = 0x17, LutVcom = 0x20, LutBlue = 0x21, LutWhite = 0x22, @@ -48,7 +48,7 @@ mod tests { assert_eq!(Command::PowerSetting.address(), 0x01); assert_eq!(Command::BoosterSoftStart.address(), 0x06); assert_eq!(Command::DataStartTransmission.address(), 0x13); - assert_eq!(Command::Refresh.address(), 0x12); + assert_eq!(Command::Refresh.address(), 0x17); assert_eq!(Command::LutVcom.address(), 0x20); assert_eq!(Command::LutBlue.address(), 0x21); assert_eq!(Command::LutWhite.address(), 0x22); diff --git a/src/epd3in52/mod.rs b/src/epd3in52/mod.rs index f7ded5f3..e0488102 100644 --- a/src/epd3in52/mod.rs +++ b/src/epd3in52/mod.rs @@ -43,8 +43,8 @@ pub type Display3in52 = crate::graphics::Display< Color, >; -/// EPD3in52 driver -pub struct EPD3in52 { +/// Epd3in52 driver +pub struct Epd3in52 { /// Connection Interface interface: DisplayInterface, /// Background Color @@ -54,7 +54,7 @@ pub struct EPD3in52 { } impl InternalWiAdditions - for EPD3in52 + for Epd3in52 where SPI: SpiDevice, BUSY: InputPin, @@ -95,7 +95,7 @@ where } impl WaveshareDisplay - for EPD3in52 + for Epd3in52 where SPI: SpiDevice, BUSY: InputPin, @@ -113,7 +113,7 @@ where delay: &mut DELAY, delay_us: Option, ) -> Result { - let mut epd = EPD3in52 { + let mut epd = Epd3in52 { interface: DisplayInterface::new(busy, dc, rst, delay_us), background_color: DEFAULT_BACKGROUND_COLOR, lut_flag: false, @@ -196,8 +196,10 @@ where self.lut_flag = !self.lut_flag; - self.interface.cmd(spi, Command::Refresh)?; + self.interface + .cmd_with_data(spi, Command::Refresh, &[0xA5])?; self.interface.wait_until_idle(delay, IS_BUSY_LOW); + delay.delay_us(200_000); Ok(()) } @@ -215,7 +217,11 @@ where 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, WIDTH * HEIGHT)?; + self.interface.data_x_times( + spi, + color, + buffer_len(WIDTH as usize, HEIGHT as usize) as u32, + )?; Ok(()) } diff --git a/src/interface.rs b/src/interface.rs index f3a9a3d7..4351daac 100644 --- a/src/interface.rs +++ b/src/interface.rs @@ -205,4 +205,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(); + } } From e1835c5cd776239c09accc723a75caf526ce5e16 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 19:30:12 -0400 Subject: [PATCH 05/27] fix: implement three-state partial refresh for epd2in13_v4_status Run 1: full refresh (establishes clean display state) Run 2: display_part_base_image (writes both RAM banks, one full refresh) Run 3+: display_partial only (true partial waveform, no color inversion) State tracked via /tmp files cleared on reboot. Eliminates 4-5 cycle full waveform on every status update. Confirmed working on Pi Zero 2W with Waveshare 2.13 V4 hardware. --- examples/epd2in13_v4_status.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/examples/epd2in13_v4_status.rs b/examples/epd2in13_v4_status.rs index bc08649a..c03a0258 100644 --- a/examples/epd2in13_v4_status.rs +++ b/examples/epd2in13_v4_status.rs @@ -2,8 +2,12 @@ //! Reads real system data via sysinfo + pisugar socket and renders to e-paper. //! //! Uses partial refresh on subsequent runs to minimize e-paper wear. -//! State file `/tmp/epd_status_initialized` tracks whether a full refresh -//! has been performed since boot. +//! 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) use embedded_graphics::{ mono_font::{ascii::FONT_6X10, MonoTextStyleBuilder}, @@ -27,6 +31,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use sysinfo::{Components, Disks, System}; const STATE_FILE: &str = "/tmp/epd_status_initialized"; +const BASE_FILE: &str = "/tmp/epd_status_base_set"; // ---- Data collection ---- @@ -404,16 +409,21 @@ fn main() -> Result<(), Box> { let buf = display.buffer(); let initialized = Path::new(STATE_FILE).exists(); + let base_set = Path::new(BASE_FILE).exists(); if !initialized { - // First run since boot — full refresh establishes clean base image + // First run since boot — full refresh epd.update_frame(&mut spi, buf, &mut delay)?; epd.display_frame(&mut spi, &mut delay)?; std::fs::write(STATE_FILE, "")?; println!("full | {}", data.summary()); - } else { - // Subsequent runs — partial refresh minimizes wear + } 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, "")?; + println!("base | {}", data.summary()); + } else { + // All subsequent runs — true partial refresh only epd.display_partial(&mut spi, buf, &mut delay)?; println!("partial | {}", data.summary()); } From d0fd6529c506c1876d0d4aafd091e5f1830143cd Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 19:41:23 -0400 Subject: [PATCH 06/27] fix: move state files to /var/lib/epd-status for persistence across reboots State files in /tmp were cleared on reboot causing full refresh cycle to repeat unnecessarily. /var/lib/epd-status persists across reboots so partial refresh is maintained permanently after initial 3-fire sequence. - STATE_FILE: /var/lib/epd-status/initialized - BASE_FILE: /var/lib/epd-status/base_set - create_dir_all() ensures directory exists when run manually - StateDirectory=epd-status in service file for systemd management - install script creates directory with correct permissions --- examples/epd2in13_v4_status.rs | 7 +++++-- scripts/epd-status.service | 1 + scripts/install_epd_status.sh | 4 ++++ 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/examples/epd2in13_v4_status.rs b/examples/epd2in13_v4_status.rs index c03a0258..75e4665e 100644 --- a/examples/epd2in13_v4_status.rs +++ b/examples/epd2in13_v4_status.rs @@ -30,8 +30,9 @@ use std::path::Path; use std::time::{Duration, SystemTime, UNIX_EPOCH}; use sysinfo::{Components, Disks, System}; -const STATE_FILE: &str = "/tmp/epd_status_initialized"; -const BASE_FILE: &str = "/tmp/epd_status_base_set"; +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"; // ---- Data collection ---- @@ -370,6 +371,8 @@ fn render(display: &mut Display2in13, data: &StatusData) { // ---- Main ---- fn main() -> Result<(), Box> { + std::fs::create_dir_all(STATE_DIR)?; + let data = StatusData::collect(); // EPD setup diff --git a/scripts/epd-status.service b/scripts/epd-status.service index 0ea3a6bb..2519963a 100644 --- a/scripts/epd-status.service +++ b/scripts/epd-status.service @@ -7,6 +7,7 @@ Wants=network-online.target Type=oneshot ExecStart=/usr/local/bin/epd2in13_v4_status User=root +StateDirectory=epd-status StandardOutput=journal StandardError=journal diff --git a/scripts/install_epd_status.sh b/scripts/install_epd_status.sh index 2ce812ec..5cc255f8 100755 --- a/scripts/install_epd_status.sh +++ b/scripts/install_epd_status.sh @@ -13,6 +13,10 @@ 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 From 9b200ffb92e5e41265eac853b51bc3cfd071e9e0 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 20:54:12 -0400 Subject: [PATCH 07/27] feat: add epd3in52_status example (UC8253, Waveshare 3.52") - Landscape orientation (Rotate90), power port on top - Stats from /proc and /sys: hostname, IP, temp, RAM, uptime - Single display_frame() call matches Python lut_GC()+refresh() sequence - lut_flag inversion bug documented: do not call display_frame() twice per cycle - Verified on hardware: colors correct, orientation correct --- examples/epd3in52_ruby_status.rs | 554 +++++++++++++++++++++++++++++++ 1 file changed, 554 insertions(+) create mode 100644 examples/epd3in52_ruby_status.rs diff --git a/examples/epd3in52_ruby_status.rs b/examples/epd3in52_ruby_status.rs new file mode 100644 index 00000000..d36b7925 --- /dev/null +++ b/examples/epd3in52_ruby_status.rs @@ -0,0 +1,554 @@ +//! epd3in52_ruby_status — Waveshare 3.52" e-paper status display +//! +//! Target: ruby (Pi 5 8GB, 192.168.10.29) +//! 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_ruby_status \ +//! --target aarch64-unknown-linux-gnu --release +//! scp target/aarch64-unknown-linux-gnu/release/examples/epd3in52_ruby_status \ +//! ruby:~/ +//! ssh ruby "sudo ./epd3in52_ruby_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}, + 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::time::{Duration, SystemTime, UNIX_EPOCH}; + +// -- Set true to send a raw half-white/half-black test pattern ---------------- +const TEST_PATTERN: bool = false; + +// -- GPIO pin numbers (BCM, verified from epdconfig.py on ruby) --------------- +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, + } + } +} + +fn main() -> Result<(), Box> { + println!("epd3in52_ruby_status -- Waveshare 3.52\" on ruby"); + + // -- Compile-time and runtime invariant checks ---------------------------- + const EXPECTED_BUF_LEN: usize = 240 / 8 * 360; + assert_eq!(EXPECTED_BUF_LEN, 10800); + + // -- Collect system stats (CPU read takes ~500ms) ------------------------- + let data = StatusData::collect(); + + // -- SPI setup (SpidevDevice, not Spidev) --------------------------------- + let mut spi = SpidevDevice::open(SPI_DEVICE)?; + let options = SpidevOptions::new() + .bits_per_word(8) + .max_speed_hz(SPI_SPEED_HZ) + .mode(spidev::SpiModeFlags::SPI_MODE_0) + .build(); + spi.configure(&options)?; + + // -- GPIO setup (gpio_cdev on Pi 5) --------------------------------------- + let mut chip = Chip::new("/dev/gpiochip0")?; + + let busy = CdevPin::new(chip.get_line(PIN_BUSY)?.request( + LineRequestFlags::INPUT, + 0, + "epd3in52-busy", + )?)?; + let dc = CdevPin::new(chip.get_line(PIN_DC)?.request( + LineRequestFlags::OUTPUT, + 0, + "epd3in52-dc", + )?)?; + let rst = CdevPin::new(chip.get_line(PIN_RST)?.request( + LineRequestFlags::OUTPUT, + 1, + "epd3in52-rst", + )?)?; + + 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)?; + + // -- 2. Clear display RAM (no refresh!) ----------------------------------- + println!("Clearing display RAM..."); + epd.clear_frame(&mut spi, &mut delay)?; + + 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 (landscape: 360w x 240h) ------------------- + println!("Rendering..."); + let mut display = Display3in52::default(); + display.set_rotation(DisplayRotation::Rotate90); + + 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)?; + + println!("Sending frame..."); + epd.update_frame(&mut spi, display.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. Temp {}°C CPU {} RAM {}/{}MB Disk {} Batt {} Up {}", + data.timestamp, + temp_str, + cpu_str, + data.used_mb, + data.total_mb, + disk_str, + batt_str, + data.uptime + ); + + Ok(()) +} + +// -- Frame content (landscape: 360 wide x 240 tall) --------------------------- +fn draw_status( + display: &mut Display3in52, + data: &StatusData, +) -> 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(360, 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)?; + // 20 chars × 8px = 160px, starting at x=192 → ends at x=352 + let ts_short = format!("{} UTC", &data.timestamp[..16]); + Text::with_baseline(&ts_short, Point::new(192, 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 = 164u32; + 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; + UdpSocket::bind("0.0.0.0:0") + .and_then(|s| { + s.connect("8.8.8.8:80")?; + s.local_addr() + }) + .map(|a| a.ip().to_string()) + .unwrap_or_else(|_| "no network".to_string()) +} + +fn format_timestamp() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|d| d.as_secs()) + .unwrap_or(0); + 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 - 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.split(':').nth(1)?.trim().parse().ok() +} From 4065c40ec8da3a2f3e04b5578f0a3612b750fe31 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 21:36:35 -0400 Subject: [PATCH 08/27] refactor: replace sysinfo with /proc+/sys reads in both examples - epd2in13_v4_status: remove sysinfo crate, read from /proc/stat, /proc/uptime, /proc/meminfo, /sys/class/thermal, /etc/hostname, df - epd3in52_ruby_status: already uses /proc+/sys (no change) - Remove sysinfo = 0.33 from dev-dependencies in Cargo.toml - Binary size: epd2in13_v4_status 1.1MB -> 772K (30% reduction) - Output format unchanged, partial refresh logic unchanged --- Cargo.toml | 1 - examples/epd2in13_v4_status.rs | 158 ++++++++++++++++++++++++--------- 2 files changed, 114 insertions(+), 45 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 8b9b6cfd..82f70fd2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,6 @@ embedded-graphics = "0.8" embedded-hal-mock = { version = "0.11", default-features = false, features = [ "eh1", ] } -sysinfo = "0.33" [target.'cfg(unix)'.dev-dependencies] linux-embedded-hal = "0.4.0" diff --git a/examples/epd2in13_v4_status.rs b/examples/epd2in13_v4_status.rs index 75e4665e..1854ad2d 100644 --- a/examples/epd2in13_v4_status.rs +++ b/examples/epd2in13_v4_status.rs @@ -28,7 +28,6 @@ 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}; -use sysinfo::{Components, Disks, System}; const STATE_DIR: &str = "/var/lib/epd-status"; const STATE_FILE: &str = "/var/lib/epd-status/initialized"; @@ -50,10 +49,7 @@ struct StatusData { impl StatusData { fn collect() -> Self { - let mut sys = System::new_all(); - std::thread::sleep(Duration::from_millis(200)); - sys.refresh_cpu_usage(); - + let cpu = Self::read_cpu(); let (battery, voltage) = Self::read_battery(); StatusData { @@ -61,8 +57,8 @@ impl StatusData { ip: Self::read_ip(), datetime: Self::read_datetime(), uptime: Self::read_uptime(), - cpu: Self::read_cpu(&sys), - memory: Self::read_memory(&sys), + cpu, + memory: Self::read_memory(), disk: Self::read_disk(), battery, voltage, @@ -70,8 +66,9 @@ impl StatusData { } fn read_hostname() -> String { - System::host_name() - .unwrap_or_else(|| "unknown".into()) + std::fs::read_to_string("/etc/hostname") + .unwrap_or_else(|_| "unknown".into()) + .trim() .to_uppercase() } @@ -103,50 +100,84 @@ impl StatusData { } fn read_uptime() -> String { - let total = System::uptime(); - 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) - } + 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(sys: &System) -> String { - let usage = sys.global_cpu_usage(); - let temp = Components::new_with_refreshed_list() - .iter() - .find(|c| c.label().contains("cpu") || c.label().contains("thermal")) - .and_then(|c| c.temperature()) - .map(|t| format!("{:.1}C", t)) - .unwrap_or_else(|| { - 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 / 1000.0)) - .unwrap_or_else(|| "??C".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(sys: &System) -> String { - let used_mb = sys.used_memory() / (1024 * 1024); - let total_mb = sys.total_memory() / (1024 * 1024); + 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 disks = Disks::new_with_refreshed_list(); - for disk in disks.list() { - if disk.mount_point() == Path::new("/") { - let total = disk.total_space() as f64 / 1_073_741_824.0; - let used = (disk.total_space() - disk.available_space()) as f64 / 1_073_741_824.0; - return format!("DSK: {:.1}/{:.1}GB", used, total); - } + 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(); } - "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) { @@ -189,7 +220,7 @@ impl StatusData { 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()?; - write!(stream, "{}\n", cmd).ok()?; + writeln!(stream, "{}", cmd).ok()?; let mut reader = BufReader::new(stream); let mut response = String::new(); reader.read_line(&mut response).ok()?; @@ -240,6 +271,45 @@ 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 - di) / dt) as u32) +} + // ---- Rendering ---- fn render(display: &mut Display2in13, data: &StatusData) { From f440a33c72307b05f77d760ba699e74efbd15fd1 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 21:43:10 -0400 Subject: [PATCH 09/27] feat: implement RefreshLut::Quick (DU) for epd3in52 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add refresh_lut field to Epd3in52 struct, default RefreshLut::Full - set_lut() now stores requested variant instead of no-op - display_frame() selects GC or DU LUT tables via match - GC path byte-for-byte identical to previous implementation - DU path documented with Waveshare warning: not recommended - Remove dead_code allows from DU constants (now referenced) - Add lut_selection_default_is_full test Note: DU is full-screen fast refresh with shorter waveform — not true partial update. UC8253 has no hardware windowing. Use GC for normal operation. --- src/epd3in52/constants.rs | 5 --- src/epd3in52/mod.rs | 80 +++++++++++++++++++++++++++++---------- 2 files changed, 61 insertions(+), 24 deletions(-) diff --git a/src/epd3in52/constants.rs b/src/epd3in52/constants.rs index 6b7fe347..6e733fc4 100644 --- a/src/epd3in52/constants.rs +++ b/src/epd3in52/constants.rs @@ -37,7 +37,6 @@ pub(crate) const LUT_R24_GC: [u8; 42] = [ // --- Differential Update (DU) LUTs --- -#[allow(dead_code)] 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, @@ -45,14 +44,12 @@ pub(crate) const LUT_R20_DU: [u8; 56] = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; -#[allow(dead_code)] 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, ]; -#[allow(dead_code)] 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, @@ -60,7 +57,6 @@ pub(crate) const LUT_R22_DU: [u8; 56] = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; -#[allow(dead_code)] 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, @@ -68,7 +64,6 @@ pub(crate) const LUT_R23_DU: [u8; 56] = [ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, ]; -#[allow(dead_code)] 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, diff --git a/src/epd3in52/mod.rs b/src/epd3in52/mod.rs index e0488102..43bee3e6 100644 --- a/src/epd3in52/mod.rs +++ b/src/epd3in52/mod.rs @@ -51,6 +51,8 @@ pub struct Epd3in52 { 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 @@ -89,6 +91,7 @@ where .cmd_with_data(spi, Command::VcomDataSetting, &[0xB7])?; self.lut_flag = false; + self.refresh_lut = RefreshLut::Full; Ok(()) } @@ -117,6 +120,7 @@ where interface: DisplayInterface::new(busy, dc, rst, delay_us), background_color: DEFAULT_BACKGROUND_COLOR, lut_flag: false, + refresh_lut: RefreshLut::Full, }; epd.init(spi, delay)?; @@ -175,23 +179,52 @@ where } fn display_frame(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { - 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])?; + 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; @@ -229,9 +262,11 @@ where &mut self, _spi: &mut SPI, _delay: &mut DELAY, - _refresh_rate: Option, + refresh_rate: Option, ) -> Result<(), SPI::Error> { - // LUTs are sent during display_frame with alternating waveform tables + if let Some(lut) = refresh_rate { + self.refresh_lut = lut; + } Ok(()) } @@ -251,4 +286,11 @@ mod tests { 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)); + } } From d428a9694c8b3a4405152506822cbbc2306437be Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 21:45:27 -0400 Subject: [PATCH 10/27] fix: resolve broken intra-doc links in epd2in13_v4/mod.rs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Convert [], [], [], [] from intra-doc links to plain backtick references — these are trait methods and rustdoc cannot resolve them without full qualified paths. Fixes CI doc build failure on PR #255. --- src/epd2in13_v4/mod.rs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/epd2in13_v4/mod.rs b/src/epd2in13_v4/mod.rs index 672849ab..f1f8032d 100644 --- a/src/epd2in13_v4/mod.rs +++ b/src/epd2in13_v4/mod.rs @@ -323,7 +323,7 @@ where /// Clear the display RAM with the background color. /// - /// This only writes to RAM. Call [`display_frame`] afterwards to + /// 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)?; @@ -342,8 +342,8 @@ where /// 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. + /// 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 @@ -353,8 +353,8 @@ where /// 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 + /// 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(); From 669f98c1d43de58f2952f9ba70eeb1fe61a3dacb Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 21:56:18 -0400 Subject: [PATCH 11/27] fix: remove dividers, add refresh mode display to epd2in13_v4_status - Remove three horizontal divider lines, tighten spacing to y+=12 - Add refresh_mode field to StatusData, read from state files - Display as RFR: full/base/partial on screen after voltage line - Add refresh mode to stdout summary line - Hardware verified: full->base->partial sequence confirmed on Waveshare 2.13" EPD V4 --- examples/epd2in13_v4_status.rs | 62 +++++++++++++++++----------------- 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/examples/epd2in13_v4_status.rs b/examples/epd2in13_v4_status.rs index 1854ad2d..0c1a77a8 100644 --- a/examples/epd2in13_v4_status.rs +++ b/examples/epd2in13_v4_status.rs @@ -12,7 +12,7 @@ use embedded_graphics::{ mono_font::{ascii::FONT_6X10, MonoTextStyleBuilder}, prelude::*, - primitives::{Line, PrimitiveStyle, Rectangle}, + primitives::{PrimitiveStyle, Rectangle}, text::{Alignment, Baseline, Text, TextStyleBuilder}, }; use epd_waveshare::{ @@ -45,6 +45,7 @@ struct StatusData { disk: String, battery: String, voltage: String, + refresh_mode: String, } impl StatusData { @@ -52,6 +53,14 @@ impl StatusData { 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(), @@ -62,6 +71,7 @@ impl StatusData { disk: Self::read_disk(), battery, voltage, + refresh_mode, } } @@ -204,7 +214,8 @@ impl StatusData { fn summary(&self) -> String { format!( - "{} | {} | {} | {} | {} | {} | {} | {}", + "{} | {} | {} | {} | {} | {} | {} | {} | {}", + self.refresh_mode, self.hostname, self.ip, self.datetime, @@ -313,7 +324,6 @@ fn read_cpu_percent() -> Option { // ---- Rendering ---- fn render(display: &mut Display2in13, data: &StatusData) { - let stroke = PrimitiveStyle::with_stroke(Color::Black, 1); let fill_black = PrimitiveStyle::with_fill(Color::Black); let white_on_black = MonoTextStyleBuilder::new() @@ -348,14 +358,7 @@ fn render(display: &mut Display2in13, data: &StatusData) { .draw(display) .ok(); - let mut y = 14; - - // Divider - Line::new(Point::new(0, y), Point::new(121, y)) - .into_styled(stroke) - .draw(display) - .ok(); - y += 2; + let mut y = 15; // Date/time Text::with_baseline( @@ -366,7 +369,7 @@ fn render(display: &mut Display2in13, data: &StatusData) { ) .draw(display) .ok(); - y += 14; + y += 12; // Uptime Text::with_baseline( @@ -377,20 +380,13 @@ fn render(display: &mut Display2in13, data: &StatusData) { ) .draw(display) .ok(); - y += 14; - - // Divider - Line::new(Point::new(0, y), Point::new(121, y)) - .into_styled(stroke) - .draw(display) - .ok(); - y += 2; + y += 12; // CPU Text::with_baseline(&data.cpu, Point::new(2, y), black_on_white, Baseline::Top) .draw(display) .ok(); - y += 14; + y += 12; // RAM Text::with_baseline( @@ -401,20 +397,13 @@ fn render(display: &mut Display2in13, data: &StatusData) { ) .draw(display) .ok(); - y += 14; + y += 12; // Disk Text::with_baseline(&data.disk, Point::new(2, y), black_on_white, Baseline::Top) .draw(display) .ok(); - y += 14; - - // Divider - Line::new(Point::new(0, y), Point::new(121, y)) - .into_styled(stroke) - .draw(display) - .ok(); - y += 2; + y += 12; // Battery Text::with_baseline( @@ -425,7 +414,7 @@ fn render(display: &mut Display2in13, data: &StatusData) { ) .draw(display) .ok(); - y += 14; + y += 12; // Voltage Text::with_baseline( @@ -436,6 +425,17 @@ fn render(display: &mut Display2in13, data: &StatusData) { ) .draw(display) .ok(); + 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 ---- From f3a190476a99e742f78b32b6560eda3736bcfbf6 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 21:57:38 -0400 Subject: [PATCH 12/27] fix: remove duplicate refresh mode in stdout summary line --- examples/epd2in13_v4_status.rs | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/examples/epd2in13_v4_status.rs b/examples/epd2in13_v4_status.rs index 0c1a77a8..99913088 100644 --- a/examples/epd2in13_v4_status.rs +++ b/examples/epd2in13_v4_status.rs @@ -489,16 +489,16 @@ fn main() -> Result<(), Box> { epd.update_frame(&mut spi, buf, &mut delay)?; epd.display_frame(&mut spi, &mut delay)?; std::fs::write(STATE_FILE, "")?; - println!("full | {}", data.summary()); + println!("{}", data.summary()); } 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, "")?; - println!("base | {}", data.summary()); + println!("{}", data.summary()); } else { // All subsequent runs — true partial refresh only epd.display_partial(&mut spi, buf, &mut delay)?; - println!("partial | {}", data.summary()); + println!("{}", data.summary()); } Ok(()) From 72d19923d40759d1f6156afa28c6a0c1d3b779b8 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 22:19:43 -0400 Subject: [PATCH 13/27] feat: runtime rotation config from /etc/epd-waveshare.conf - Both examples read rotation= from /etc/epd-waveshare.conf (0/90/180/270) - epd2in13_v4_status: detect rotation change, force full refresh cycle, write ROTATION_FILE to persist last-used rotation - All hardcoded widths replaced with logical_w from rotation - stdout includes rotation degrees - Hardware verified: all rotations work on Waveshare 3.52" EPD, 0/90/180 on Waveshare 2.13" EPD V4 - Note: Rotate270 renders but produces no visible change on SSD1680 hardware --- examples/epd2in13_v4_status.rs | 111 +++++++++++++++++++++++++++---- examples/epd3in52_ruby_status.rs | 73 +++++++++++++++++--- 2 files changed, 161 insertions(+), 23 deletions(-) diff --git a/examples/epd2in13_v4_status.rs b/examples/epd2in13_v4_status.rs index 99913088..fc8ce563 100644 --- a/examples/epd2in13_v4_status.rs +++ b/examples/epd2in13_v4_status.rs @@ -1,5 +1,5 @@ //! System status display for Pi Zero 2W on Waveshare 2.13" V4 (SSD1680). -//! Reads real system data via sysinfo + pisugar socket and renders to e-paper. +//! 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: @@ -8,6 +8,9 @@ //! 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}, @@ -16,7 +19,8 @@ use embedded_graphics::{ text::{Alignment, Baseline, Text, TextStyleBuilder}, }; use epd_waveshare::{ - epd2in13_v4::{Display2in13, Epd2in13}, + epd2in13_v4::{Display2in13, Epd2in13, HEIGHT, WIDTH}, + graphics::DisplayRotation, prelude::*, }; use linux_embedded_hal::{ @@ -32,6 +36,7 @@ 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 ---- @@ -212,10 +217,13 @@ impl StatusData { (bat_line, volt_line) } - fn summary(&self) -> String { + fn summary(&self, rotation_degrees: u16, reoriented: bool) -> String { + let reoriented_str = if reoriented { " | REORIENTED" } else { "" }; format!( - "{} | {} | {} | {} | {} | {} | {} | {} | {}", + "{} | ROT:{}{} | {} | {} | {} | {} | {} | {} | {} | {}", self.refresh_mode, + rotation_degrees, + reoriented_str, self.hostname, self.ip, self.datetime, @@ -321,9 +329,44 @@ fn read_cpu_percent() -> Option { Some((100 * (dt - di) / dt) as u32) } +fn parse_config() -> DisplayRotation { + if !Path::new("/etc/epd-waveshare.conf").exists() { + eprintln!( + "epd-waveshare: no config file found at /etc/epd-waveshare.conf, using defaults." + ); + eprintln!("Create it with:"); + eprintln!(" sudo tee /etc/epd-waveshare.conf << 'EOF'"); + eprintln!("# /etc/epd-waveshare.conf"); + eprintln!("rotation=0"); + eprintln!("EOF"); + } + 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('=') { + if key.trim() == "rotation" { + match value.trim() { + "0" => return DisplayRotation::Rotate0, + "90" => return DisplayRotation::Rotate90, + "180" => return DisplayRotation::Rotate180, + "270" => return DisplayRotation::Rotate270, + _ => eprintln!( + "epd-waveshare: invalid rotation value '{}', using 0", + value.trim() + ), + } + } + } + } + DisplayRotation::Rotate0 +} + // ---- Rendering ---- -fn render(display: &mut Display2in13, data: &StatusData) { +fn render(display: &mut Display2in13, data: &StatusData, w: u32, _h: u32) { let fill_black = PrimitiveStyle::with_fill(Color::Black); let white_on_black = MonoTextStyleBuilder::new() @@ -342,7 +385,7 @@ fn render(display: &mut Display2in13, data: &StatusData) { .build(); // Header bar: hostname left, IP right - Rectangle::new(Point::new(0, 0), Size::new(122, 13)) + Rectangle::new(Point::new(0, 0), Size::new(w, 13)) .into_styled(fill_black) .draw(display) .ok(); @@ -354,9 +397,14 @@ fn render(display: &mut Display2in13, data: &StatusData) { ) .draw(display) .ok(); - Text::with_text_style(&data.ip, Point::new(120, 2), white_on_black, right_align) - .draw(display) - .ok(); + Text::with_text_style( + &data.ip, + Point::new((w - 2) as i32, 2), + white_on_black, + right_align, + ) + .draw(display) + .ok(); let mut y = 15; @@ -443,6 +491,40 @@ fn render(display: &mut Display2in13, data: &StatusData) { fn main() -> Result<(), Box> { std::fs::create_dir_all(STATE_DIR)?; + let rotation = parse_config(); + let (logical_w, logical_h) = match rotation { + DisplayRotation::Rotate0 | DisplayRotation::Rotate180 => (WIDTH, HEIGHT), + DisplayRotation::Rotate90 | DisplayRotation::Rotate270 => (HEIGHT, WIDTH), + }; + let rotation_degrees: u16 = match 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 + ); + let _ = std::fs::remove_file(STATE_FILE); + let _ = std::fs::remove_file(BASE_FILE); + true + } else { + false + } + } else { + false + }; + let data = StatusData::collect(); // EPD setup @@ -477,8 +559,9 @@ fn main() -> Result<(), Box> { // Render to framebuffer let mut display = Display2in13::default(); + display.set_rotation(rotation); display.clear(Color::White).ok(); - render(&mut display, &data); + render(&mut display, &data, logical_w, logical_h); let buf = display.buffer(); let initialized = Path::new(STATE_FILE).exists(); @@ -489,17 +572,19 @@ fn main() -> Result<(), Box> { epd.update_frame(&mut spi, buf, &mut delay)?; epd.display_frame(&mut spi, &mut delay)?; std::fs::write(STATE_FILE, "")?; - println!("{}", data.summary()); + println!("{}", data.summary(rotation_degrees, reoriented)); } 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, "")?; - println!("{}", data.summary()); + println!("{}", data.summary(rotation_degrees, reoriented)); } else { // All subsequent runs — true partial refresh only epd.display_partial(&mut spi, buf, &mut delay)?; - println!("{}", data.summary()); + println!("{}", data.summary(rotation_degrees, reoriented)); } + let _ = std::fs::write(ROTATION_FILE, ¤t_rotation); + Ok(()) } diff --git a/examples/epd3in52_ruby_status.rs b/examples/epd3in52_ruby_status.rs index d36b7925..73e635fa 100644 --- a/examples/epd3in52_ruby_status.rs +++ b/examples/epd3in52_ruby_status.rs @@ -25,7 +25,7 @@ use embedded_graphics::{ }; use epd_waveshare::{ color::Color, - epd3in52::{Display3in52, Epd3in52}, + epd3in52::{Display3in52, Epd3in52, HEIGHT, WIDTH}, graphics::DisplayRotation, prelude::*, }; @@ -36,6 +36,7 @@ use linux_embedded_hal::{ }; 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}; // -- Set true to send a raw half-white/half-black test pattern ---------------- @@ -92,6 +93,41 @@ impl StatusData { } } +fn parse_config() -> DisplayRotation { + if !Path::new("/etc/epd-waveshare.conf").exists() { + eprintln!( + "epd-waveshare: no config file found at /etc/epd-waveshare.conf, using defaults." + ); + eprintln!("Create it with:"); + eprintln!(" sudo tee /etc/epd-waveshare.conf << 'EOF'"); + eprintln!("# /etc/epd-waveshare.conf"); + eprintln!("rotation=0"); + eprintln!("EOF"); + } + 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('=') { + if key.trim() == "rotation" { + match value.trim() { + "0" => return DisplayRotation::Rotate0, + "90" => return DisplayRotation::Rotate90, + "180" => return DisplayRotation::Rotate180, + "270" => return DisplayRotation::Rotate270, + _ => eprintln!( + "epd-waveshare: invalid rotation value '{}', using 0", + value.trim() + ), + } + } + } + } + DisplayRotation::Rotate0 +} + fn main() -> Result<(), Box> { println!("epd3in52_ruby_status -- Waveshare 3.52\" on ruby"); @@ -99,6 +135,19 @@ fn main() -> Result<(), Box> { const EXPECTED_BUF_LEN: usize = 240 / 8 * 360; assert_eq!(EXPECTED_BUF_LEN, 10800); + // -- Read config ---------------------------------------------------------- + let rotation = parse_config(); + let (logical_w, _logical_h) = match rotation { + DisplayRotation::Rotate0 | DisplayRotation::Rotate180 => (WIDTH, HEIGHT), + DisplayRotation::Rotate90 | DisplayRotation::Rotate270 => (HEIGHT, WIDTH), + }; + let rotation_degrees: u16 = match rotation { + DisplayRotation::Rotate0 => 0, + DisplayRotation::Rotate90 => 90, + DisplayRotation::Rotate180 => 180, + DisplayRotation::Rotate270 => 270, + }; + // -- Collect system stats (CPU read takes ~500ms) ------------------------- let data = StatusData::collect(); @@ -148,10 +197,10 @@ fn main() -> Result<(), Box> { } epd.update_frame(&mut spi, &buf, &mut delay)?; } else { - // -- 3. Build frame buffer (landscape: 360w x 240h) ------------------- + // -- 3. Build frame buffer -------------------------------------------- println!("Rendering..."); let mut display = Display3in52::default(); - display.set_rotation(DisplayRotation::Rotate90); + display.set_rotation(rotation); assert_eq!( display.buffer().len(), @@ -165,7 +214,7 @@ fn main() -> Result<(), Box> { "buffer must be all-white (0xFF) after clear" ); - draw_status(&mut display, &data)?; + draw_status(&mut display, &data, logical_w, _logical_h)?; println!("Sending frame..."); epd.update_frame(&mut spi, display.buffer(), &mut delay)?; @@ -195,8 +244,9 @@ fn main() -> Result<(), Box> { .map(|p| format!("{:.0}%", p)) .unwrap_or_else(|| "--".to_string()); println!( - "[{}] Display updated. Temp {}°C CPU {} RAM {}/{}MB Disk {} Batt {} Up {}", + "[{}] Display updated. Rotation {}° Temp {}°C CPU {} RAM {}/{}MB Disk {} Batt {} Up {}", data.timestamp, + rotation_degrees, temp_str, cpu_str, data.used_mb, @@ -209,10 +259,12 @@ fn main() -> Result<(), Box> { Ok(()) } -// -- Frame content (landscape: 360 wide x 240 tall) --------------------------- +// -- Frame content ------------------------------------------------------------ fn draw_status( display: &mut Display3in52, data: &StatusData, + w: u32, + _h: u32, ) -> Result<(), Box> { let body = MonoTextStyleBuilder::new() .font(&FONT_8X13) @@ -224,8 +276,9 @@ fn draw_status( .text_color(Color::White) .background_color(Color::Black) .build(); + // ── Header bar (full width, 28px tall) ─────────────────────────────────── - Rectangle::new(Point::new(0, 0), Size::new(360, 28)) + Rectangle::new(Point::new(0, 0), Size::new(w, 28)) .into_styled(PrimitiveStyle::with_fill(Color::Black)) .draw(display)?; Text::with_baseline( @@ -235,9 +288,9 @@ fn draw_status( Baseline::Top, ) .draw(display)?; - // 20 chars × 8px = 160px, starting at x=192 → ends at x=352 let ts_short = format!("{} UTC", &data.timestamp[..16]); - Text::with_baseline(&ts_short, Point::new(192, 8), header_title, Baseline::Top) + 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) ───────────────────────────── @@ -314,7 +367,7 @@ fn draw_status( if let Some(pct) = data.batt_percent { let bar_x = x; let bar_y = 170; - let bar_w = 164u32; + let bar_w = w / 2; let bar_h = 10u32; // Outline Rectangle::new(Point::new(bar_x, bar_y), Size::new(bar_w, bar_h)) From d64b992c805e67215f472aafa7ed7263ac03c817 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sat, 11 Apr 2026 22:28:23 -0400 Subject: [PATCH 14/27] feat: add color_invert config key to /etc/epd-waveshare.conf MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Both examples read color_invert=true/false from conf - EpdConfig struct consolidates rotation + color_invert parsing - stdout includes Inv:true/false in summary line - Hardware verified: Waveshare 3.52" EPD (UC8253) and Waveshare 2.13" EPD V4 (SSD1680) - Known: color_invert change on Waveshare 2.13" EPD V4 requires base->partial cycle to self-correct (no forced full refresh on inversion change — backlog) --- examples/epd2in13_v4_status.rs | 66 +++++++++++++++++++++++--------- examples/epd3in52_ruby_status.rs | 49 +++++++++++++++++------- 2 files changed, 83 insertions(+), 32 deletions(-) diff --git a/examples/epd2in13_v4_status.rs b/examples/epd2in13_v4_status.rs index fc8ce563..988dc3a4 100644 --- a/examples/epd2in13_v4_status.rs +++ b/examples/epd2in13_v4_status.rs @@ -217,12 +217,13 @@ impl StatusData { (bat_line, volt_line) } - fn summary(&self, rotation_degrees: u16, reoriented: bool) -> String { + fn summary(&self, rotation_degrees: u16, color_invert: bool, reoriented: bool) -> String { let reoriented_str = if reoriented { " | REORIENTED" } else { "" }; format!( - "{} | ROT:{}{} | {} | {} | {} | {} | {} | {} | {} | {}", + "{} | ROT:{} | Inv:{}{} | {} | {} | {} | {} | {} | {} | {} | {}", self.refresh_mode, rotation_degrees, + color_invert, reoriented_str, self.hostname, self.ip, @@ -329,7 +330,12 @@ fn read_cpu_percent() -> Option { Some((100 * (dt - di) / dt) as u32) } -fn parse_config() -> DisplayRotation { +struct EpdConfig { + rotation: DisplayRotation, + color_invert: bool, +} + +fn parse_config() -> EpdConfig { if !Path::new("/etc/epd-waveshare.conf").exists() { eprintln!( "epd-waveshare: no config file found at /etc/epd-waveshare.conf, using defaults." @@ -338,8 +344,11 @@ fn parse_config() -> DisplayRotation { eprintln!(" sudo tee /etc/epd-waveshare.conf << 'EOF'"); eprintln!("# /etc/epd-waveshare.conf"); eprintln!("rotation=0"); + eprintln!("color_invert=false"); eprintln!("EOF"); } + 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(); @@ -347,21 +356,28 @@ fn parse_config() -> DisplayRotation { continue; } if let Some((key, value)) = line.split_once('=') { - if key.trim() == "rotation" { - match value.trim() { - "0" => return DisplayRotation::Rotate0, - "90" => return DisplayRotation::Rotate90, - "180" => return DisplayRotation::Rotate180, - "270" => return DisplayRotation::Rotate270, + 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"; } + _ => {} } } } - DisplayRotation::Rotate0 + EpdConfig { + rotation, + color_invert, + } } // ---- Rendering ---- @@ -491,12 +507,12 @@ fn render(display: &mut Display2in13, data: &StatusData, w: u32, _h: u32) { fn main() -> Result<(), Box> { std::fs::create_dir_all(STATE_DIR)?; - let rotation = parse_config(); - let (logical_w, logical_h) = match rotation { + 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 rotation { + let rotation_degrees: u16 = match config.rotation { DisplayRotation::Rotate0 => 0, DisplayRotation::Rotate90 => 90, DisplayRotation::Rotate180 => 180, @@ -559,11 +575,16 @@ fn main() -> Result<(), Box> { // Render to framebuffer let mut display = Display2in13::default(); - display.set_rotation(rotation); + display.set_rotation(config.rotation); display.clear(Color::White).ok(); render(&mut display, &data, logical_w, logical_h); - let buf = display.buffer(); + 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 = Path::new(STATE_FILE).exists(); let base_set = Path::new(BASE_FILE).exists(); @@ -572,16 +593,25 @@ fn main() -> Result<(), Box> { epd.update_frame(&mut spi, buf, &mut delay)?; epd.display_frame(&mut spi, &mut delay)?; std::fs::write(STATE_FILE, "")?; - println!("{}", data.summary(rotation_degrees, reoriented)); + println!( + "{}", + data.summary(rotation_degrees, config.color_invert, reoriented) + ); } 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, "")?; - println!("{}", data.summary(rotation_degrees, reoriented)); + println!( + "{}", + data.summary(rotation_degrees, config.color_invert, reoriented) + ); } else { // All subsequent runs — true partial refresh only epd.display_partial(&mut spi, buf, &mut delay)?; - println!("{}", data.summary(rotation_degrees, reoriented)); + println!( + "{}", + data.summary(rotation_degrees, config.color_invert, reoriented) + ); } let _ = std::fs::write(ROTATION_FILE, ¤t_rotation); diff --git a/examples/epd3in52_ruby_status.rs b/examples/epd3in52_ruby_status.rs index 73e635fa..53358c4c 100644 --- a/examples/epd3in52_ruby_status.rs +++ b/examples/epd3in52_ruby_status.rs @@ -93,7 +93,12 @@ impl StatusData { } } -fn parse_config() -> DisplayRotation { +struct EpdConfig { + rotation: DisplayRotation, + color_invert: bool, +} + +fn parse_config() -> EpdConfig { if !Path::new("/etc/epd-waveshare.conf").exists() { eprintln!( "epd-waveshare: no config file found at /etc/epd-waveshare.conf, using defaults." @@ -102,8 +107,11 @@ fn parse_config() -> DisplayRotation { eprintln!(" sudo tee /etc/epd-waveshare.conf << 'EOF'"); eprintln!("# /etc/epd-waveshare.conf"); eprintln!("rotation=0"); + eprintln!("color_invert=false"); eprintln!("EOF"); } + 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(); @@ -111,21 +119,28 @@ fn parse_config() -> DisplayRotation { continue; } if let Some((key, value)) = line.split_once('=') { - if key.trim() == "rotation" { - match value.trim() { - "0" => return DisplayRotation::Rotate0, - "90" => return DisplayRotation::Rotate90, - "180" => return DisplayRotation::Rotate180, - "270" => return DisplayRotation::Rotate270, + 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"; } + _ => {} } } } - DisplayRotation::Rotate0 + EpdConfig { + rotation, + color_invert, + } } fn main() -> Result<(), Box> { @@ -136,12 +151,12 @@ fn main() -> Result<(), Box> { assert_eq!(EXPECTED_BUF_LEN, 10800); // -- Read config ---------------------------------------------------------- - let rotation = parse_config(); - let (logical_w, _logical_h) = match rotation { + 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 rotation { + let rotation_degrees: u16 = match config.rotation { DisplayRotation::Rotate0 => 0, DisplayRotation::Rotate90 => 90, DisplayRotation::Rotate180 => 180, @@ -200,7 +215,7 @@ fn main() -> Result<(), Box> { // -- 3. Build frame buffer -------------------------------------------- println!("Rendering..."); let mut display = Display3in52::default(); - display.set_rotation(rotation); + display.set_rotation(config.rotation); assert_eq!( display.buffer().len(), @@ -217,7 +232,12 @@ fn main() -> Result<(), Box> { draw_status(&mut display, &data, logical_w, _logical_h)?; println!("Sending frame..."); - epd.update_frame(&mut spi, display.buffer(), &mut delay)?; + 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) ----------- @@ -244,9 +264,10 @@ fn main() -> Result<(), Box> { .map(|p| format!("{:.0}%", p)) .unwrap_or_else(|| "--".to_string()); println!( - "[{}] Display updated. Rotation {}° Temp {}°C CPU {} RAM {}/{}MB Disk {} Batt {} Up {}", + "[{}] 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, From 5c3519a7d72691f95630930bc438c8ee7f34bb17 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sun, 12 Apr 2026 16:38:03 -0400 Subject: [PATCH 15/27] =?UTF-8?q?tools:=20add=20test=5Frotation.sh=20?= =?UTF-8?q?=E2=80=94=20cycle=20all=20four=20rotations=20on=20a=20target=20?= =?UTF-8?q?device?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Usage: bash tools/test_rotation.sh Reads current rotation before starting, restores on completion. 20s settle time between rotations to avoid timing issues on SSD1680. --- tools/test_rotation.sh | 48 ++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 48 insertions(+) create mode 100755 tools/test_rotation.sh diff --git a/tools/test_rotation.sh b/tools/test_rotation.sh new file mode 100755 index 00000000..afbd371c --- /dev/null +++ b/tools/test_rotation.sh @@ -0,0 +1,48 @@ +#!/usr/bin/env bash +# test_rotation.sh — test all four display rotations on a target device +# +# Usage: +# bash tools/test_rotation.sh ruby +# bash tools/test_rotation.sh coral +# +# Cycles through 0/90/180/270, waits 20s between each, +# then restores the original rotation from the device. + +set -euo pipefail + +TARGET="${1:-}" +if [[ -z "$TARGET" ]]; then + echo "Usage: $0 " >&2 + exit 1 +fi + +# Read current rotation before we start so we can restore it +ORIGINAL=$(ssh "$TARGET" "grep '^rotation=' /etc/epd-waveshare.conf 2>/dev/null | cut -d= -f2" || echo "0") +echo "Current rotation on $TARGET: ${ORIGINAL}°" +echo "" + +for rot in 0 90 180 270; do + echo "--- Rotation ${rot}° ---" + ssh "$TARGET" "sudo tee /etc/epd-waveshare.conf > /dev/null << 'CONF' +# /etc/epd-waveshare.conf +rotation=${rot} +color_invert=false +CONF" + ssh "$TARGET" "sudo /usr/local/bin/epd2in13_v4_status 2>&1" \ + || ssh "$TARGET" "sudo /home/aken/epd3in52_ruby_status 2>&1" + echo "Waiting 20s..." + sleep 20 +done + +# Restore original rotation +echo "--- Restoring rotation ${ORIGINAL}° ---" +ssh "$TARGET" "sudo tee /etc/epd-waveshare.conf > /dev/null << CONF +# /etc/epd-waveshare.conf +rotation=${ORIGINAL} +color_invert=false +CONF" +ssh "$TARGET" "sudo /usr/local/bin/epd2in13_v4_status 2>&1" \ + || ssh "$TARGET" "sudo /home/aken/epd3in52_ruby_status 2>&1" + +echo "" +echo "Done. $TARGET restored to rotation=${ORIGINAL}°" From b39c2dff9bd5d85b9e2fae7002db36bb59dc4cbf Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sun, 12 Apr 2026 22:39:33 -0400 Subject: [PATCH 16/27] =?UTF-8?q?fix:=20epd=20status=20examples=20?= =?UTF-8?q?=E2=80=94=20sleep,=20clock=20skew,=20state=20ordering,=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add epd.sleep() before exit in epd2in13_v4_status (SSD1680 left awake between timer runs without this) - Replace unwrap_or_default() on SystemTime with "CLK? unsynced" sentinel visible on display when clock not yet synced after boot - Move state file removal to after successful display update — SPI init failure no longer clears state unnecessarily - Replace multi-line config-missing block with single eprintln! to reduce systemd journal noise on timer-driven nodes - Use rsplit_once(':') in parse_pisugar_float for robustness - Standardise IP discovery UDP connect to port 53 in both files - Use .get(..16).unwrap_or() instead of direct slice in 3in52 - Replace const TEST_PATTERN: bool = false with EPD_TEST_PATTERN env var — no recompile needed to toggle - Remove tautological assert_eq!(EXPECTED_BUF_LEN, 10800) in 3in52 - Register epd3in52_ruby_status in Cargo.toml [[example]] - Align epd.sleep() position with 3in52 pattern - H3: saturating_sub for dt - di in read_cpu_percent (both examples) - M1: add .map_err() error context at SPI, GPIO, EPD init boundaries Tested on hardware: Raspberry Pi Zero 2WH (aarch64, Raspberry Pi OS), Waveshare 2.13" EPD V4 (SSD1680). Stripped binary: 581 KB. Co-Authored-By: Claude Opus 4.6 (1M context) --- Cargo.toml | 5 ++ examples/epd2in13_v4_status.rs | 134 +++++++++++++++---------------- examples/epd3in52_ruby_status.rs | 88 ++++++++++---------- 3 files changed, 116 insertions(+), 111 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 82f70fd2..497f567c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -55,6 +55,10 @@ required-features = ["linux-dev"] name = "epd2in13_v4_status" required-features = ["linux-dev"] +[[example]] +name = "epd3in52_ruby_status" +required-features = ["linux-dev"] + [[example]] name = "epd4in2" required-features = ["linux-dev"] @@ -67,6 +71,7 @@ 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/epd2in13_v4_status.rs b/examples/epd2in13_v4_status.rs index 988dc3a4..7d056857 100644 --- a/examples/epd2in13_v4_status.rs +++ b/examples/epd2in13_v4_status.rs @@ -88,6 +88,8 @@ impl StatusData { } 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() { @@ -99,10 +101,13 @@ impl StatusData { } fn read_datetime() -> String { - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .unwrap_or_default() - .as_secs(); + 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; @@ -248,7 +253,7 @@ fn query_pisugar(cmd: &str) -> Option { } fn parse_pisugar_float(response: &str) -> Option { - response.split(':').nth(1)?.trim().parse().ok() + response.rsplit_once(':')?.1.trim().parse().ok() } fn days_to_ymd(mut days: u64) -> (u64, u64, u64) { @@ -327,7 +332,7 @@ fn read_cpu_percent() -> Option { if dt == 0 { return Some(0); } - Some((100 * (dt - di) / dt) as u32) + Some((100 * dt.saturating_sub(di) / dt) as u32) } struct EpdConfig { @@ -337,15 +342,7 @@ struct EpdConfig { fn parse_config() -> EpdConfig { if !Path::new("/etc/epd-waveshare.conf").exists() { - eprintln!( - "epd-waveshare: no config file found at /etc/epd-waveshare.conf, using defaults." - ); - eprintln!("Create it with:"); - eprintln!(" sudo tee /etc/epd-waveshare.conf << 'EOF'"); - eprintln!("# /etc/epd-waveshare.conf"); - eprintln!("rotation=0"); - eprintln!("color_invert=false"); - eprintln!("EOF"); + eprintln!("epd-waveshare: no config at /etc/epd-waveshare.conf, using defaults"); } let mut rotation = DisplayRotation::Rotate0; let mut color_invert = false; @@ -382,7 +379,12 @@ fn parse_config() -> EpdConfig { // ---- Rendering ---- -fn render(display: &mut Display2in13, data: &StatusData, w: u32, _h: u32) { +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() @@ -403,24 +405,21 @@ fn render(display: &mut Display2in13, data: &StatusData, w: u32, _h: u32) { // Header bar: hostname left, IP right Rectangle::new(Point::new(0, 0), Size::new(w, 13)) .into_styled(fill_black) - .draw(display) - .ok(); + .draw(display)?; Text::with_baseline( &data.hostname, Point::new(2, 2), white_on_black, Baseline::Top, ) - .draw(display) - .ok(); + .draw(display)?; Text::with_text_style( &data.ip, Point::new((w - 2) as i32, 2), white_on_black, right_align, ) - .draw(display) - .ok(); + .draw(display)?; let mut y = 15; @@ -431,8 +430,7 @@ fn render(display: &mut Display2in13, data: &StatusData, w: u32, _h: u32) { black_on_white, Baseline::Top, ) - .draw(display) - .ok(); + .draw(display)?; y += 12; // Uptime @@ -442,14 +440,12 @@ fn render(display: &mut Display2in13, data: &StatusData, w: u32, _h: u32) { black_on_white, Baseline::Top, ) - .draw(display) - .ok(); + .draw(display)?; y += 12; // CPU Text::with_baseline(&data.cpu, Point::new(2, y), black_on_white, Baseline::Top) - .draw(display) - .ok(); + .draw(display)?; y += 12; // RAM @@ -459,14 +455,12 @@ fn render(display: &mut Display2in13, data: &StatusData, w: u32, _h: u32) { black_on_white, Baseline::Top, ) - .draw(display) - .ok(); + .draw(display)?; y += 12; // Disk Text::with_baseline(&data.disk, Point::new(2, y), black_on_white, Baseline::Top) - .draw(display) - .ok(); + .draw(display)?; y += 12; // Battery @@ -476,8 +470,7 @@ fn render(display: &mut Display2in13, data: &StatusData, w: u32, _h: u32) { black_on_white, Baseline::Top, ) - .draw(display) - .ok(); + .draw(display)?; y += 12; // Voltage @@ -487,8 +480,7 @@ fn render(display: &mut Display2in13, data: &StatusData, w: u32, _h: u32) { black_on_white, Baseline::Top, ) - .draw(display) - .ok(); + .draw(display)?; y += 12; // Refresh mode @@ -498,8 +490,9 @@ fn render(display: &mut Display2in13, data: &StatusData, w: u32, _h: u32) { black_on_white, Baseline::Top, ) - .draw(display) - .ok(); + .draw(display)?; + + Ok(()) } // ---- Main ---- @@ -531,8 +524,6 @@ fn main() -> Result<(), Box> { "epd-waveshare: rotation changed {} -> {}, forcing full refresh", last_rotation, current_rotation ); - let _ = std::fs::remove_file(STATE_FILE); - let _ = std::fs::remove_file(BASE_FILE); true } else { false @@ -544,40 +535,51 @@ fn main() -> Result<(), Box> { let data = StatusData::collect(); // EPD setup - let mut spi = SpidevDevice::open("/dev/spidev0.0")?; + 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)?; + spi.configure(&options) + .map_err(|e| format!("configure /dev/spidev0.0: {e}"))?; - let mut chip = Chip::new("/dev/gpiochip0")?; + let mut chip = Chip::new("/dev/gpiochip0").map_err(|e| format!("open /dev/gpiochip0: {e}"))?; let busy = CdevPin::new( - chip.get_line(24)? - .request(LineRequestFlags::INPUT, 0, "epd-busy")?, + 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)? - .request(LineRequestFlags::OUTPUT, 0, "epd-dc")?, + 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)? - .request(LineRequestFlags::OUTPUT, 1, "epd-rst")?, + 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)? - .request(LineRequestFlags::OUTPUT, 0, "epd-pwr")?, + 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)?; + 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); + render(&mut display, &data, logical_w, logical_h)?; let final_buffer: Vec = if config.color_invert { display.buffer().iter().map(|&b| !b).collect() @@ -585,36 +587,32 @@ fn main() -> Result<(), Box> { display.buffer().to_vec() }; let buf = final_buffer.as_slice(); - let initialized = Path::new(STATE_FILE).exists(); - let base_set = Path::new(BASE_FILE).exists(); + let initialized = !reoriented && Path::new(STATE_FILE).exists(); + let base_set = !reoriented && Path::new(BASE_FILE).exists(); if !initialized { - // First run since boot — full refresh + // 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, "")?; - println!( - "{}", - data.summary(rotation_degrees, config.color_invert, reoriented) - ); } 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, "")?; - println!( - "{}", - data.summary(rotation_degrees, config.color_invert, reoriented) - ); } else { // All subsequent runs — true partial refresh only epd.display_partial(&mut spi, buf, &mut delay)?; - println!( - "{}", - data.summary(rotation_degrees, config.color_invert, reoriented) - ); } 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_ruby_status.rs b/examples/epd3in52_ruby_status.rs index 53358c4c..c60be0f7 100644 --- a/examples/epd3in52_ruby_status.rs +++ b/examples/epd3in52_ruby_status.rs @@ -39,9 +39,6 @@ use std::os::unix::net::UnixStream; use std::path::Path; use std::time::{Duration, SystemTime, UNIX_EPOCH}; -// -- Set true to send a raw half-white/half-black test pattern ---------------- -const TEST_PATTERN: bool = false; - // -- GPIO pin numbers (BCM, verified from epdconfig.py on ruby) --------------- const PIN_BUSY: u32 = 24; const PIN_RST: u32 = 17; @@ -100,15 +97,7 @@ struct EpdConfig { fn parse_config() -> EpdConfig { if !Path::new("/etc/epd-waveshare.conf").exists() { - eprintln!( - "epd-waveshare: no config file found at /etc/epd-waveshare.conf, using defaults." - ); - eprintln!("Create it with:"); - eprintln!(" sudo tee /etc/epd-waveshare.conf << 'EOF'"); - eprintln!("# /etc/epd-waveshare.conf"); - eprintln!("rotation=0"); - eprintln!("color_invert=false"); - eprintln!("EOF"); + eprintln!("epd-waveshare: no config at /etc/epd-waveshare.conf, using defaults"); } let mut rotation = DisplayRotation::Rotate0; let mut color_invert = false; @@ -146,9 +135,7 @@ fn parse_config() -> EpdConfig { fn main() -> Result<(), Box> { println!("epd3in52_ruby_status -- Waveshare 3.52\" on ruby"); - // -- Compile-time and runtime invariant checks ---------------------------- const EXPECTED_BUF_LEN: usize = 240 / 8 * 360; - assert_eq!(EXPECTED_BUF_LEN, 10800); // -- Read config ---------------------------------------------------------- let config = parse_config(); @@ -167,44 +154,51 @@ fn main() -> Result<(), Box> { let data = StatusData::collect(); // -- SPI setup (SpidevDevice, not Spidev) --------------------------------- - let mut spi = SpidevDevice::open(SPI_DEVICE)?; + 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)?; + 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")?; - - let busy = CdevPin::new(chip.get_line(PIN_BUSY)?.request( - LineRequestFlags::INPUT, - 0, - "epd3in52-busy", - )?)?; - let dc = CdevPin::new(chip.get_line(PIN_DC)?.request( - LineRequestFlags::OUTPUT, - 0, - "epd3in52-dc", - )?)?; - let rst = CdevPin::new(chip.get_line(PIN_RST)?.request( - LineRequestFlags::OUTPUT, - 1, - "epd3in52-rst", - )?)?; + 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)?; + 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)?; - if TEST_PATTERN { + 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() { @@ -309,7 +303,10 @@ fn draw_status( Baseline::Top, ) .draw(display)?; - let ts_short = format!("{} UTC", &data.timestamp[..16]); + 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)?; @@ -420,9 +417,11 @@ fn read_hostname() -> 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:80")?; + s.connect("8.8.8.8:53")?; s.local_addr() }) .map(|a| a.ip().to_string()) @@ -430,10 +429,13 @@ fn read_local_ip() -> String { } fn format_timestamp() -> String { - let secs = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|d| d.as_secs()) - .unwrap_or(0); + 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; @@ -577,7 +579,7 @@ fn read_cpu_percent() -> Option { if dt == 0 { return Some(0); } - Some((100 * (dt - di) / dt) as u32) + Some((100 * dt.saturating_sub(di) / dt) as u32) } /// Read root filesystem usage by spawning `df -k /` and parsing output. @@ -624,5 +626,5 @@ fn query_pisugar(cmd: &str) -> Option { } fn parse_pisugar_float(response: &str) -> Option { - response.split(':').nth(1)?.trim().parse().ok() + response.rsplit_once(':')?.1.trim().parse().ok() } From a4c59b3bc5500edada48fa4e6d66633dd0104446 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sun, 12 Apr 2026 22:39:51 -0400 Subject: [PATCH 17/27] =?UTF-8?q?fix:=20driver=20correctness=20and=20docum?= =?UTF-8?q?entation=20=E2=80=94=20reset=20timing,=20todo,=20idioms?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - H2: correct epd3in52 reset pulse to match Waveshare Python reference driver: 30μs/10μs → 200ms/10ms (UC8253 RST timing) Hardware verified: Waveshare 3.52" EPD on Raspberry Pi 5 (aarch64) - E1: replace todo!() in epd3in52::update_partial_frame with documented fallback to update_frame — UC8253 does not support true partial frame updates; todo!() panics at runtime for any caller - E2: expand epd3in52 module doc to upstream parity — UC8253 name, product page link, Python driver link, lut_flag caveat, example block - E3: add epd3in52 = [] feature stub to Cargo.toml [features] - E4: add #[repr(u8)] to epd2in13_v4 Command enum for consistency with epd3in52 and explicit hardware register layout - E6: add SSD1680 reset timing citation comments in epd2in13_v4/mod.rs - E7: annotate DisplayUpdateControl2 0xF7/0xC7/0xFF bytes in v4; annotate UC8253 init register sequence in 3in52 - E8: expand Epd3in52 struct doc to match Epd2in13 density - E9: align module-doc section style between both drivers - M2: document data_x_times per-byte SPI write limitation - H1/M3/M4: deferred — BUSY timeout and GPIO error propagation require library API changes with dedicated hardware testing Co-Authored-By: Claude Opus 4.6 (1M context) --- src/epd2in13_v4/command.rs | 1 + src/epd2in13_v4/mod.rs | 29 ++++++++++++++- src/epd3in52/mod.rs | 76 +++++++++++++++++++++++++++++++++----- src/interface.rs | 6 ++- 4 files changed, 100 insertions(+), 12 deletions(-) diff --git a/src/epd2in13_v4/command.rs b/src/epd2in13_v4/command.rs index d106f2f8..3f439e8a 100644 --- a/src/epd2in13_v4/command.rs +++ b/src/epd2in13_v4/command.rs @@ -5,6 +5,7 @@ use crate::traits; /// EPD 2.13" V4 commands #[allow(dead_code)] #[derive(Copy, Clone)] +#[repr(u8)] pub(crate) enum Command { /// Software reset SwReset = 0x12, diff --git a/src/epd2in13_v4/mod.rs b/src/epd2in13_v4/mod.rs index f1f8032d..3389e560 100644 --- a/src/epd2in13_v4/mod.rs +++ b/src/epd2in13_v4/mod.rs @@ -24,6 +24,27 @@ //! //! 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; @@ -168,6 +189,7 @@ where } 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)?; @@ -175,6 +197,7 @@ where } 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)?; @@ -186,6 +209,7 @@ where 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)?; @@ -201,6 +225,8 @@ where /// /// 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)?; @@ -421,7 +447,8 @@ where // Drive power pin HIGH before any SPI communication (V4 requirement) let _ = self.pwr_pin.set_high(); - // HW reset: HIGH 20ms -> LOW 2ms -> HIGH 20ms + // 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)?; diff --git a/src/epd3in52/mod.rs b/src/epd3in52/mod.rs index 43bee3e6..5f1546db 100644 --- a/src/epd3in52/mod.rs +++ b/src/epd3in52/mod.rs @@ -1,7 +1,40 @@ -//! A simple Driver for the Waveshare 3.52" E-Ink Display via SPI +//! A Driver for the Waveshare 3.52" E-Ink Display via SPI (UC8253 controller) //! +//! # References //! -//! Build with the help of documentation/code from [Waveshare](https://www.waveshare.com/wiki/3.52inch_e-Paper_HAT), +//! - [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, @@ -43,7 +76,12 @@ pub type Display3in52 = crate::graphics::Display< Color, >; -/// Epd3in52 driver +/// 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, @@ -66,27 +104,38 @@ where { fn init(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { // reset the device - self.interface.reset(delay, 30, 10); + // 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])?; @@ -164,18 +213,25 @@ where Ok(()) } - #[allow(unused)] + /// 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, + _x: u32, + _y: u32, + _width: u32, + _height: u32, ) -> Result<(), SPI::Error> { - todo!() + self.update_frame(spi, buffer, delay) } fn display_frame(&mut self, spi: &mut SPI, delay: &mut DELAY) -> Result<(), SPI::Error> { diff --git a/src/interface.rs b/src/interface.rs index 4351daac..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])?; } From 9e7935ad7e0726b9c708d6c5ea9c4f804cba84a0 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sun, 12 Apr 2026 22:49:24 -0400 Subject: [PATCH 18/27] =?UTF-8?q?refactor:=20rename=20epd3in52=5Fruby=5Fst?= =?UTF-8?q?atus=20=E2=86=92=20epd3in52=5Fstatus?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove deployment-specific hostname from example filename. Update Cargo.toml [[example]] name to match. --- Cargo.toml | 2 +- examples/{epd3in52_ruby_status.rs => epd3in52_status.rs} | 0 2 files changed, 1 insertion(+), 1 deletion(-) rename examples/{epd3in52_ruby_status.rs => epd3in52_status.rs} (100%) diff --git a/Cargo.toml b/Cargo.toml index 497f567c..be57a4ad 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -56,7 +56,7 @@ name = "epd2in13_v4_status" required-features = ["linux-dev"] [[example]] -name = "epd3in52_ruby_status" +name = "epd3in52_status" required-features = ["linux-dev"] [[example]] diff --git a/examples/epd3in52_ruby_status.rs b/examples/epd3in52_status.rs similarity index 100% rename from examples/epd3in52_ruby_status.rs rename to examples/epd3in52_status.rs From 614eee27a40683ef3c720f6864e48da916936f65 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sun, 12 Apr 2026 23:25:38 -0400 Subject: [PATCH 19/27] feat: add epd2in13_v4 Ferris walking demo (partial refresh) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates SSD1680 partial refresh using an animated Ferris sprite walking left/right across the bottom of the panel. Ferris is 110x73 pixels on a 250x122 display — large enough to be clearly visible. Sequence: 1. Full refresh — clear panel white 2. display_part_base_image — write base to both SSD1680 RAM banks 3. Walk loop — 3 back-and-forth cycles via display_partial (no flash) 4. reinit() — required before full refresh after partial mode 5. Final full refresh — clear panel white 6. deep sleep Key implementation notes: - 500ms settle delays after full refreshes before partial mode begins - Uses gpio_cdev (not sysfs_gpio — deprecated on RPi OS Bookworm) - Ferris asset: examples/assets/ferris_110x73.raw (1-bit, 1022 bytes) generated from rustacean.net/assets/rustacean-flat-noshadow.png Tested on hardware: Raspberry Pi Zero 2WH (aarch64, Raspberry Pi OS), Waveshare 2.13" EPD V4 (SSD1680). Clean walk, no ghosting. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/assets/ferris_110x73.raw | Bin 0 -> 1022 bytes examples/epd2in13_v4.rs | 272 +++++++++++++++++++----------- 2 files changed, 171 insertions(+), 101 deletions(-) create mode 100644 examples/assets/ferris_110x73.raw diff --git a/examples/assets/ferris_110x73.raw b/examples/assets/ferris_110x73.raw new file mode 100644 index 0000000000000000000000000000000000000000..47f2715a9ada702110f50e25fe2024862fbd7930 GIT binary patch literal 1022 zcmbu7u};G<5QeWZYC2TOnxT?;gpN$Lcog2CBLk` zj$^k1gb+96?(=_l^_@%fAIr{*^GsEVi{dHKMC+}K+DxzCUL$JF{GRBA`6Cg-YU-^t zo`R@7?FSNx`LC2~U&fn?U_}q1O{7chSIUMi(lYgwIyFe#{X(OQ%tS5fE{S4h^QEVW znk>;KIuMC=;!X|&?YNUeWNLBTigW!4fvWE))IpPiBeR7=7XudzzhH-$85d1}RiuG% ze6}P|6}KrBF1|@mCvo0RYgZ)NudD31AGEC@)$9g(HZ*FM1LX^%b{?pkJ`k;LRz~>P zmY~JVc;cqK$9z+b+gogGj){sfuH*2+?i!RmW{eeNOm~=FZAN}|E!{f2*p4g?bwdg% zR7*tW(9@DM{taA{f<$xar%*c Result<(), Box> { - let mut spi = SpidevDevice::open("/dev/spidev0.0")?; + // --- 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)?; + spi.configure(&options) + .map_err(|e| format!("configure /dev/spidev0.0: {e}"))?; - let mut chip = Chip::new("/dev/gpiochip0")?; + // --- 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)? - .request(LineRequestFlags::INPUT, 0, "epd-busy")?, + 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)? - .request(LineRequestFlags::OUTPUT, 0, "epd-dc")?, + 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)? - .request(LineRequestFlags::OUTPUT, 1, "epd-rst")?, + 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)? - .request(LineRequestFlags::OUTPUT, 0, "epd-pwr")?, + 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)?; - delay.delay_ms(100); - - epd.reinit(&mut spi, &mut delay)?; - delay.delay_ms(100); + // --- 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}"))?; - // Prepare framebuffer — white background let mut display = Display2in13::default(); - display.clear(Color::White).ok(); - - let stroke = PrimitiveStyle::with_stroke(Color::Black, 1); - let fill_black = PrimitiveStyle::with_fill(Color::Black); - - // --- Bottom border: outline around full display --- - Rectangle::new(Point::new(0, 0), Size::new(122, 250)) - .into_styled(stroke) - .draw(&mut display)?; - - // --- Header bar: filled black with white text --- - Rectangle::new(Point::new(0, 0), Size::new(122, 21)) - .into_styled(fill_black) - .draw(&mut display)?; - - let white_text = MonoTextStyleBuilder::new() - .font(&FONT_6X10) - .text_color(Color::White) - .background_color(Color::Black) - .build(); - let center = TextStyleBuilder::new() - .alignment(Alignment::Center) - .baseline(Baseline::Top) - .build(); - Text::with_text_style("EPD V4 RUST", Point::new(61, 6), white_text, center) - .draw(&mut display)?; - - // --- Horizontal divider --- - Line::new(Point::new(0, 21), Point::new(121, 21)) - .into_styled(stroke) - .draw(&mut display)?; - - // --- Outline rectangle --- - Rectangle::new(Point::new(0, 25), Size::new(51, 51)) - .into_styled(stroke) - .draw(&mut display)?; - - // --- Diagonal lines through outline rectangle --- - Line::new(Point::new(0, 25), Point::new(50, 75)) - .into_styled(stroke) - .draw(&mut display)?; - Line::new(Point::new(50, 25), Point::new(0, 75)) - .into_styled(stroke) - .draw(&mut display)?; - - // --- Filled rectangle --- - Rectangle::new(Point::new(55, 25), Size::new(51, 51)) - .into_styled(fill_black) - .draw(&mut display)?; - - // --- Filled circle --- - Circle::new(Point::new(5, 80), 40) - .into_styled(fill_black) - .draw(&mut display)?; - - // --- Outline circle --- - Circle::new(Point::new(55, 80), 40) - .into_styled(stroke) - .draw(&mut display)?; - - // --- Text rows --- - let black_text = MonoTextStyleBuilder::new() - .font(&FONT_6X10) - .text_color(Color::Black) - .background_color(Color::White) - .build(); + display.set_rotation(DisplayRotation::Rotate90); - Text::with_baseline("SSD1680 OK", Point::new(2, 130), black_text, Baseline::Top) - .draw(&mut display)?; - Text::with_baseline("122x250px", Point::new(2, 145), black_text, Baseline::Top) - .draw(&mut display)?; - Text::with_baseline("Driver test", Point::new(2, 160), black_text, Baseline::Top) - .draw(&mut display)?; + // 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)?; - // Buffer summary - let buf = display.buffer(); - let non_white = buf.iter().filter(|&&b| b != 0xFF).count(); - println!("Buffer: {} bytes, {} non-white", buf.len(), non_white); + // 5. Deep sleep. + println!("Sleeping..."); + epd.sleep(&mut spi, &mut delay)?; - epd.update_frame(&mut spi, buf, &mut delay)?; - epd.display_frame(&mut spi, &mut delay)?; + Ok(()) +} - // No sleep — PWR stays high, image persists - println!("Done. Waiting 10s..."); - delay.delay_ms(10_000); +/// 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(()) } From 3413015068b9b7f47c3e8cbb6182aa6655b9f077 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Sun, 12 Apr 2026 23:29:45 -0400 Subject: [PATCH 20/27] fix: remove hostname references from epd3in52_status example MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace deployment-specific references with generic placeholders: - Remove hostname from module doc target line - Remove hostname from build/deploy instructions - Remove hostname from stdout banner - Remove hostname from GPIO comment - Update example name references epd3in52_ruby_status → epd3in52_status Tested on hardware: Raspberry Pi 5 (aarch64), Waveshare 3.52" EPD (UC8253). --- examples/epd3in52_status.rs | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/examples/epd3in52_status.rs b/examples/epd3in52_status.rs index c60be0f7..387a6bfe 100644 --- a/examples/epd3in52_status.rs +++ b/examples/epd3in52_status.rs @@ -1,6 +1,6 @@ -//! epd3in52_ruby_status — Waveshare 3.52" e-paper status display +//! epd3in52_status — Waveshare 3.52" e-paper status display //! -//! Target: ruby (Pi 5 8GB, 192.168.10.29) +//! 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) @@ -11,11 +11,11 @@ //! refresh to use swapped R22/R23 LUTs which inverts colors. //! //! Build and deploy: -//! cargo build --example epd3in52_ruby_status \ +//! cargo build --example epd3in52_status \ //! --target aarch64-unknown-linux-gnu --release -//! scp target/aarch64-unknown-linux-gnu/release/examples/epd3in52_ruby_status \ -//! ruby:~/ -//! ssh ruby "sudo ./epd3in52_ruby_status" +//! scp target/aarch64-unknown-linux-gnu/release/examples/epd3in52_status \ +//! :~/ +//! ssh "sudo ./epd3in52_status" use embedded_graphics::{ mono_font::{ascii::FONT_8X13, MonoTextStyleBuilder}, @@ -39,7 +39,7 @@ 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 on ruby) --------------- +// -- GPIO pin numbers (BCM, verified from epdconfig.py) --------------- const PIN_BUSY: u32 = 24; const PIN_RST: u32 = 17; const PIN_DC: u32 = 25; @@ -133,7 +133,7 @@ fn parse_config() -> EpdConfig { } fn main() -> Result<(), Box> { - println!("epd3in52_ruby_status -- Waveshare 3.52\" on ruby"); + println!("epd3in52_status -- Waveshare 3.52\""); const EXPECTED_BUF_LEN: usize = 240 / 8 * 360; From 934ce7b8a27886f95c4b80f425361318a8b27718 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Mon, 13 Apr 2026 00:29:30 -0400 Subject: [PATCH 21/27] =?UTF-8?q?feat:=20add=20epd3in52=20carcinisation=20?= =?UTF-8?q?demo=20(lobster=E2=86=92Ferris=20dissolve)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Demonstrates UC8253 DU (quick) refresh mode via an 8-frame deterministic cross-dissolve from the OpenClaw lobster mascot to Ferris the Rustacean. Layout: 'Carcinisation' caption persists above the sprite throughout all phases. 'Rustacean' appears below on the final frame only. Sprite centered vertically between the two captions. Carcinisation: the convergent evolutionary process by which non-crab crustaceans independently evolve into crab-like forms. Applied here to the software ecosystem. Animation sequence: 1. Full refresh — lobster + 'Carcinisation' (2s pause) 2. 8-frame DU dissolve — deterministic pixel cross-dissolve, lobster to Ferris, 'Carcinisation' present throughout 3. Full refresh — Ferris + 'Carcinisation' + 'Rustacean' Assets: - examples/assets/lobster_96x96.raw (1-bit, 1152 bytes) sourced from Noto Color Emoji lobster glyph - examples/assets/ferris_96x96.raw (1-bit, 1152 bytes) sourced from rustacean.net/assets/rustacean-flat-happy.png Tested on hardware: Raspberry Pi 5 (aarch64), Waveshare 3.52" EPD (UC8253). DU refresh artifacts are characteristic of the waveform mode and expected. Co-Authored-By: Claude Opus 4.6 (1M context) --- examples/assets/ferris_96x96.raw | Bin 0 -> 1152 bytes examples/assets/lobster_96x96.raw | Bin 0 -> 1152 bytes examples/epd3in52.rs | 298 ++++++++++++++++++++++++++++++ 3 files changed, 298 insertions(+) create mode 100644 examples/assets/ferris_96x96.raw create mode 100644 examples/assets/lobster_96x96.raw create mode 100644 examples/epd3in52.rs diff --git a/examples/assets/ferris_96x96.raw b/examples/assets/ferris_96x96.raw new file mode 100644 index 0000000000000000000000000000000000000000..cfdea54c0a73fdf4410acde0cf0c960be2075b8f GIT binary patch literal 1152 zcmeH^u};G<5QhIK5nU<*TaYSQJOW#W3iB#F0$Ycu_SBI#D18*d!c?h`K%@>`!gtMtb&jo}ozuiE5PD*9{mRCqm-Ha76}0lT@`py07KXR zKT?K*1`W!={&+ebPoypKALIKf_q?)Z-%2%$4w%GBhgzkNB6X@YIUV{W;(+9LLl{N6 z2=eb~<;8XKVxDwRI8=HK*By4oGNCLXQ;&YhQO(}8QvYR*a*6-jm91sYBYI~|P4eT}?C3DRvmmy_Y3yw| z(ytseqET~kV(WxVEMQ7o4%lju%}ORj|caM0Y1Jm=k-Xt-AVN{<$m^0 zXn(pxSiDC= 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() +} From e287ac9c710ed48f2586a6c6fadde2ae895d381f Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Mon, 13 Apr 2026 15:47:09 -0400 Subject: [PATCH 22/27] feat: add boot state reset and journald volatile config - epd-status-boot.service: clears display state files on every boot so the full->base->partial refresh cycle runs correctly after power cycle (display RAM is cleared but state files survive reboot) - journald-volatile.conf: volatile RAM-only journal with 10MB cap for SD card wear protection on Pi Zero 2W nodes - install_epd_status.sh: updated to install all five components - README.md: documents boot reset behavior and journald config Deployed and verified on Pi Zero 2WH (aarch64, Raspberry Pi OS). Co-Authored-By: Claude Opus 4.6 (1M context) --- scripts/README.md | 36 +++++++++++++++++++++++++++++++-- scripts/epd-status-boot.service | 13 ++++++++++++ scripts/install_epd_status.sh | 11 ++++++++++ scripts/journald-volatile.conf | 3 +++ 4 files changed, 61 insertions(+), 2 deletions(-) create mode 100644 scripts/epd-status-boot.service create mode 100644 scripts/journald-volatile.conf diff --git a/scripts/README.md b/scripts/README.md index d37a4426..4515f6ec 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -29,7 +29,36 @@ Copy the repo (or at minimum `scripts/` and the built binary) to the Pi, then: sudo ./scripts/install_epd_status.sh ``` -This installs the binary to `/usr/local/bin/`, copies the systemd units, and enables the timer. +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 @@ -73,8 +102,11 @@ sudo systemctl stop epd-status.timer sudo systemctl disable epd-status.timer # Uninstall everything -sudo systemctl disable epd-status.timer +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/install_epd_status.sh b/scripts/install_epd_status.sh index 5cc255f8..3d239314 100755 --- a/scripts/install_epd_status.sh +++ b/scripts/install_epd_status.sh @@ -24,10 +24,21 @@ 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 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 From 209267680c8903764c9aef8ac5ce3b42e1a4ee0b Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Mon, 13 Apr 2026 16:00:15 -0400 Subject: [PATCH 23/27] =?UTF-8?q?chore:=20gitignore=20docs/=20=E2=80=94=20?= =?UTF-8?q?internal=20process=20documentation?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 3 +++ 1 file changed, 3 insertions(+) diff --git a/.gitignore b/.gitignore index 1f41f710..0bccdc52 100644 --- a/.gitignore +++ b/.gitignore @@ -14,3 +14,6 @@ Cargo.lock # intellij/clion .idea/ + +# Internal process documentation — never upstream +docs/ From 89616bbded4b98822e9851dfd15295d167210822 Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Mon, 13 Apr 2026 23:04:28 -0400 Subject: [PATCH 24/27] feat: add epd3in52 status service and timer for Pi 5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - epd3in52-status.service: oneshot service for Waveshare 3.52" EPD - epd3in52-status.timer: 1h refresh interval (vs 5min for Pi Zero) UC8253 uses full GC refresh only — hourly is appropriate for a development machine display, reduces visual interruption Deployed and verified on Raspberry Pi 5 (aarch64, Raspberry Pi OS). --- scripts/epd3in52-status.service | 15 +++++++++++++++ scripts/epd3in52-status.timer | 12 ++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 scripts/epd3in52-status.service create mode 100644 scripts/epd3in52-status.timer 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 From 529aff05053e7e95739d2b7bcae21b44f6da7c4d Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Mon, 13 Apr 2026 23:09:50 -0400 Subject: [PATCH 25/27] feat: add epd3in52 daily panel exercise service and timer Runs the carcinisation demo at 03:00 daily on Pi 5 nodes. Provides thorough panel exercise (multiple full GC cycles) to prevent UC8253 ghosting from prolonged static display. --- scripts/epd3in52-daily.service | 13 +++++++++++++ scripts/epd3in52-daily.timer | 11 +++++++++++ 2 files changed, 24 insertions(+) create mode 100644 scripts/epd3in52-daily.service create mode 100644 scripts/epd3in52-daily.timer 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 From ba9901d2e04fd7d8f94465057a2954dce989a64b Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Tue, 14 Apr 2026 10:20:36 -0400 Subject: [PATCH 26/27] chore: remove deployment tool from upstream contribution test_rotation.sh is deployment-specific tooling, not part of the driver contribution. Removing from branch before PR review. --- tools/test_rotation.sh | 48 ------------------------------------------ 1 file changed, 48 deletions(-) delete mode 100755 tools/test_rotation.sh diff --git a/tools/test_rotation.sh b/tools/test_rotation.sh deleted file mode 100755 index afbd371c..00000000 --- a/tools/test_rotation.sh +++ /dev/null @@ -1,48 +0,0 @@ -#!/usr/bin/env bash -# test_rotation.sh — test all four display rotations on a target device -# -# Usage: -# bash tools/test_rotation.sh ruby -# bash tools/test_rotation.sh coral -# -# Cycles through 0/90/180/270, waits 20s between each, -# then restores the original rotation from the device. - -set -euo pipefail - -TARGET="${1:-}" -if [[ -z "$TARGET" ]]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -# Read current rotation before we start so we can restore it -ORIGINAL=$(ssh "$TARGET" "grep '^rotation=' /etc/epd-waveshare.conf 2>/dev/null | cut -d= -f2" || echo "0") -echo "Current rotation on $TARGET: ${ORIGINAL}°" -echo "" - -for rot in 0 90 180 270; do - echo "--- Rotation ${rot}° ---" - ssh "$TARGET" "sudo tee /etc/epd-waveshare.conf > /dev/null << 'CONF' -# /etc/epd-waveshare.conf -rotation=${rot} -color_invert=false -CONF" - ssh "$TARGET" "sudo /usr/local/bin/epd2in13_v4_status 2>&1" \ - || ssh "$TARGET" "sudo /home/aken/epd3in52_ruby_status 2>&1" - echo "Waiting 20s..." - sleep 20 -done - -# Restore original rotation -echo "--- Restoring rotation ${ORIGINAL}° ---" -ssh "$TARGET" "sudo tee /etc/epd-waveshare.conf > /dev/null << CONF -# /etc/epd-waveshare.conf -rotation=${ORIGINAL} -color_invert=false -CONF" -ssh "$TARGET" "sudo /usr/local/bin/epd2in13_v4_status 2>&1" \ - || ssh "$TARGET" "sudo /home/aken/epd3in52_ruby_status 2>&1" - -echo "" -echo "Done. $TARGET restored to rotation=${ORIGINAL}°" From a30113824833888e8f72b2a0a14bba81476816ce Mon Sep 17 00:00:00 2001 From: cogwheel886 Date: Tue, 14 Apr 2026 10:23:34 -0400 Subject: [PATCH 27/27] =?UTF-8?q?chore:=20gitignore=20.claude/=20=E2=80=94?= =?UTF-8?q?=20contains=20local=20SSH=20allowlist=20with=20hostnames?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 0bccdc52..74b6ca30 100644 --- a/.gitignore +++ b/.gitignore @@ -17,3 +17,4 @@ Cargo.lock # Internal process documentation — never upstream docs/ +.claude/