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
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)