Add CircuitPython RLCD migration prototype

This commit is contained in:
aeroreyna
2026-06-21 21:14:18 -04:00
parent 2bc8fbeaca
commit edf3da1a30
9 changed files with 688 additions and 0 deletions
+10
View File
@@ -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 # Temporary generated media and screenshots
*.pbm *.pbm
*.png *.png
+83
View File
@@ -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
Antigravitys 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.
+115
View File
@@ -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()
+29
View File
@@ -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
+74
View File
@@ -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 <<EOF
Cannot access $PORT as $(id -un).
Temporary fix from a local terminal:
sudo chmod 666 $PORT
Permanent fix:
sudo usermod -aG dialout $(id -un)
# then log out/in, or start a new shell with: newgrp dialout
EOF
exit 2
fi
echo "==> 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 <<EOF
Flashing finished, but CIRCUITPY did not auto-mount.
Reset the board once, then rerun just deployment with:
CIRCUITPY_MOUNT=/path/to/CIRCUITPY bash circuitpython/deploy_only.sh
EOF
exit 3
fi
echo "==> 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."
Binary file not shown.
+130
View File
@@ -0,0 +1,130 @@
"""CircuitPython audio helpers for Waveshare ESP32-S3-RLCD-4.2.
Ports the ES8311 setup sequence from MicroPython audio_util.py and adds MP3
playback using CircuitPython's audiomp3 + audiobusio stack.
"""
import time
import audiobusio
import audiomp3
import digitalio
import pwmio
class ES8311:
ADDR = 0x18
def __init__(self, i2c):
self.i2c = i2c
def _write(self, register, value):
# CircuitPython I2CDevice is not used to keep this dependency-free.
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, bytes([register & 0xFF, value & 0xFF]))
finally:
self.i2c.unlock()
def init(self, sample_rate=44100):
"""Initialize ES8311 for I2S DAC playback.
The register sequence is the existing MicroPython project's known-good
sequence. The original comments targeted 16 kHz WAV playback, but the
I2S peripheral is configured with the MP3 decoder sample rate at play
time. If MP3 output is silent, hardware-test this sequence first with a
16 kHz WAV/tone and then tune codec dividers for the MP3 sample rate.
"""
del sample_rate # Kept for API clarity; sequence currently fixed.
self._write(0x00, 0x1F)
time.sleep(0.01)
self._write(0x00, 0x00)
time.sleep(0.01)
self._write(0x01, 0x3F)
self._write(0x02, 0x48)
self._write(0x03, 0x10)
self._write(0x04, 0x20)
self._write(0x05, 0x00)
self._write(0x06, 0x03)
self._write(0x07, 0x00)
self._write(0x08, 0xFF)
self._write(0x09, 0x0C)
self._write(0x0A, 0x0C)
self._write(0x0D, 0x01)
self._write(0x0E, 0x02)
self._write(0x12, 0x00)
self._write(0x13, 0x10)
self._write(0x1C, 0x6A)
self._write(0x37, 0x08)
self._write(0x32, 0xBF)
self._write(0x31, 0x00)
self._write(0x00, 0x80)
def set_volume(self, volume):
volume = max(0, min(100, int(volume)))
self._write(0x32, int(volume * 255 / 100))
class BoardAudio:
def __init__(self, i2c, *, bit_clock, word_select, data, mclk, amp):
self.i2c = i2c
self.bit_clock_pin = bit_clock
self.word_select_pin = word_select
self.data_pin = data
self.mclk_pin = mclk
self.amp_pin = amp
self._mclk = None
self._amp = digitalio.DigitalInOut(amp)
self._amp.switch_to_output(value=False)
self.codec = ES8311(i2c)
def start_mclk(self):
if self._mclk is None:
# External MCLK for ES8311. CircuitPython I2SOut on ESP32-S3 does
# not accept a main_clock parameter, so keep this independent.
self._mclk = pwmio.PWMOut(
self.mclk_pin,
frequency=12288000,
duty_cycle=32768,
variable_frequency=False,
)
def stop_mclk(self):
if self._mclk is not None:
self._mclk.deinit()
self._mclk = None
def play_mp3(self, filename, volume=60):
"""Play an MP3 file from CIRCUITPY storage over the onboard speaker."""
self.start_mclk()
self.codec.init()
self.codec.set_volume(volume)
decoder = None
audio = None
f = None
try:
f = open(filename, "rb")
decoder = audiomp3.MP3Decoder(f)
# Use decoder-derived rate if available, else fall back.
sample_rate = getattr(decoder, "sample_rate", 44100)
audio = audiobusio.I2SOut(
bit_clock=self.bit_clock_pin,
word_select=self.word_select_pin,
data=self.data_pin,
)
self._amp.value = True
audio.play(decoder)
while audio.playing:
time.sleep(0.05)
return True
finally:
self._amp.value = False
if audio is not None:
audio.deinit()
if decoder is not None:
decoder.deinit()
if f is not None:
f.close()
# Leave MCLK running for repeated playback; call stop_mclk() before
# deep sleep if power matters.
+48
View File
@@ -0,0 +1,48 @@
"""GIF playback helper for the custom RLCD CircuitPython driver."""
import gc
import time
import gifio
class GIFPlayer:
def __init__(self, display):
self.display = display
def play(self, filename, *, loops=1, x=40, y=0, threshold=1, clear_between_frames=False):
"""Decode a GIF from disk and push frames to the reflective LCD.
CircuitPython gifio currently supports GIFs up to 320 pixels wide, so
x defaults to 40 to center a 320-wide GIF on this 400-wide display.
The ST7305/RLCD display is monochrome in this project, so frames are
thresholded from palette indexes: palette index 0 is white; any index
>= 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()
+199
View File
@@ -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)