From edf3da1a305daafc9d7d964af897345dd358cee8 Mon Sep 17 00:00:00 2001 From: aeroreyna Date: Sun, 21 Jun 2026 21:14:18 -0400 Subject: [PATCH] Add CircuitPython RLCD migration prototype --- .gitignore | 10 ++ circuitpython/README.md | 83 ++++++++++ circuitpython/code.py | 115 ++++++++++++++ circuitpython/deploy_only.sh | 29 ++++ circuitpython/flash_and_deploy.sh | 74 +++++++++ circuitpython/lib/adafruit_framebuf.mpy | Bin 0 -> 5336 bytes circuitpython/lib/audio_cp.py | 130 ++++++++++++++++ circuitpython/lib/gif_player.py | 48 ++++++ circuitpython/lib/rlcd_cp.py | 199 ++++++++++++++++++++++++ 9 files changed, 688 insertions(+) create mode 100644 circuitpython/README.md create mode 100644 circuitpython/code.py create mode 100755 circuitpython/deploy_only.sh create mode 100755 circuitpython/flash_and_deploy.sh create mode 100644 circuitpython/lib/adafruit_framebuf.mpy create mode 100644 circuitpython/lib/audio_cp.py create mode 100644 circuitpython/lib/gif_player.py create mode 100644 circuitpython/lib/rlcd_cp.py diff --git a/.gitignore b/.gitignore index c750cf3..8e19916 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,13 @@ +# Local-only CircuitPython configuration / secrets +settings.toml +.env + +# Firmware downloads and generated bundles +firmware/ +*.bin +*.uf2 +*.zip + # Temporary generated media and screenshots *.pbm *.png diff --git a/circuitpython/README.md b/circuitpython/README.md new file mode 100644 index 0000000..9f6f610 --- /dev/null +++ b/circuitpython/README.md @@ -0,0 +1,83 @@ +# CircuitPython migration prototype for Waveshare ESP32-S3-RLCD-4.2 + +This folder is an initial CircuitPython port attempt for the existing MicroPython ESP32-S3-RLCD project. + +## Goal + +Move the board firmware toward CircuitPython so we can use built-in CircuitPython media modules: + +- `audiomp3` for MP3 playback over I2S +- `gifio` for frame-by-frame GIF decoding + +The current MicroPython project already has working low-level knowledge for: + +- ST7305 reflective LCD init and 2x4 packed monochrome framebuffer +- ES8311 speaker DAC over I2C + I2S +- GPIO46 speaker amp enable +- ESP32-S3 board pins + +## Current status + +This is not a full replacement firmware yet. It is a bootable/iterable prototype layout: + +- `code.py` — demo entrypoint for CircuitPython +- `lib/rlcd_cp.py` — custom ST7305/RLCD driver using `busio`/`digitalio` +- `lib/audio_cp.py` — ES8311 + I2S MP3 playback helper +- `lib/gif_player.py` — GIF-to-RLCD playback helper using `gifio` + +## Why a custom display driver is needed + +Antigravity’s review confirmed the important constraint: ST7305/RLCD is not a normal `displayio` panel here. The existing `rlcd.py` packs pixels into 15,000 bytes where each byte represents a 2×4 pixel block, with vertical inversion. CircuitPython does not appear to have a native `displayio` ST7305 driver for this exact layout, so this port keeps a Python-side canvas and manually packs it before SPI transfer. + +## Hardware pins copied from the working MicroPython firmware + +Display SPI: + +- SCK: IO11 +- MOSI: IO12 +- CS: IO40 +- DC: IO5 +- RST: IO41 + +Audio/I2C: + +- I2C SDA: IO13 +- I2C SCL: IO14 +- I2S BCLK: IO9 +- I2S LRCK/WS: IO45 +- I2S DOUT: IO8 +- MCLK PWM: IO16 +- Speaker amp enable: IO46 + +## Copy to CIRCUITPY + +After flashing a compatible CircuitPython build for the ESP32-S3 N16R8-style board: + +```bash +cp circuitpython/code.py /media/$USER/CIRCUITPY/code.py +mkdir -p /media/$USER/CIRCUITPY/lib +cp circuitpython/lib/*.py /media/$USER/CIRCUITPY/lib/ +``` + +Optional test assets: + +```bash +cp demo.mp3 /media/$USER/CIRCUITPY/demo.mp3 +cp demo.gif /media/$USER/CIRCUITPY/demo.gif +``` + +## Expected next hardware tests + +1. Boot with only `code.py` and libraries copied. +2. Confirm display init + dashboard text appears. +3. Copy a tiny 320px-wide-or-smaller monochrome/low-color GIF and test GIF playback. `gifio` cannot load 400px-wide GIFs on current CircuitPython builds; the prototype centers 320px GIFs on the 400px screen. +4. Copy a short MP3 and test I2S/ES8311 playback. +5. If display is scrambled, compare `pack()` output against MicroPython `rlcd.py` and host `notetaker.py` mapping. + +## Known risks + +- CircuitPython may not expose every `board.IO##` name on the selected build. If so, update `PINS` in `code.py` with the names present in `dir(board)`. +- `pwmio.PWMOut` at 12.288 MHz on IO16 may not work on all CircuitPython ESP32-S3 builds. If MCLK fails, MP3 playback may be silent even if I2S is writing. +- Pure Python display packing loops may be slow. This prototype favors correctness first; optimize with lookup tables after the first visible frame works. +- GIF playback on a 1-bit reflective LCD will be low-framerate and monochrome-thresholded. +- CircuitPython SPI configuration is lock-scoped, so `rlcd_cp.py` configures 20 MHz SPI after every lock. diff --git a/circuitpython/code.py b/circuitpython/code.py new file mode 100644 index 0000000..697788a --- /dev/null +++ b/circuitpython/code.py @@ -0,0 +1,115 @@ +"""CircuitPython entrypoint for Waveshare ESP32-S3-RLCD-4.2 migration. + +Copy this file to CIRCUITPY/code.py after flashing CircuitPython. +""" + +import gc +import os +import time + +import board +import busio +import digitalio + +from audio_cp import BoardAudio +from gif_player import GIFPlayer +from rlcd_cp import RLCD + + +# Keep pin choices in one place because CircuitPython board builds differ in +# how they expose ESP32-S3 pins. If board.IO## is missing, run `dir(board)` on +# the serial console and update these names. +PINS = { + "display_sck": board.IO11, + "display_mosi": board.IO12, + "display_cs": board.IO40, + "display_dc": board.IO5, + "display_rst": board.IO41, + "i2c_sda": board.IO13, + "i2c_scl": board.IO14, + "i2s_bclk": board.IO9, + "i2s_lrck": board.IO45, + "i2s_dout": board.IO8, + "audio_mclk": board.IO16, + "audio_amp": board.IO46, +} + + +def exists(path): + try: + os.stat(path) + return True + except OSError: + return False + + +def init_display(): + spi = busio.SPI(clock=PINS["display_sck"], MOSI=PINS["display_mosi"]) + # Match the working MicroPython code's 20 MHz SPI if the build supports it. + while not spi.try_lock(): + pass + try: + spi.configure(baudrate=20000000, phase=0, polarity=0) + finally: + spi.unlock() + display = RLCD( + spi, + cs=digitalio.DigitalInOut(PINS["display_cs"]), + dc=digitalio.DigitalInOut(PINS["display_dc"]), + rst=digitalio.DigitalInOut(PINS["display_rst"]), + ) + return display + + +def init_audio(): + i2c = busio.I2C(scl=PINS["i2c_scl"], sda=PINS["i2c_sda"]) + return BoardAudio( + i2c, + bit_clock=PINS["i2s_bclk"], + word_select=PINS["i2s_lrck"], + data=PINS["i2s_dout"], + mclk=PINS["audio_mclk"], + amp=PINS["audio_amp"], + ) + + +def draw_boot_screen(display, message="CircuitPython RLCD"): + display.clear(0) + display.text("ESP32-S3-RLCD", 10, 10, 1) + display.line(10, 22, 390, 22, 1) + display.text_large("CIRCUITPY", 18, 45, scale=3, color=1) + display.text(message, 18, 86, 1) + display.text("MP3: /demo.mp3", 18, 116, 1) + display.text("GIF: /demo.gif", 18, 132, 1) + display.text("If visible, ST7305 init works", 18, 170, 1) + display.show() + + +def main(): + display = init_display() + draw_boot_screen(display, "Display initialized") + time.sleep(1) + + # Try GIF first because it verifies display animation support. + if exists("/demo.gif"): + draw_boot_screen(display, "Playing GIF...") + GIFPlayer(display).play("/demo.gif", loops=1) + draw_boot_screen(display, "GIF done") + gc.collect() + + # Try MP3 if present. If this fails silently, verify MCLK on IO16 and codec + # register sequence against a known-good WAV/tone first. + if exists("/demo.mp3"): + draw_boot_screen(display, "Playing MP3...") + try: + audio = init_audio() + ok = audio.play_mp3("/demo.mp3", volume=65) + draw_boot_screen(display, "MP3 ok" if ok else "MP3 failed") + except Exception as exc: + draw_boot_screen(display, "MP3 error: " + str(exc)[:28]) + + while True: + time.sleep(5) + + +main() diff --git a/circuitpython/deploy_only.sh b/circuitpython/deploy_only.sh new file mode 100755 index 0000000..b256a4d --- /dev/null +++ b/circuitpython/deploy_only.sh @@ -0,0 +1,29 @@ +#!/usr/bin/env bash +set -euo pipefail +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +CIRCUITPY_MOUNT="${CIRCUITPY_MOUNT:-}" + +if [[ -z "$CIRCUITPY_MOUNT" ]]; then + for candidate in \ + "/media/$USER/CIRCUITPY" \ + "/run/media/$USER/CIRCUITPY" \ + "/media/$(id -un)/CIRCUITPY" \ + "/Volumes/CIRCUITPY"; do + if [[ -d "$candidate" ]]; then + CIRCUITPY_MOUNT="$candidate" + break + fi + done +fi + +if [[ -z "$CIRCUITPY_MOUNT" || ! -d "$CIRCUITPY_MOUNT" ]]; then + echo "CIRCUITPY mount not found. Set CIRCUITPY_MOUNT=/path/to/CIRCUITPY" >&2 + exit 1 +fi + +mkdir -p "$CIRCUITPY_MOUNT/lib" +cp "$ROOT/circuitpython/code.py" "$CIRCUITPY_MOUNT/code.py" +cp "$ROOT/circuitpython/lib/"*.py "$CIRCUITPY_MOUNT/lib/" +cp "$ROOT/circuitpython/lib/"*.mpy "$CIRCUITPY_MOUNT/lib/" +sync +find "$CIRCUITPY_MOUNT" -maxdepth 2 -type f | sort diff --git a/circuitpython/flash_and_deploy.sh b/circuitpython/flash_and_deploy.sh new file mode 100755 index 0000000..ab8633f --- /dev/null +++ b/circuitpython/flash_and_deploy.sh @@ -0,0 +1,74 @@ +#!/usr/bin/env bash +set -euo pipefail + +PORT="${PORT:-/dev/ttyACM0}" +ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +FIRMWARE="$ROOT/firmware/adafruit-circuitpython-espressif_esp32s3_devkitc_1_n8r8-en_US-10.2.1.bin" +CIRCUITPY_MOUNT="${CIRCUITPY_MOUNT:-}" + +if [[ ! -f "$FIRMWARE" ]]; then + echo "Firmware not found: $FIRMWARE" >&2 + exit 1 +fi + +if [[ ! -r "$PORT" || ! -w "$PORT" ]]; then + cat >&2 < Probing ESP32-S3 on $PORT" +uvx esptool --chip esp32s3 --port "$PORT" flash-id + +echo "==> Erasing flash" +uvx esptool --chip esp32s3 --port "$PORT" erase-flash + +echo "==> Flashing CircuitPython N8R8 firmware" +uvx esptool --chip esp32s3 --port "$PORT" --baud 460800 write-flash -z 0x0 "$FIRMWARE" + +echo "==> Waiting for CIRCUITPY mount" +for _ in $(seq 1 60); do + if [[ -n "$CIRCUITPY_MOUNT" && -d "$CIRCUITPY_MOUNT" ]]; then + break + fi + for candidate in \ + "/media/$USER/CIRCUITPY" \ + "/run/media/$USER/CIRCUITPY" \ + "/media/$(id -un)/CIRCUITPY" \ + "/Volumes/CIRCUITPY"; do + if [[ -d "$candidate" ]]; then + CIRCUITPY_MOUNT="$candidate" + break 2 + fi + done + sleep 1 +done + +if [[ -z "$CIRCUITPY_MOUNT" || ! -d "$CIRCUITPY_MOUNT" ]]; then + cat >&2 < Deploying prototype to $CIRCUITPY_MOUNT" +mkdir -p "$CIRCUITPY_MOUNT/lib" +cp "$ROOT/circuitpython/code.py" "$CIRCUITPY_MOUNT/code.py" +cp "$ROOT/circuitpython/lib/"*.py "$CIRCUITPY_MOUNT/lib/" +cp "$ROOT/circuitpython/lib/"*.mpy "$CIRCUITPY_MOUNT/lib/" +sync + +echo "==> Deployed. Files on CIRCUITPY:" +find "$CIRCUITPY_MOUNT" -maxdepth 2 -type f | sort + +echo "==> Done. Reset the board if it does not reload automatically." diff --git a/circuitpython/lib/adafruit_framebuf.mpy b/circuitpython/lib/adafruit_framebuf.mpy new file mode 100644 index 0000000000000000000000000000000000000000..a3dd8881496598f4a6e24000d25fad4d23cc8237 GIT binary patch literal 5336 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 threshold is black. + """ + gif = gifio.OnDiskGif(filename) + try: + count = 0 + while loops < 0 or count < loops: + while True: + delay = gif.next_frame() + if delay is None: + break + if clear_between_frames: + self.display.clear(0) + self.display.draw_bitmap_threshold(gif.bitmap, x, y, threshold=threshold) + self.display.show() + # gifio delay is seconds in modern CircuitPython builds. If + # an older build plays 100x too slowly, divide by 100 here. + time.sleep(max(0.0, delay)) + count += 1 + if loops < 0 or count < loops: + # OnDiskGif has no reliable seek/rewind API; reopen to loop. + gif.deinit() + gc.collect() + gif = gifio.OnDiskGif(filename) + finally: + try: + gif.deinit() + except AttributeError: + pass + gc.collect() diff --git a/circuitpython/lib/rlcd_cp.py b/circuitpython/lib/rlcd_cp.py new file mode 100644 index 0000000..e1ebe86 --- /dev/null +++ b/circuitpython/lib/rlcd_cp.py @@ -0,0 +1,199 @@ +"""CircuitPython ST7305/RLCD driver for Waveshare ESP32-S3-RLCD-4.2. + +This is a direct migration attempt from the project's MicroPython rlcd.py. +It intentionally does not use displayio's display bus because the panel uses a +non-standard 2x4 packed 1-bit memory layout. +""" + +import time + +try: + import adafruit_framebuf +except ImportError: # CircuitPython bundle dependency. + adafruit_framebuf = None + + +class RLCD: + WIDTH = 400 + HEIGHT = 300 + BUFFER_SIZE = (WIDTH * HEIGHT) // 8 + + def __init__(self, spi, cs, dc, rst, width=WIDTH, height=HEIGHT): + if adafruit_framebuf is None: + raise RuntimeError("adafruit_framebuf is required in CIRCUITPY/lib") + self.spi = spi + self.cs = cs + self.dc = dc + self.rst = rst + self.width = width + self.height = height + self.hw_buffer = bytearray((width * height) // 8) + self.canvas_buffer = bytearray((width * height) // 8) + self.canvas = adafruit_framebuf.FrameBuffer( + self.canvas_buffer, + width, + height, + adafruit_framebuf.MHMSB, + ) + + self.cs.switch_to_output(value=True) + self.dc.switch_to_output(value=False) + self.rst.switch_to_output(value=True) + + self.reset() + self.init_display() + + def reset(self): + self.rst.value = True + time.sleep(0.05) + self.rst.value = False + time.sleep(0.02) + self.rst.value = True + time.sleep(0.05) + + def _lock_spi(self): + while not self.spi.try_lock(): + pass + # CircuitPython SPI settings are only guaranteed while the bus is + # locked, so configure after every lock instead of only at startup. + self.spi.configure(baudrate=20000000, phase=0, polarity=0) + + def _unlock_spi(self): + self.spi.unlock() + + def write_cmd(self, cmd): + self._lock_spi() + try: + self.cs.value = False + self.dc.value = False + self.spi.write(bytes([cmd & 0xFF])) + self.cs.value = True + finally: + self._unlock_spi() + + def write_data(self, data): + if isinstance(data, int): + payload = bytes([data & 0xFF]) + elif isinstance(data, (bytes, bytearray, memoryview)): + payload = data + else: + payload = bytes(data) + self._lock_spi() + try: + self.cs.value = False + self.dc.value = True + self.spi.write(payload) + self.cs.value = True + finally: + self._unlock_spi() + + def init_display(self): + # Ported from MicroPython rlcd.py. The old file had one decimal 19 in + # the 0xC5 payload; this port uses 0x19 consistently. + self.write_cmd(0xD6); self.write_data([0x17, 0x02]) + self.write_cmd(0xD1); self.write_data(0x01) + self.write_cmd(0xC0); self.write_data([0x11, 0x04]) + self.write_cmd(0xC1); self.write_data([0x41, 0x41, 0x41, 0x41]) + self.write_cmd(0xC2); self.write_data([0x19, 0x19, 0x19, 0x19]) + self.write_cmd(0xC4); self.write_data([0x41, 0x41, 0x41, 0x41]) + self.write_cmd(0xC5); self.write_data([0x19, 0x19, 0x19, 0x19]) + self.write_cmd(0xD8); self.write_data([0xA6, 0xE9]) + self.write_cmd(0xB2); self.write_data(0x05) + self.write_cmd(0xB3); self.write_data([0xE5, 0xF6, 0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45]) + self.write_cmd(0xB4); self.write_data([0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45]) + self.write_cmd(0x62); self.write_data([0x32, 0x03, 0x1F]) + self.write_cmd(0xB7); self.write_data(0x13) + self.write_cmd(0xB0); self.write_data(0x64) + self.write_cmd(0x11) + time.sleep(0.2) + self.write_cmd(0xC9); self.write_data(0x00) + self.write_cmd(0x36); self.write_data(0x48) + self.write_cmd(0x3A); self.write_data(0x11) + self.write_cmd(0xB9); self.write_data(0x20) + self.write_cmd(0xB8); self.write_data(0x29) + self.write_cmd(0x21) + self.write_cmd(0x2A); self.write_data([0x12, 0x2A]) + self.write_cmd(0x2B); self.write_data([0x00, 0xC7]) + self.write_cmd(0x35); self.write_data(0x00) + self.write_cmd(0xD0); self.write_data(0xFF) + self.write_cmd(0x38) + self.write_cmd(0x29) + + def clear(self, color=0): + self.canvas.fill(1 if color else 0) + + def pixel(self, x, y, color): + self.canvas.pixel(x, y, 1 if color else 0) + + def line(self, x0, y0, x1, y1, color): + self.canvas.line(x0, y0, x1, y1, 1 if color else 0) + + def rect(self, x, y, width, height, color): + self.canvas.rect(x, y, width, height, 1 if color else 0) + + def fill_rect(self, x, y, width, height, color): + self.canvas.fill_rect(x, y, width, height, 1 if color else 0) + + def text(self, text, x, y, color=1): + self.canvas.text(str(text), x, y, 1 if color else 0) + + def text_large(self, text, x, y, scale=2, color=1): + # Simple nearest-neighbor scaled 8x8 font rendering using a temp buffer. + tmp = bytearray(8) + fb = adafruit_framebuf.FrameBuffer(tmp, 8, 8, adafruit_framebuf.MHMSB) + color = 1 if color else 0 + for ch in str(text): + fb.fill(0) + fb.text(ch, 0, 0, 1) + for py in range(8): + for px in range(8): + if fb.pixel(px, py): + self.canvas.fill_rect(x + px * scale, y + py * scale, scale, scale, color) + x += 8 * scale + + def draw_bitmap_threshold(self, bitmap, x=0, y=0, threshold=1): + """Copy any indexable bitmap-like object to the 1-bit canvas. + + gifio/displayio bitmaps return palette indexes. Treat index 0 as white + and anything >= threshold as black by default. + """ + width = min(getattr(bitmap, "width", self.width), self.width - x) + height = min(getattr(bitmap, "height", self.height), self.height - y) + for yy in range(height): + for xx in range(width): + self.canvas.pixel(x + xx, y + yy, 1 if bitmap[xx, yy] >= threshold else 0) + + def pack(self): + """Convert standard horizontal 1-bit canvas into the RLCD 2x4 layout.""" + buf = self.hw_buffer + for i in range(len(buf)): + buf[i] = 0 + + # Correctness-first port of MicroPython native loop. + height = self.height + for y in range(height): + inv_y = height - 1 - y + block_y = inv_y // 4 + local_y = inv_y & 0x03 + local_y_shift = local_y * 2 + row_offset = block_y + for x in range(self.width): + if self.canvas.pixel(x, y): + byte_x = x >> 1 + index = byte_x * 75 + row_offset + local_x = x & 0x01 + bit = 7 - (local_y_shift + local_x) + buf[index] |= 1 << bit + return buf + + def show(self): + self.pack() + self.write_cmd(0x2A) + self.write_data([0x12, 0x2A]) + self.write_cmd(0x2B) + self.write_data([0x00, 0xC7]) + self.write_cmd(0x2C) + self.write_data(self.hw_buffer) + + def invert(self, enable=True): + self.write_cmd(0x21 if enable else 0x20)