Skip to content

feat: add SSD1680 (Waveshare 2.13" V4) and UC8253 (Waveshare 3.52") drivers#255

Open
cogwheel886 wants to merge 27 commits intorust-embedded-community:mainfrom
cogwheel886:feat/epd2in13-v4
Open

feat: add SSD1680 (Waveshare 2.13" V4) and UC8253 (Waveshare 3.52") drivers#255
cogwheel886 wants to merge 27 commits intorust-embedded-community:mainfrom
cogwheel886:feat/epd2in13-v4

Conversation

@cogwheel886
Copy link
Copy Markdown

@cogwheel886 cogwheel886 commented Apr 11, 2026

Summary

Adds two new e-paper display drivers:

  • Waveshare 2.13" EPD V4 — SSD1680 controller, 250x122px, three-state partial refresh (full to base to partial), optional PWR pin (GPIO18)
  • Waveshare 3.52" EPD — UC8253 controller, 360x240px, full and DU (quick) refresh via RefreshLut, alternating lut_flag sequence

Consolidates and supersedes #254 which had the 3.52" driver in an earlier state without hardware-verified fixes.

Hardware tested

  • Waveshare 2.13" e-Paper HAT V4 (SSD1680) on Raspberry Pi Zero 2WH (aarch64)
  • Waveshare 3.52" e-Paper HAT (UC8253) on Raspberry Pi 5 (aarch64)

Both running Raspberry Pi OS (Bookworm, 64-bit).

Examples

  • epd2in13_v4.rs -- Ferris walking demo demonstrating SSD1680 partial refresh: Ferris the Rustacean walks left/right across the panel with no full-refresh flash between steps
  • epd2in13_v4_status.rs -- system status display for the 2.13" panel (hostname, IP, CPU temp, RAM, disk, uptime, optional PiSugar 3 battery). Demonstrates the three-state partial refresh sequence in a real deployment. PiSugar battery status degrades gracefully to N/A without the daemon.
  • epd3in52.rs -- carcinisation demo demonstrating UC8253 DU (quick) refresh: 8-frame deterministic cross-dissolve from a lobster to Ferris the Rustacean, with "Carcinisation" and "Rustacean" captions
  • epd3in52_status.rs -- system status display for the 3.52" panel (hostname, IP, CPU temp, RAM, disk, uptime, optional PiSugar 3 battery)

All examples use gpio_cdev rather than the deprecated sysfs_gpio, required for Raspberry Pi OS Bookworm and Pi 5 compatibility.

Implementation notes

  • src/interface.rs gains soft_reset() -- RST LOW to HIGH with no trailing 200ms delay, used by SSD1680 partial refresh path
  • epd3in52::update_partial_frame delegates to update_frame -- UC8253 has no hardware windowing; DU mode via RefreshLut::Quick is the correct fast-refresh path
  • UC8253 reset timing corrected to 200ms/10ms matching the Waveshare Python reference driver (was 30us/10us)
  • SSD1680 DisplayUpdateControl2 bytes annotated (0xF7/0xC7/0xFF)
  • UC8253 init register sequence annotated against Python reference driver

Checklist

  • Tested on real hardware (both displays)
  • cargo fmt applied
  • cargo clippy clean (3 pre-existing upstream warnings in src/graphics.rs and src/color.rs -- empty diff vs upstream/main)
  • cargo test -- 58 passed, 0 failed
  • Feature flags added (epd2in13_v4, epd3in52)
  • Examples registered in Cargo.toml with required-features
  • Closes epd2in13_v4 support? #207

cogwheel886 added 6 commits April 11, 2026 01:53
- 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)
- 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 rust-embedded-community#207
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 rust-embedded-community#207
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.
…eboots

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
cogwheel886 pushed a commit to cogwheel886/epd-waveshare that referenced this pull request Apr 12, 2026
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 rust-embedded-community#255.
cogwheel886 and others added 12 commits April 12, 2026 22: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
- 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
- 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.
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 rust-embedded-community#255.
- 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
- 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
- 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)
…vice

Usage:
  bash tools/test_rotation.sh <hostname>

Reads current rotation before starting, restores on completion.
20s settle time between rotations to avoid timing issues on SSD1680.
- 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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
Remove deployment-specific hostname from example filename.
Update Cargo.toml [[example]] name to match.
cogwheel886 and others added 2 commits April 12, 2026 23:25
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) <noreply@anthropic.com>
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).
@cogwheel886 cogwheel886 changed the title feat: add support for Waveshare 2.13" EPD V4 (SSD1680) feat: add SSD1680 (Waveshare 2.13" V4) and UC8253 (Waveshare 3.52") drivers Apr 13, 2026
cogwheel886 and others added 6 commits April 13, 2026 00:29
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) <noreply@anthropic.com>
- 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) <noreply@anthropic.com>
- 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).
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.
test_rotation.sh is deployment-specific tooling, not part of the
driver contribution. Removing from branch before PR review.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

epd2in13_v4 support?

1 participant