Add CircuitPython RLCD migration prototype
This commit is contained in:
Binary file not shown.
@@ -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.
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
Reference in New Issue
Block a user