Migrate MCP and utilities to CircuitPython, including color GIF and volume fixes
This commit is contained in:
+406
-82
@@ -1,6 +1,7 @@
|
||||
"""CircuitPython entrypoint for Waveshare ESP32-S3-RLCD-4.2 migration.
|
||||
"""CircuitPython entrypoint for Waveshare ESP32-S3-RLCD-4.2 and Hosyond ESP32-S3 Touchscreen.
|
||||
|
||||
Copy this file to CIRCUITPY/code.py after flashing CircuitPython.
|
||||
Performs I2C scanning to auto-detect the connected board type, dynamically initializes
|
||||
all peripheral drivers, connects to Wi-Fi, and starts the MCP server and video stream servers.
|
||||
"""
|
||||
|
||||
import gc
|
||||
@@ -10,106 +11,429 @@ import time
|
||||
import board
|
||||
import busio
|
||||
import digitalio
|
||||
import wifi
|
||||
import socketpool
|
||||
import adafruit_requests
|
||||
|
||||
# Import drivers
|
||||
from audio_cp import BoardAudio
|
||||
from gif_player import GIFPlayer
|
||||
from rlcd_cp import RLCD
|
||||
from ili9341_cp import ILI9341
|
||||
from ft6336u_cp import FT6336U
|
||||
from battery_cp import BatteryMonitor
|
||||
from shtc3_cp import SHTC3
|
||||
from rtc_cp import PCF85063
|
||||
from rgb_led_cp import BoardLED
|
||||
from button_cp import BoardButtons
|
||||
from ble_cp import BLEUART
|
||||
from video_stream_cp import VideoStreamServer
|
||||
from mcp_server_cp import MCPServer
|
||||
import wifi_config
|
||||
|
||||
# Global variables for hardware
|
||||
board_type = None # 'HOSYOND' or 'WAVESHARE_RLCD'
|
||||
display = None
|
||||
touch = None
|
||||
audio = None
|
||||
sensor = None
|
||||
rtc_chip = None
|
||||
battery = None
|
||||
led = None
|
||||
buttons = None
|
||||
ble_uart = None
|
||||
ip_addr = "Offline"
|
||||
|
||||
# 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 detect_and_init():
|
||||
global board_type, display, touch, audio, sensor, rtc_chip, battery, led, buttons, ble_uart
|
||||
|
||||
print("Scanning for board type...")
|
||||
|
||||
def exists(path):
|
||||
# Try Hosyond pins first (SDA=IO16, SCL=IO15)
|
||||
try:
|
||||
os.stat(path)
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
i2c = busio.I2C(scl=board.IO15, sda=board.IO16)
|
||||
while not i2c.try_lock():
|
||||
pass
|
||||
devices = i2c.scan()
|
||||
i2c.unlock()
|
||||
i2c.deinit()
|
||||
except Exception as e:
|
||||
devices = []
|
||||
|
||||
if 0x38 in devices:
|
||||
print("Detected Board: HOSYOND (Color Screen + Touch)")
|
||||
board_type = 'HOSYOND'
|
||||
|
||||
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
|
||||
# Display: ILI9341 (320x240)
|
||||
spi = busio.SPI(clock=board.IO12, MOSI=board.IO11)
|
||||
display = ILI9341(
|
||||
spi,
|
||||
cs=digitalio.DigitalInOut(board.IO10),
|
||||
dc=digitalio.DigitalInOut(board.IO46),
|
||||
bl=board.IO45,
|
||||
rst=None,
|
||||
)
|
||||
|
||||
# Touch: FT6336U
|
||||
i2c_bus = busio.I2C(scl=board.IO15, sda=board.IO16)
|
||||
touch = FT6336U(
|
||||
i2c_bus,
|
||||
rst_pin=digitalio.DigitalInOut(board.IO18),
|
||||
int_pin=digitalio.DigitalInOut(board.IO17),
|
||||
width=320,
|
||||
height=240,
|
||||
swap_xy=True,
|
||||
invert_x=False,
|
||||
invert_y=True,
|
||||
)
|
||||
|
||||
# Audio: ES8311
|
||||
audio = BoardAudio(
|
||||
i2c_bus,
|
||||
bit_clock=board.IO5,
|
||||
word_select=board.IO7,
|
||||
data=board.IO8,
|
||||
mclk=board.IO4,
|
||||
amp=board.IO1,
|
||||
amp_active_level=0, # Active Low
|
||||
)
|
||||
|
||||
battery = BatteryMonitor(board.IO9)
|
||||
led = BoardLED(board.IO48)
|
||||
buttons = BoardButtons(board.IO0, key_pin=None)
|
||||
|
||||
ble_name = "ESP32-S3-Touch"
|
||||
try:
|
||||
ble_uart = BLEUART(name=ble_name)
|
||||
except Exception as ble_err:
|
||||
print(f"BLE init failed: {ble_err}")
|
||||
return
|
||||
|
||||
# Try Waveshare RLCD pins (SDA=IO13, SCL=IO14)
|
||||
try:
|
||||
spi.configure(baudrate=20000000, phase=0, polarity=0)
|
||||
finally:
|
||||
spi.unlock()
|
||||
display = RLCD(
|
||||
i2c = busio.I2C(scl=board.IO14, sda=board.IO13)
|
||||
while not i2c.try_lock():
|
||||
pass
|
||||
devices = i2c.scan()
|
||||
i2c.unlock()
|
||||
i2c.deinit()
|
||||
except Exception as e:
|
||||
devices = []
|
||||
|
||||
if 0x70 in devices or 0x51 in devices:
|
||||
print("Detected Board: WAVESHARE_RLCD (Monochrome RLCD)")
|
||||
board_type = 'WAVESHARE_RLCD'
|
||||
|
||||
# Display: RLCD (400x300)
|
||||
spi = busio.SPI(clock=board.IO11, MOSI=board.IO12)
|
||||
display = RLCD(
|
||||
spi,
|
||||
cs=digitalio.DigitalInOut(board.IO40),
|
||||
dc=digitalio.DigitalInOut(board.IO5),
|
||||
rst=digitalio.DigitalInOut(board.IO41),
|
||||
)
|
||||
|
||||
# Audio: ES8311
|
||||
i2c_bus = busio.I2C(scl=board.IO14, sda=board.IO13)
|
||||
audio = BoardAudio(
|
||||
i2c_bus,
|
||||
bit_clock=board.IO9,
|
||||
word_select=board.IO45,
|
||||
data=board.IO8,
|
||||
mclk=board.IO16,
|
||||
amp=board.IO46,
|
||||
amp_active_level=1, # Active High
|
||||
)
|
||||
|
||||
battery = BatteryMonitor(board.IO9)
|
||||
led = BoardLED(board.IO38)
|
||||
buttons = BoardButtons(board.IO0, key_pin=board.IO47)
|
||||
|
||||
# Environment & Clock
|
||||
try:
|
||||
sensor = SHTC3(i2c_bus)
|
||||
except Exception as e:
|
||||
print("Failed to init SHTC3:", e)
|
||||
try:
|
||||
rtc_chip = PCF85063(i2c_bus)
|
||||
except Exception as e:
|
||||
print("Failed to init PCF85063:", e)
|
||||
|
||||
ble_name = "ESP32-S3-RLCD"
|
||||
try:
|
||||
ble_uart = BLEUART(name=ble_name)
|
||||
except Exception as ble_err:
|
||||
print(f"BLE init failed: {ble_err}")
|
||||
return
|
||||
|
||||
# Fallback to Hosyond
|
||||
print("Board scan failed. Falling back to HOSYOND defaults...")
|
||||
board_type = 'HOSYOND'
|
||||
spi = busio.SPI(clock=board.IO12, MOSI=board.IO11)
|
||||
display = ILI9341(
|
||||
spi,
|
||||
cs=digitalio.DigitalInOut(PINS["display_cs"]),
|
||||
dc=digitalio.DigitalInOut(PINS["display_dc"]),
|
||||
rst=digitalio.DigitalInOut(PINS["display_rst"]),
|
||||
cs=digitalio.DigitalInOut(board.IO10),
|
||||
dc=digitalio.DigitalInOut(board.IO46),
|
||||
bl=board.IO45,
|
||||
rst=None,
|
||||
)
|
||||
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"],
|
||||
i2c_bus = busio.I2C(scl=board.IO15, sda=board.IO16)
|
||||
try:
|
||||
touch = FT6336U(
|
||||
i2c_bus,
|
||||
rst_pin=digitalio.DigitalInOut(board.IO18),
|
||||
int_pin=digitalio.DigitalInOut(board.IO17),
|
||||
width=320,
|
||||
height=240,
|
||||
swap_xy=True,
|
||||
invert_x=False,
|
||||
invert_y=True,
|
||||
)
|
||||
except Exception as te:
|
||||
print("Fallback touch init failed:", te)
|
||||
audio = BoardAudio(
|
||||
i2c_bus,
|
||||
bit_clock=board.IO5,
|
||||
word_select=board.IO7,
|
||||
data=board.IO8,
|
||||
mclk=board.IO4,
|
||||
amp=board.IO1,
|
||||
amp_active_level=0,
|
||||
)
|
||||
|
||||
|
||||
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()
|
||||
|
||||
battery = BatteryMonitor(board.IO9)
|
||||
led = BoardLED(board.IO48)
|
||||
buttons = BoardButtons(board.IO0)
|
||||
|
||||
ble_name = "ESP32-S3-Touch"
|
||||
try:
|
||||
ble_uart = BLEUART(name=ble_name)
|
||||
except Exception as ble_err:
|
||||
print(f"BLE init failed: {ble_err}")
|
||||
|
||||
def main():
|
||||
display = init_display()
|
||||
draw_boot_screen(display, "Display initialized")
|
||||
time.sleep(1)
|
||||
global ip_addr
|
||||
detect_and_init()
|
||||
|
||||
# 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()
|
||||
# Draw initial dashboard
|
||||
display.clear(0)
|
||||
display.text("CircuitPython Active", 10, 10, 1)
|
||||
display.text("Connecting Wi-Fi...", 10, 30, 1)
|
||||
display.show()
|
||||
|
||||
# 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...")
|
||||
# Sync system clock from RTC chip if available
|
||||
if rtc_chip:
|
||||
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])
|
||||
rtc_chip.sync_to_system()
|
||||
except Exception as e:
|
||||
print("Failed to sync clock:", e)
|
||||
|
||||
# Connect to Wi-Fi
|
||||
WIFI_SSID = getattr(wifi_config, "WIFI_SSID", "FamReynaMesh")
|
||||
WIFI_PASS = getattr(wifi_config, "WIFI_PASS", "Gloria2020")
|
||||
print(f"Connecting to Wi-Fi SSID '{WIFI_SSID}'...")
|
||||
try:
|
||||
wifi.radio.connect(WIFI_SSID, WIFI_PASS)
|
||||
ip_addr = str(wifi.radio.ipv4_address)
|
||||
print(f"Wi-Fi Connected! IP Address: {ip_addr}")
|
||||
except Exception as wifi_err:
|
||||
print(f"Wi-Fi Connection failed: {wifi_err}")
|
||||
ip_addr = "Disconnected"
|
||||
|
||||
# Initialize socket pool and request session
|
||||
pool = socketpool.SocketPool(wifi.radio)
|
||||
session = adafruit_requests.Session(pool)
|
||||
|
||||
# Start Video Stream Server
|
||||
vstream = VideoStreamServer(display, pool, tcp_port=8081, udp_port=8082, color_port=8083)
|
||||
vstream.start()
|
||||
|
||||
# Start MCP Server
|
||||
mcp = MCPServer(
|
||||
display, led, battery, sensor, rtc_chip, ble_uart,
|
||||
pool=pool, vstream=vstream, touch=touch, session=session, audio=audio
|
||||
)
|
||||
mcp.start(port=80)
|
||||
|
||||
# Sync time from NTP if connected
|
||||
if ip_addr != "Disconnected":
|
||||
try:
|
||||
mcp._call_tool("sync_time", {})
|
||||
except Exception as ntp_err:
|
||||
print(f"NTP Time sync failed: {ntp_err}")
|
||||
|
||||
# Set default LED mode: Green Breathing
|
||||
led_modes = [
|
||||
("Red (Breathing)", lambda l: l.set_color(40, 0, 0), "breath"),
|
||||
("Green (Breathing)", lambda l: l.set_color(0, 40, 0), "breath"),
|
||||
("Blue (Breathing)", lambda l: l.set_color(0, 0, 40), "breath"),
|
||||
("Cyan (Breathing)", lambda l: l.set_color(0, 30, 30), "breath"),
|
||||
("Magenta (Breathing)", lambda l: l.set_color(30, 0, 30), "breath"),
|
||||
("Rainbow Cycle", lambda l: l.set_color(30, 30, 30), "rainbow"),
|
||||
("LED Off", lambda l: l.off(), "off")
|
||||
]
|
||||
local_led_mode_idx = 1 # Green breathing
|
||||
led_modes[local_led_mode_idx][1](led)
|
||||
mcp.active_led_mode = led_modes[local_led_mode_idx][2]
|
||||
|
||||
# Button callbacks
|
||||
last_action_str = "Boot finished."
|
||||
force_dashboard_redraw = True
|
||||
|
||||
def on_key_click():
|
||||
nonlocal local_led_mode_idx, last_action_str, force_dashboard_redraw
|
||||
local_led_mode_idx = (local_led_mode_idx + 1) % len(led_modes)
|
||||
mode_name, color_fn, mode_type = led_modes[local_led_mode_idx]
|
||||
color_fn(led)
|
||||
mcp.active_led_mode = mode_type
|
||||
print(f"Local Button: Cycle LED -> {mode_name}")
|
||||
last_action_str = f"Local Button: {mode_name}"
|
||||
mcp.override_active = False
|
||||
force_dashboard_redraw = True
|
||||
|
||||
def on_boot_click():
|
||||
nonlocal last_action_str, force_dashboard_redraw
|
||||
print("Local Button: Force Dashboard refresh.")
|
||||
last_action_str = "Dashboard Refreshed"
|
||||
mcp.override_active = False
|
||||
force_dashboard_redraw = True
|
||||
|
||||
if buttons:
|
||||
buttons.boot.on_click(on_boot_click)
|
||||
if buttons.key:
|
||||
buttons.key.on_click(on_key_click)
|
||||
|
||||
last_dashboard_update = 0
|
||||
dashboard_update_interval_s = 5
|
||||
last_led_update = 0
|
||||
last_wifi_check = 0
|
||||
|
||||
print("ESP32 CircuitPython main loop running...")
|
||||
|
||||
while True:
|
||||
time.sleep(5)
|
||||
now = time.monotonic()
|
||||
|
||||
# A. Check Wi-Fi connection and reconnect if lost (every 10s)
|
||||
if now - last_wifi_check >= 10.0:
|
||||
last_wifi_check = now
|
||||
if not wifi.radio.connected:
|
||||
print("Wi-Fi connection lost. Attempting reconnect...")
|
||||
last_action_str = "Wi-Fi Disconnected"
|
||||
if ip_addr != "Disconnected":
|
||||
ip_addr = "Disconnected"
|
||||
force_dashboard_redraw = True
|
||||
try:
|
||||
wifi.radio.connect(WIFI_SSID, WIFI_PASS)
|
||||
except Exception as e:
|
||||
print("Wi-Fi reconnect trigger failed:", e)
|
||||
elif ip_addr == "Disconnected":
|
||||
ip_addr = str(wifi.radio.ipv4_address)
|
||||
print(f"Wi-Fi Connected! IP Address: {ip_addr}")
|
||||
last_action_str = f"Wi-Fi Connected: {ip_addr}"
|
||||
force_dashboard_redraw = True
|
||||
try:
|
||||
mcp._call_tool("sync_time", {})
|
||||
except:
|
||||
pass
|
||||
|
||||
main()
|
||||
# B. Check for incoming MCP JSON-RPC TCP/UDP queries
|
||||
mcp.update()
|
||||
|
||||
# C. Check for incoming Video Streaming TCP/UDP frames
|
||||
vstream.update()
|
||||
|
||||
# D. Update button debouncers
|
||||
if buttons:
|
||||
buttons.update()
|
||||
|
||||
# E. Update BLE UART connection poll
|
||||
if ble_uart:
|
||||
ble_uart.update()
|
||||
|
||||
# F. Draw Local Dashboard periodically
|
||||
# Do not draw if screen is overridden by MCP commands (e.g. draw_text, draw_image) or active video stream
|
||||
if not mcp.override_active and not vstream.active:
|
||||
if force_dashboard_redraw or (now - last_dashboard_update >= dashboard_update_interval_s):
|
||||
force_dashboard_redraw = False
|
||||
last_dashboard_update = now
|
||||
|
||||
# SHTC3 Sensor
|
||||
t, h = sensor.read_sensor() if sensor else (None, None)
|
||||
t_str = f"{t:.1f} C" if t is not None else "N/A"
|
||||
h_str = f"{h:.1f} %" if h is not None else "N/A"
|
||||
|
||||
# Battery
|
||||
bat_v = battery.read_voltage() if battery else None
|
||||
bat_p = battery.read_percentage() if battery else 0
|
||||
bat_str = f"{bat_v:.2f}V ({bat_p}%)" if bat_v is not None else "N/A"
|
||||
|
||||
# Time
|
||||
dt = rtc_chip.get_datetime() if rtc_chip else None
|
||||
if dt:
|
||||
time_str = f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d} {dt[4]:02d}:{dt[5]:02d}:{dt[6]:02d}"
|
||||
else:
|
||||
time_str = "N/A (RTC Error)"
|
||||
|
||||
display.clear(0)
|
||||
line_w = display.width - 10
|
||||
|
||||
if board_type == 'WAVESHARE_RLCD':
|
||||
title_text = "Waveshare ESP32-S3-RLCD Server"
|
||||
display.text(title_text, 10, 10, 1)
|
||||
display.line(10, 20, line_w, 20, 1)
|
||||
display.text_large("ENVIRONMENT", 15, 30, scale=2, color=1)
|
||||
display.text(f"Temp : {t_str}", 25, 55, 1)
|
||||
display.text(f"Humid : {h_str}", 25, 70, 1)
|
||||
display.line(10, 95, line_w, 95, 1)
|
||||
display.text_large("MCP NET CONNECTION", 15, 105, scale=2, color=1)
|
||||
display.text(f"IP Address : {ip_addr}", 25, 130, 1)
|
||||
display.text(f"Port / Path : 80 /api/mcp", 25, 145, 1)
|
||||
display.text(f"BLE Name : ESP32-S3-RLCD", 25, 160, 1)
|
||||
display.line(10, 185, line_w, 185, 1)
|
||||
display.text_large("SYSTEM STATUS", 15, 195, scale=2, color=1)
|
||||
display.text(f"Battery : {bat_str}", 25, 220, 1)
|
||||
display.text(f"Time : {time_str}", 25, 235, 1)
|
||||
display.line(10, 255, line_w, 255, 1)
|
||||
display.text(f"Status: {last_action_str}", 15, 265, 1)
|
||||
else:
|
||||
title_text = "Hosyond ESP32-S3 Server"
|
||||
display.text(title_text, 10, 8, 1)
|
||||
display.line(10, 18, line_w, 18, 1)
|
||||
# Left Column
|
||||
display.text("SYSTEM & ENV", 10, 28, 1)
|
||||
display.line(10, 38, 150, 38, 1)
|
||||
display.text(f"Temp : {t_str}", 10, 46, 1)
|
||||
display.text(f"Hum : {h_str}", 10, 58, 1)
|
||||
display.text(f"Bat : {bat_str}", 10, 70, 1)
|
||||
display.text(f"Time : {time_str[11:19]}", 10, 82, 1)
|
||||
display.text(f"Date : {time_str[0:10]}", 10, 94, 1)
|
||||
# Right Column
|
||||
display.text("MCP NETWORK", 170, 28, 1)
|
||||
display.line(170, 38, line_w, 38, 1)
|
||||
display.text(f"IP : {ip_addr}", 170, 46, 1)
|
||||
display.text("Port: 80/api/mcp", 170, 58, 1)
|
||||
display.text("BLE : Touch", 170, 70, 1)
|
||||
# Bottom Status
|
||||
display.line(10, 115, line_w, 115, 1)
|
||||
display.text(f"Status: {last_action_str}", 10, 125, 1)
|
||||
|
||||
display.show()
|
||||
gc.collect()
|
||||
|
||||
# G. Update NeoPixel LED animations smoothly (every 50ms)
|
||||
if now - last_led_update >= 0.05:
|
||||
last_led_update = now
|
||||
mode = mcp.active_led_mode
|
||||
if mode == "breath":
|
||||
led.update_breathing(1.5)
|
||||
elif mode == "rainbow":
|
||||
led.update_rainbow(0.4)
|
||||
elif mode == "off":
|
||||
led.off()
|
||||
|
||||
# H. Sleep to prevent CPU hogging, poll faster if streaming is active
|
||||
if vstream.active:
|
||||
time.sleep(0.002)
|
||||
else:
|
||||
time.sleep(0.020)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
PORT="${PORT:-/dev/ttyACM0}"
|
||||
PORT="${PORT:-/dev/cu.usbmodem101}"
|
||||
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:-}"
|
||||
@@ -26,13 +26,13 @@ EOF
|
||||
fi
|
||||
|
||||
echo "==> Probing ESP32-S3 on $PORT"
|
||||
uvx esptool --chip esp32s3 --port "$PORT" flash-id
|
||||
python3 -m esptool --chip esp32s3 --port "$PORT" flash_id
|
||||
|
||||
echo "==> Erasing flash"
|
||||
uvx esptool --chip esp32s3 --port "$PORT" erase-flash
|
||||
python3 -m 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"
|
||||
python3 -m esptool --chip esp32s3 --port "$PORT" --baud 460800 write_flash -z 0x0 "$FIRMWARE"
|
||||
|
||||
echo "==> Waiting for CIRCUITPY mount"
|
||||
for _ in $(seq 1 60); do
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -5,13 +5,17 @@ playback using CircuitPython's audiomp3 + audiobusio stack.
|
||||
"""
|
||||
|
||||
import time
|
||||
import array
|
||||
import math
|
||||
|
||||
import audiobusio
|
||||
import audiomp3
|
||||
import audiocore
|
||||
import digitalio
|
||||
import pwmio
|
||||
|
||||
|
||||
|
||||
class ES8311:
|
||||
ADDR = 0x18
|
||||
|
||||
@@ -63,20 +67,29 @@ class ES8311:
|
||||
|
||||
def set_volume(self, volume):
|
||||
volume = max(0, min(100, int(volume)))
|
||||
self._write(0x32, int(volume * 255 / 100))
|
||||
if volume <= 0:
|
||||
reg_val = 0
|
||||
elif volume <= 50:
|
||||
# 1..50 maps to 0..191 (0xBF = 0dB)
|
||||
reg_val = int((volume / 50.0) * 191)
|
||||
else:
|
||||
# 51..100 maps to 192..255 (0xFF = +32dB)
|
||||
reg_val = 191 + int(((volume - 50) / 50.0) * (255 - 191))
|
||||
self._write(0x32, reg_val)
|
||||
|
||||
|
||||
class BoardAudio:
|
||||
def __init__(self, i2c, *, bit_clock, word_select, data, mclk, amp):
|
||||
def __init__(self, i2c, *, bit_clock, word_select, data, mclk, amp, amp_active_level=1):
|
||||
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.amp_active_level = amp_active_level
|
||||
self._mclk = None
|
||||
self._amp = digitalio.DigitalInOut(amp)
|
||||
self._amp.switch_to_output(value=False)
|
||||
self._amp.switch_to_output(value=not amp_active_level)
|
||||
self.codec = ES8311(i2c)
|
||||
|
||||
def start_mclk(self):
|
||||
@@ -88,7 +101,7 @@ class BoardAudio:
|
||||
frequency=12288000,
|
||||
duty_cycle=32768,
|
||||
variable_frequency=False,
|
||||
)
|
||||
)
|
||||
|
||||
def stop_mclk(self):
|
||||
if self._mclk is not None:
|
||||
@@ -106,25 +119,92 @@ class BoardAudio:
|
||||
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
|
||||
self._amp.value = self.amp_active_level
|
||||
audio.play(decoder)
|
||||
while audio.playing:
|
||||
time.sleep(0.05)
|
||||
return True
|
||||
finally:
|
||||
self._amp.value = False
|
||||
self._amp.value = not self.amp_active_level
|
||||
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.
|
||||
|
||||
def play_wav(self, filename, volume=60):
|
||||
"""Play a WAV file from CIRCUITPY storage over the onboard speaker."""
|
||||
self.start_mclk()
|
||||
self.codec.init()
|
||||
self.codec.set_volume(volume)
|
||||
wav = None
|
||||
audio = None
|
||||
f = None
|
||||
try:
|
||||
f = open(filename, "rb")
|
||||
wav = audiocore.WaveFile(f)
|
||||
audio = audiobusio.I2SOut(
|
||||
bit_clock=self.bit_clock_pin,
|
||||
word_select=self.word_select_pin,
|
||||
data=self.data_pin,
|
||||
)
|
||||
self._amp.value = self.amp_active_level
|
||||
audio.play(wav)
|
||||
while audio.playing:
|
||||
time.sleep(0.05)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error playing WAV: {e}")
|
||||
return False
|
||||
finally:
|
||||
self._amp.value = not self.amp_active_level
|
||||
if audio is not None:
|
||||
audio.deinit()
|
||||
if wav is not None:
|
||||
wav.deinit()
|
||||
if f is not None:
|
||||
f.close()
|
||||
|
||||
def play_tone(self, frequency=440, duration_ms=1000, volume=50):
|
||||
"""Generate and play a pure sine wave tone on the speaker."""
|
||||
self.start_mclk()
|
||||
self.codec.init()
|
||||
self.codec.set_volume(volume)
|
||||
|
||||
sample_rate = 16000
|
||||
length = int(sample_rate / frequency)
|
||||
if length < 4:
|
||||
length = 4
|
||||
|
||||
sine_wave = array.array("h", [0] * length)
|
||||
for i in range(length):
|
||||
sine_wave[i] = int(math.sin(2 * math.pi * i / length) * 32767)
|
||||
|
||||
sample = audiocore.RawSample(sine_wave, sample_rate=sample_rate)
|
||||
audio = None
|
||||
try:
|
||||
audio = audiobusio.I2SOut(
|
||||
bit_clock=self.bit_clock_pin,
|
||||
word_select=self.word_select_pin,
|
||||
data=self.data_pin,
|
||||
)
|
||||
self._amp.value = self.amp_active_level
|
||||
audio.play(sample, loop=True)
|
||||
time.sleep(duration_ms / 1000.0)
|
||||
audio.stop()
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error playing tone: {e}")
|
||||
return False
|
||||
finally:
|
||||
self._amp.value = not self.amp_active_level
|
||||
if audio is not None:
|
||||
audio.deinit()
|
||||
sample.deinit()
|
||||
|
||||
|
||||
@@ -0,0 +1,42 @@
|
||||
"""CircuitPython Battery Monitor utility.
|
||||
|
||||
Wraps analogio.AnalogIn to read voltage via a 2x divider and estimate capacity.
|
||||
"""
|
||||
|
||||
import analogio
|
||||
import board
|
||||
|
||||
class BatteryMonitor:
|
||||
def __init__(self, pin=board.IO9):
|
||||
self.adc = analogio.AnalogIn(pin)
|
||||
|
||||
def read_voltage(self):
|
||||
try:
|
||||
# AnalogIn value is 16-bit (0-65535). Map to 3.3V reference.
|
||||
# Divider is 2x, so actual voltage is scale * 2.
|
||||
raw = self.adc.value
|
||||
voltage = (raw / 65535.0) * 3.3 * 2.0
|
||||
return round(voltage, 3)
|
||||
except Exception as e:
|
||||
print(f"Error reading battery ADC: {e}")
|
||||
return None
|
||||
|
||||
def read_percentage(self):
|
||||
voltage = self.read_voltage()
|
||||
if voltage is None:
|
||||
return 0
|
||||
v_min = 3.0
|
||||
v_max = 4.2
|
||||
if voltage <= v_min:
|
||||
return 0
|
||||
if voltage >= v_max:
|
||||
return 100
|
||||
pct = (voltage - v_min) / (v_max - v_min) * 100.0
|
||||
return int(pct)
|
||||
|
||||
def get_status_summary(self):
|
||||
v = self.read_voltage()
|
||||
p = self.read_percentage()
|
||||
if v is None:
|
||||
return "Battery: Error"
|
||||
return f"Battery: {v:.2f}V ({p}%)"
|
||||
@@ -0,0 +1,101 @@
|
||||
"""CircuitPython BLE UART utility.
|
||||
|
||||
Wraps the adafruit_ble library to advertise Nordic UART Service and scan for nearby devices.
|
||||
"""
|
||||
|
||||
import time
|
||||
from adafruit_ble import BLERadio
|
||||
from adafruit_ble.services.nordic import UARTService
|
||||
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
|
||||
|
||||
class BLEUART:
|
||||
def __init__(self, ble=None, name="ESP32-S3-RLCD"):
|
||||
self.ble = BLERadio()
|
||||
self.ble.name = name
|
||||
self.uart = UARTService()
|
||||
self.advertisement = ProvideServicesAdvertisement(self.uart)
|
||||
self.rx_callback = None
|
||||
self._is_advertising = False
|
||||
self._start_advertise()
|
||||
|
||||
def _start_advertise(self):
|
||||
if not self.ble.connected and not self._is_advertising:
|
||||
try:
|
||||
self.ble.start_advertising(self.advertisement)
|
||||
self._is_advertising = True
|
||||
print(f"BLE advertising started as '{self.ble.name}'")
|
||||
except Exception as e:
|
||||
print(f"Failed to start BLE advertising: {e}")
|
||||
|
||||
def on_rx(self, callback):
|
||||
self.rx_callback = callback
|
||||
return callback
|
||||
|
||||
def update(self):
|
||||
"""Polls the UART stream for incoming data and triggers callbacks."""
|
||||
if self.ble.connected:
|
||||
self._is_advertising = False
|
||||
try:
|
||||
if self.uart.in_waiting:
|
||||
data = self.uart.read(self.uart.in_waiting)
|
||||
if self.rx_callback and data:
|
||||
try:
|
||||
decoded = data.decode("utf-8").strip()
|
||||
self.rx_callback(decoded)
|
||||
except:
|
||||
self.rx_callback(data)
|
||||
except Exception as e:
|
||||
print(f"Error reading BLE RX: {e}")
|
||||
else:
|
||||
if not self._is_advertising:
|
||||
self._start_advertise()
|
||||
|
||||
def write(self, data):
|
||||
if not self.ble.connected:
|
||||
return False
|
||||
if isinstance(data, str):
|
||||
data = data.encode("utf-8")
|
||||
try:
|
||||
self.uart.write(data)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Error sending BLE data: {e}")
|
||||
return False
|
||||
|
||||
def is_connected(self):
|
||||
return self.ble.connected
|
||||
|
||||
def scan(self, duration_ms=3000):
|
||||
"""Scans for nearby BLE devices."""
|
||||
duration_s = duration_ms / 1000.0
|
||||
results = {}
|
||||
was_advertising = self._is_advertising
|
||||
if was_advertising:
|
||||
try:
|
||||
self.ble.stop_advertising()
|
||||
except:
|
||||
pass
|
||||
self._is_advertising = False
|
||||
|
||||
try:
|
||||
print("Starting BLE scan...")
|
||||
for adv in self.ble.start_scan(timeout=duration_s):
|
||||
# Clean up address representation (mac address)
|
||||
mac = str(adv.address).replace("Address(", "").replace(")", "").strip()
|
||||
name = adv.complete_name or ""
|
||||
results[mac] = {"rssi": adv.rssi, "name": name}
|
||||
self.ble.stop_scan()
|
||||
except Exception as e:
|
||||
print(f"BLE scan error: {e}")
|
||||
|
||||
if was_advertising:
|
||||
self._start_advertise()
|
||||
return results
|
||||
|
||||
def close(self):
|
||||
try:
|
||||
self.ble.stop_advertising()
|
||||
except:
|
||||
pass
|
||||
self._is_advertising = False
|
||||
print("BLE closed.")
|
||||
@@ -0,0 +1,73 @@
|
||||
"""CircuitPython polled and debounced Button driver.
|
||||
|
||||
Replaces the MicroPython Pin IRQ implementation with active loop polling.
|
||||
"""
|
||||
|
||||
import time
|
||||
import digitalio
|
||||
|
||||
class Button:
|
||||
def __init__(self, pin_obj, name="Button", debounce_ms=50, long_press_ms=800):
|
||||
self.pin = digitalio.DigitalInOut(pin_obj)
|
||||
self.pin.switch_to_input(pull=digitalio.Pull.UP)
|
||||
self.name = name
|
||||
self.debounce_s = debounce_ms / 1000.0
|
||||
self.long_press_s = long_press_ms / 1000.0
|
||||
|
||||
self.last_state = True
|
||||
self.press_time = 0.0
|
||||
self.last_debounce_time = 0.0
|
||||
|
||||
self.click_callback = None
|
||||
self.long_press_callback = None
|
||||
|
||||
def is_pressed(self):
|
||||
return not self.pin.value
|
||||
|
||||
def on_click(self, callback):
|
||||
self.click_callback = callback
|
||||
return callback
|
||||
|
||||
def on_long_press(self, callback):
|
||||
self.long_press_callback = callback
|
||||
return callback
|
||||
|
||||
def update(self):
|
||||
now = time.monotonic()
|
||||
val = self.pin.value
|
||||
|
||||
if (now - self.last_debounce_time) < self.debounce_s:
|
||||
return
|
||||
|
||||
if val != self.last_state:
|
||||
self.last_debounce_time = now
|
||||
self.last_state = val
|
||||
|
||||
if not val:
|
||||
# Pressed (Active Low)
|
||||
self.press_time = now
|
||||
else:
|
||||
# Released
|
||||
if self.press_time > 0.0:
|
||||
duration = now - self.press_time
|
||||
self.press_time = 0.0
|
||||
|
||||
if duration >= self.long_press_s:
|
||||
if self.long_press_callback:
|
||||
self.long_press_callback()
|
||||
else:
|
||||
if self.click_callback:
|
||||
self.click_callback()
|
||||
|
||||
class BoardButtons:
|
||||
def __init__(self, boot_pin, key_pin=None):
|
||||
self.boot = Button(boot_pin, "BOOT")
|
||||
if key_pin is not None:
|
||||
self.key = Button(key_pin, "KEY")
|
||||
else:
|
||||
self.key = None
|
||||
|
||||
def update(self):
|
||||
self.boot.update()
|
||||
if self.key is not None:
|
||||
self.key.update()
|
||||
@@ -0,0 +1,66 @@
|
||||
"""CircuitPython file download utility.
|
||||
|
||||
Downloads files over Wi-Fi in chunks to local flash or microSD card storage.
|
||||
"""
|
||||
|
||||
import os
|
||||
from sd_cp import SDCardManager
|
||||
|
||||
def download_file(url, dest_filename, session, use_sd=True):
|
||||
"""Downloads a file from a URL over the network.
|
||||
|
||||
Args:
|
||||
url (str): Source URL.
|
||||
dest_filename (str): Target filename.
|
||||
session (Session): adafruit_requests Session object.
|
||||
use_sd (bool): Save to microSD card if True, else local flash.
|
||||
"""
|
||||
dest_path = dest_filename
|
||||
sd_manager = None
|
||||
|
||||
if use_sd:
|
||||
sd_manager = SDCardManager(mount_point='/sd')
|
||||
if not sd_manager.mount():
|
||||
print("Download Error: Could not mount SD card.")
|
||||
return None
|
||||
dest_path = f"/sd/{dest_filename}"
|
||||
|
||||
print(f"Starting download from: {url} -> {dest_path}")
|
||||
|
||||
try:
|
||||
res = session.get(url)
|
||||
except Exception as e:
|
||||
print(f"HTTP Connection failed: {e}")
|
||||
return None
|
||||
|
||||
if res.status_code != 200:
|
||||
print(f"HTTP Error: Received status code {res.status_code}")
|
||||
res.close()
|
||||
return None
|
||||
|
||||
try:
|
||||
chunk_size = 4096
|
||||
total_downloaded = 0
|
||||
|
||||
with open(dest_path, 'wb') as f:
|
||||
for chunk in res.iter_content(chunk_size):
|
||||
if not chunk:
|
||||
break
|
||||
f.write(chunk)
|
||||
total_downloaded += len(chunk)
|
||||
|
||||
if total_downloaded % (chunk_size * 25) == 0:
|
||||
print(f"Downloaded {total_downloaded // 1024} KB...")
|
||||
|
||||
print(f"Download complete! Saved {total_downloaded} bytes to '{dest_path}'.")
|
||||
return dest_path
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error writing to file: {e}")
|
||||
try:
|
||||
os.remove(dest_path)
|
||||
except:
|
||||
pass
|
||||
return None
|
||||
finally:
|
||||
res.close()
|
||||
@@ -0,0 +1,144 @@
|
||||
"""CircuitPython FT6336U touch controller driver.
|
||||
|
||||
Ports the MicroPython ft6336u.py driver using busio.I2C and digitalio.DigitalInOut.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
class FT6336U:
|
||||
ADDR = 0x38
|
||||
|
||||
# Registers
|
||||
REG_DEV_MODE = 0x00
|
||||
REG_TD_STATUS = 0x02
|
||||
REG_P1_XH = 0x03
|
||||
REG_P1_XL = 0x04
|
||||
REG_P1_YH = 0x05
|
||||
REG_P1_YL = 0x06
|
||||
REG_CTRL = 0x86
|
||||
REG_CHIPID = 0xA3
|
||||
REG_G_MODE = 0xA4
|
||||
|
||||
# Modes
|
||||
CTRL_KEEP_ACTIVE = 0x00
|
||||
G_MODE_TRIGGER = 0x01
|
||||
|
||||
def __init__(self, i2c, rst_pin, int_pin, width=480, height=320, swap_xy=True, invert_x=True, invert_y=False):
|
||||
self.i2c = i2c
|
||||
self.rst = rst_pin
|
||||
self.int = int_pin
|
||||
self.width = width
|
||||
self.height = height
|
||||
self.swap_xy = swap_xy
|
||||
self.invert_x = invert_x
|
||||
self.invert_y = invert_y
|
||||
self.initialized = False
|
||||
|
||||
# Configure reset and interrupt pins
|
||||
self.rst.switch_to_output(value=True)
|
||||
self.int.switch_to_input(pull=None) # Typically pulled up externally or on-board
|
||||
|
||||
self.reset()
|
||||
self.init_chip()
|
||||
|
||||
def reset(self):
|
||||
self.rst.value = False
|
||||
time.sleep(0.010)
|
||||
self.rst.value = True
|
||||
time.sleep(0.300) # Wait for chip to wake up
|
||||
|
||||
def read_reg(self, reg, n=1):
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
try:
|
||||
self.i2c.writeto(self.ADDR, bytes([reg]))
|
||||
buf = bytearray(n)
|
||||
self.i2c.readfrom_into(self.ADDR, buf)
|
||||
return buf
|
||||
except Exception as e:
|
||||
print(f"I2C read failed at reg 0x{reg:02X}: {e}")
|
||||
return None
|
||||
finally:
|
||||
self.i2c.unlock()
|
||||
|
||||
def write_reg(self, reg, val):
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
try:
|
||||
self.i2c.writeto(self.ADDR, bytes([reg, val]))
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"I2C write failed at reg 0x{reg:02X}: {e}")
|
||||
return False
|
||||
finally:
|
||||
self.i2c.unlock()
|
||||
|
||||
def init_chip(self):
|
||||
# 1. Read Chip ID
|
||||
chip_id = self.read_reg(self.REG_CHIPID)
|
||||
if chip_id is None or chip_id[0] != 0x64:
|
||||
time.sleep(0.100)
|
||||
chip_id = self.read_reg(self.REG_CHIPID)
|
||||
if chip_id is None or chip_id[0] != 0x64:
|
||||
print(f"FT6336U error: Invalid Chip ID (got {chip_id[0] if chip_id else None}, expected 0x64)")
|
||||
self.initialized = False
|
||||
return False
|
||||
|
||||
print(f"FT6336U touch controller detected (Chip ID: 0x{chip_id[0]:02X})")
|
||||
|
||||
# 2. Configure operating mode (0 = Normal Mode)
|
||||
self.write_reg(self.REG_DEV_MODE, 0x00)
|
||||
|
||||
# 3. Configure CTRL mode (0 = Keep Active)
|
||||
self.write_reg(self.REG_CTRL, self.CTRL_KEEP_ACTIVE)
|
||||
|
||||
self.initialized = True
|
||||
return True
|
||||
|
||||
def is_touched(self):
|
||||
"""Returns True if screen is touched by checking the TD_STATUS register."""
|
||||
if not self.initialized:
|
||||
return False
|
||||
td_status = self.read_reg(self.REG_TD_STATUS)
|
||||
if td_status is None:
|
||||
return False
|
||||
touch_count = td_status[0] & 0x0F
|
||||
if 0 < touch_count < 3:
|
||||
# Read P1 coordinates to clear register/interrupt state on the chip
|
||||
self.read_reg(self.REG_P1_XH, 6)
|
||||
return True
|
||||
return False
|
||||
|
||||
def read_touch(self):
|
||||
"""Reads touch point coordinates."""
|
||||
if not self.initialized:
|
||||
return None
|
||||
|
||||
td_status = self.read_reg(self.REG_TD_STATUS)
|
||||
if td_status is None:
|
||||
return None
|
||||
|
||||
touch_count = td_status[0] & 0x0F
|
||||
if touch_count == 0:
|
||||
return None
|
||||
|
||||
buf = self.read_reg(self.REG_P1_XH, 6)
|
||||
if buf is None or len(buf) < 6:
|
||||
return None
|
||||
|
||||
raw_x = ((buf[0] & 0x0F) << 8) | buf[1]
|
||||
raw_y = ((buf[2] & 0x0F) << 8) | buf[3]
|
||||
|
||||
if self.swap_xy:
|
||||
raw_x, raw_y = raw_y, raw_x
|
||||
|
||||
if self.invert_x:
|
||||
raw_x = self.width - 1 - raw_x
|
||||
|
||||
if self.invert_y:
|
||||
raw_y = self.height - 1 - raw_y
|
||||
|
||||
x = max(0, min(self.width - 1, raw_x))
|
||||
y = max(0, min(self.height - 1, raw_y))
|
||||
|
||||
return (x, y)
|
||||
@@ -10,7 +10,7 @@ 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):
|
||||
def play(self, filename, *, loops=1, x=40, y=0, threshold=1, clear_between_frames=False, max_frames=-1):
|
||||
"""Decode a GIF from disk and push frames to the reflective LCD.
|
||||
|
||||
CircuitPython gifio currently supports GIFs up to 320 pixels wide, so
|
||||
@@ -23,14 +23,21 @@ class GIFPlayer:
|
||||
try:
|
||||
count = 0
|
||||
while loops < 0 or count < loops:
|
||||
frame_count = 0
|
||||
while True:
|
||||
if max_frames > 0 and frame_count >= max_frames:
|
||||
break
|
||||
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()
|
||||
if hasattr(self.display, "draw_bitmap_color"):
|
||||
self.display.draw_bitmap_color(gif.bitmap, gif.palette, x, y)
|
||||
else:
|
||||
self.display.draw_bitmap_threshold(gif.bitmap, x, y, threshold=threshold)
|
||||
self.display.show()
|
||||
frame_count += 1
|
||||
# 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))
|
||||
|
||||
@@ -0,0 +1,481 @@
|
||||
"""CircuitPython ILI9341 driver for Hosyond ESP32-S3 Touchscreen board.
|
||||
|
||||
This driver wraps adafruit_framebuf using a 1-bit MONO_HLSB canvas buffer,
|
||||
then converts it row-by-row to 16-bit RGB565 via a lookup table (LUT) during show().
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
try:
|
||||
import adafruit_framebuf
|
||||
except ImportError:
|
||||
adafruit_framebuf = None
|
||||
|
||||
|
||||
class ILI9341:
|
||||
WIDTH = 320
|
||||
HEIGHT = 240
|
||||
|
||||
def __init__(self, spi, cs, dc, rst=None, bl=None, width=WIDTH, height=HEIGHT, invert_color=True):
|
||||
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.invert_color = invert_color
|
||||
|
||||
# 1-bit canvas buffer (1 = White/On, 0 = Black/Off)
|
||||
self.hw_len = (width * height) // 8
|
||||
self.canvas_buffer = bytearray(self.hw_len)
|
||||
self.canvas = adafruit_framebuf.FrameBuffer(
|
||||
self.canvas_buffer,
|
||||
width,
|
||||
height,
|
||||
adafruit_framebuf.MHMSB, # Matches MONO_HLSB (most significant bit first)
|
||||
)
|
||||
|
||||
# Pre-allocate chunk buffer for conversion (16 rows: 320 * 16 * 2 = 10,240 bytes)
|
||||
self.chunk_rows = 16
|
||||
self.row_buffer = bytearray(width * self.chunk_rows * 2)
|
||||
|
||||
# Precompute lookup table for fast 1-bit to 16-bit conversion
|
||||
# Each byte (8 pixels) maps to 16 bytes of RGB565 (8 pixels * 2 bytes)
|
||||
self.lut = []
|
||||
for i in range(256):
|
||||
entry = bytearray(16)
|
||||
for bit in range(8):
|
||||
if i & (1 << (7 - bit)):
|
||||
# White pixel: 0xFFFF (High byte: 0xFF, Low byte: 0xFF)
|
||||
entry[bit * 2] = 0xFF
|
||||
entry[bit * 2 + 1] = 0xFF
|
||||
else:
|
||||
# Black pixel: 0x0000
|
||||
entry[bit * 2] = 0x00
|
||||
entry[bit * 2 + 1] = 0x00
|
||||
self.lut.append(bytes(entry))
|
||||
|
||||
# Setup CS and DC
|
||||
self.cs.switch_to_output(value=True)
|
||||
self.dc.switch_to_output(value=False)
|
||||
|
||||
# Setup Reset if present
|
||||
if self.rst is not None:
|
||||
self.rst.switch_to_output(value=True)
|
||||
|
||||
# Setup Backlight PWM if present
|
||||
if bl is not None:
|
||||
import pwmio
|
||||
self.bl_pwm = pwmio.PWMOut(bl, frequency=1000, duty_cycle=65535)
|
||||
else:
|
||||
self.bl_pwm = None
|
||||
|
||||
self.reset()
|
||||
self.init_display()
|
||||
self.clear(0)
|
||||
self.show()
|
||||
|
||||
def reset(self):
|
||||
if self.rst is not None:
|
||||
self.rst.value = True
|
||||
time.sleep(0.005)
|
||||
self.rst.value = False
|
||||
time.sleep(0.015)
|
||||
self.rst.value = True
|
||||
time.sleep(0.015)
|
||||
else:
|
||||
# Software reset command if no reset pin
|
||||
self.write_cmd(0x01)
|
||||
time.sleep(0.150)
|
||||
|
||||
def _lock_spi(self):
|
||||
while not self.spi.try_lock():
|
||||
pass
|
||||
self.spi.configure(baudrate=40000000, 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):
|
||||
# SWRESET
|
||||
self.write_cmd(0x01)
|
||||
time.sleep(0.150)
|
||||
|
||||
self.write_cmd(0xCF); self.write_data(b"\x00\xC1\x30")
|
||||
self.write_cmd(0xED); self.write_data(b"\x64\x03\x12\x81")
|
||||
self.write_cmd(0xE8); self.write_data(b"\x85\x00\x78")
|
||||
self.write_cmd(0xCB); self.write_data(b"\x39\x2C\x00\x34\x02")
|
||||
self.write_cmd(0xF7); self.write_data(b"\x20")
|
||||
self.write_cmd(0xEA); self.write_data(b"\x00\x00")
|
||||
|
||||
self.write_cmd(0xC0); self.write_data(b"\x13") # Power Control 1
|
||||
self.write_cmd(0xC1); self.write_data(b"\x13") # Power Control 2
|
||||
self.write_cmd(0xC5); self.write_data(b"\x22\x35") # VCOM Control 1
|
||||
self.write_cmd(0xC7); self.write_data(b"\xBD") # VCOM Control 2
|
||||
|
||||
# Memory Access Control (MADCTL) = 0x68 (Landscape: MV=1, MX=1, MY=0, BGR color filter)
|
||||
self.write_cmd(0x36); self.write_data(b"\x68")
|
||||
|
||||
self.write_cmd(0xB6); self.write_data(b"\x0A\xA2") # Display Function Control
|
||||
self.write_cmd(0x3A); self.write_data(b"\x55") # Pixel Format (COLMOD) = 16-bit RGB565
|
||||
self.write_cmd(0xF6); self.write_data(b"\x01\x30")
|
||||
self.write_cmd(0xB1); self.write_data(b"\x00\x1B") # Frame Rate Control
|
||||
self.write_cmd(0xF2); self.write_data(b"\x00")
|
||||
self.write_cmd(0x26); self.write_data(b"\x01") # Gamma Curve
|
||||
|
||||
self.write_cmd(0xE0); self.write_data(b"\x0F\x35\x31\x0B\x0E\x06\x49\xA7\x33\x07\x0F\x03\x0C\x0A\x00")
|
||||
self.write_cmd(0xE1); self.write_data(b"\x00\x0A\x0F\x04\x11\x08\x36\x58\x4D\x07\x10\x0C\x32\x34\x0F")
|
||||
|
||||
if self.invert_color:
|
||||
self.write_cmd(0x21) # INVON
|
||||
else:
|
||||
self.write_cmd(0x20) # INVOFF
|
||||
|
||||
self.write_cmd(0x11) # SLPOUT
|
||||
time.sleep(0.120)
|
||||
self.write_cmd(0x29) # DISPON
|
||||
time.sleep(0.010)
|
||||
|
||||
def invert(self, enable):
|
||||
self.write_cmd(0x21 if enable else 0x20)
|
||||
|
||||
def set_window(self, x0, y0, x1, y1):
|
||||
self.write_cmd(0x2A)
|
||||
self.write_data(bytes([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF]))
|
||||
self.write_cmd(0x2B)
|
||||
self.write_data(bytes([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF]))
|
||||
self.write_cmd(0x2C)
|
||||
|
||||
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):
|
||||
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):
|
||||
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 draw_bitmap_color(self, bitmap, palette, x=0, y=0):
|
||||
width = min(getattr(bitmap, "width", self.width), self.width - x)
|
||||
height = min(getattr(bitmap, "height", self.height), self.height - y)
|
||||
row_buf = bytearray(width * 2)
|
||||
for yy in range(height):
|
||||
idx = 0
|
||||
for xx in range(width):
|
||||
val = bitmap[xx, yy]
|
||||
if palette is None:
|
||||
rgb = val
|
||||
else:
|
||||
color = palette[val]
|
||||
if isinstance(color, tuple) or isinstance(color, list):
|
||||
r, g, b = color[0], color[1], color[2]
|
||||
elif isinstance(color, int):
|
||||
r = (color >> 16) & 0xFF
|
||||
g = (color >> 8) & 0xFF
|
||||
b = color & 0xFF
|
||||
else:
|
||||
r, g, b = 0, 0, 0
|
||||
r5 = r >> 3
|
||||
g6 = g >> 2
|
||||
b5 = b >> 3
|
||||
rgb = (r5 << 11) | (g6 << 5) | b5
|
||||
row_buf[idx] = (rgb >> 8) & 0xFF
|
||||
row_buf[idx + 1] = rgb & 0xFF
|
||||
idx += 2
|
||||
self.draw_rgb565(x, yy + y, width, 1, row_buf, sync_canvas=False)
|
||||
|
||||
def show(self):
|
||||
"""Optimized conversion of 1-bit frame buffer to 16-bit RGB565 over SPI."""
|
||||
self.set_window(0, 0, self.width - 1, self.height - 1)
|
||||
self.dc.value = True
|
||||
self.cs.value = False
|
||||
|
||||
lut = self.lut
|
||||
canvas_buf = self.canvas_buffer
|
||||
row_buf = self.row_buffer
|
||||
width_bytes = self.width // 8 # 40 bytes per row
|
||||
|
||||
num_chunks = self.height // self.chunk_rows # 240 // 16 = 15 chunks
|
||||
for chunk in range(num_chunks):
|
||||
start_row = chunk * self.chunk_rows
|
||||
idx = 0
|
||||
# Loop for 16 rows * 40 bytes/row = 640 bytes. Slice assignment maps directly to LUT.
|
||||
for y in range(start_row, start_row + self.chunk_rows):
|
||||
offset = y * width_bytes
|
||||
for x_byte_idx in range(width_bytes):
|
||||
val = canvas_buf[offset + x_byte_idx]
|
||||
row_buf[idx : idx + 16] = lut[val]
|
||||
idx += 16
|
||||
|
||||
self._lock_spi()
|
||||
try:
|
||||
self.spi.write(row_buf)
|
||||
finally:
|
||||
self._unlock_spi()
|
||||
|
||||
self.cs.value = True
|
||||
|
||||
def set_brightness(self, level):
|
||||
if self.bl_pwm is not None:
|
||||
level = max(0, min(100, level))
|
||||
self.bl_pwm.duty_cycle = int(level * 65535 / 100)
|
||||
|
||||
def set_power(self, on):
|
||||
if on:
|
||||
self.write_cmd(0x11) # SLPOUT
|
||||
time.sleep(0.120)
|
||||
self.write_cmd(0x29) # DISPON
|
||||
if self.bl_pwm is not None:
|
||||
self.bl_pwm.duty_cycle = 65535
|
||||
else:
|
||||
self.write_cmd(0x28) # DISPOFF
|
||||
self.write_cmd(0x10) # SLPIN
|
||||
time.sleep(0.010)
|
||||
if self.bl_pwm is not None:
|
||||
self.bl_pwm.duty_cycle = 0
|
||||
|
||||
def _update_mono_canvas_rgb565(self, x, y, w, h, data):
|
||||
for cy in range(h):
|
||||
screen_y = y + cy
|
||||
if screen_y < 0 or screen_y >= self.height:
|
||||
continue
|
||||
for cx in range(w):
|
||||
screen_x = x + cx
|
||||
if screen_x < 0 or screen_x >= self.width:
|
||||
continue
|
||||
idx = (cy * w + cx) * 2
|
||||
h_byte = data[idx]
|
||||
l_byte = data[idx + 1]
|
||||
# Extract RGB from RGB565
|
||||
r = (h_byte & 0xF8)
|
||||
g = ((h_byte & 0x07) << 5) | ((l_byte & 0xE0) >> 3)
|
||||
b = (l_byte & 0x1F) << 3
|
||||
# Convert to luminance
|
||||
lum = (r * 299 + g * 587 + b * 114) // 1000
|
||||
mono = 1 if lum >= 128 else 0
|
||||
self.canvas.pixel(screen_x, screen_y, mono)
|
||||
|
||||
def draw_rgb565(self, x, y, w, h, data, sync_canvas=True):
|
||||
"""Draw raw RGB565 pixel data on the screen at specified (x,y) with width and height."""
|
||||
# Clip coordinates
|
||||
x_start = max(0, x)
|
||||
x_end = min(self.width - 1, x + w - 1)
|
||||
y_start = max(0, y)
|
||||
y_end = min(self.height - 1, y + h - 1)
|
||||
|
||||
if x_start > x_end or y_start > y_end:
|
||||
return True
|
||||
|
||||
# Fast path: if completely visible on screen, draw in one go
|
||||
if x_start == x and x_end == x + w - 1 and y_start == y and y_end == y + h - 1:
|
||||
self.set_window(x_start, y_start, x_end, y_end)
|
||||
self.dc.value = True
|
||||
self.cs.value = False
|
||||
self._lock_spi()
|
||||
try:
|
||||
self.spi.write(data)
|
||||
finally:
|
||||
self._unlock_spi()
|
||||
self.cs.value = True
|
||||
else:
|
||||
# Slow path: row-by-row clipping
|
||||
for cy in range(y_start, y_end + 1):
|
||||
src_y = cy - y
|
||||
src_row_offset = (src_y * w + (x_start - x)) * 2
|
||||
row_len_bytes = (x_end - x_start + 1) * 2
|
||||
|
||||
self.set_window(x_start, cy, x_end, cy)
|
||||
self.dc.value = True
|
||||
self.cs.value = False
|
||||
self._lock_spi()
|
||||
try:
|
||||
self.spi.write(memoryview(data)[src_row_offset : src_row_offset + row_len_bytes])
|
||||
finally:
|
||||
self._unlock_spi()
|
||||
self.cs.value = True
|
||||
|
||||
# Sync the internal 1-bit canvas buffer
|
||||
if sync_canvas:
|
||||
self._update_mono_canvas_rgb565(x, y, w, h, data)
|
||||
return True
|
||||
|
||||
def _convert_bgr24_to_rgb565(self, bgr_buf, rgb565_buf, width, src_offset, num_pixels):
|
||||
idx = 0
|
||||
for i in range(src_offset, src_offset + num_pixels):
|
||||
b = bgr_buf[i * 3]
|
||||
g = bgr_buf[i * 3 + 1]
|
||||
r = bgr_buf[i * 3 + 2]
|
||||
r_5 = r >> 3
|
||||
g_6 = g >> 2
|
||||
b_5 = b >> 3
|
||||
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
|
||||
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
|
||||
idx += 2
|
||||
|
||||
def _convert_bgra32_to_rgb565(self, bgra_buf, rgb565_buf, width, src_offset, num_pixels):
|
||||
idx = 0
|
||||
for i in range(src_offset, src_offset + num_pixels):
|
||||
b = bgra_buf[i * 4]
|
||||
g = bgra_buf[i * 4 + 1]
|
||||
r = bgra_buf[i * 4 + 2]
|
||||
r_5 = r >> 3
|
||||
g_6 = g >> 2
|
||||
b_5 = b >> 3
|
||||
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
|
||||
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
|
||||
idx += 2
|
||||
|
||||
def draw_bmp(self, filename, x=0, y=0):
|
||||
import struct
|
||||
try:
|
||||
with open(filename, 'rb') as f:
|
||||
header = f.read(54)
|
||||
if len(header) < 54 or header[0:2] != b'BM':
|
||||
print("Err: Not a valid BMP file")
|
||||
return False
|
||||
|
||||
pixel_offset = struct.unpack('<I', header[10:14])[0]
|
||||
width, height = struct.unpack('<ii', header[18:26])
|
||||
planes, bpp = struct.unpack('<HH', header[26:30])
|
||||
compression = struct.unpack('<I', header[30:34])[0]
|
||||
|
||||
if bpp not in (24, 32):
|
||||
print("Err: Only 24-bit and 32-bit BMP formats supported")
|
||||
return False
|
||||
|
||||
if compression != 0:
|
||||
print("Err: Only uncompressed BMP supported")
|
||||
return False
|
||||
|
||||
f.seek(pixel_offset)
|
||||
bottom_up = True
|
||||
if height < 0:
|
||||
height = -height
|
||||
bottom_up = False
|
||||
|
||||
row_bytes = (width * bpp) // 8
|
||||
row_padded = ((width * bpp + 31) // 32) * 4
|
||||
|
||||
read_buf = bytearray(row_padded)
|
||||
rgb565_buf = bytearray(width * 2)
|
||||
|
||||
for row_idx in range(height):
|
||||
n = f.readinto(read_buf)
|
||||
if n < row_padded:
|
||||
break
|
||||
|
||||
screen_y = y + (height - 1 - row_idx) if bottom_up else y + row_idx
|
||||
if screen_y < 0 or screen_y >= self.height:
|
||||
continue
|
||||
|
||||
x_start = x
|
||||
x_end = x + width - 1
|
||||
|
||||
if x_start >= self.width or x_end < 0:
|
||||
continue
|
||||
|
||||
win_x0 = max(0, x_start)
|
||||
win_x1 = min(self.width - 1, x_end)
|
||||
|
||||
if win_x1 < win_x0:
|
||||
continue
|
||||
|
||||
src_offset_pixels = win_x0 - x_start
|
||||
win_w = win_x1 - win_x0 + 1
|
||||
|
||||
# Convert pixel data to RGB565 row buffer
|
||||
if bpp == 24:
|
||||
self._convert_bgr24_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
|
||||
elif bpp == 32:
|
||||
self._convert_bgra32_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
|
||||
|
||||
# Draw directly to the screen via SPI window
|
||||
self.set_window(win_x0, screen_y, win_x1, screen_y)
|
||||
self.dc.value = True
|
||||
self.cs.value = False
|
||||
self._lock_spi()
|
||||
try:
|
||||
self.spi.write(memoryview(rgb565_buf)[:win_w * 2])
|
||||
finally:
|
||||
self._unlock_spi()
|
||||
self.cs.value = True
|
||||
|
||||
# Also update internal 1-bit canvas buffer for screenshots/refresh consistency
|
||||
for px in range(win_w):
|
||||
screen_x = win_x0 + px
|
||||
src_px = src_offset_pixels + px
|
||||
if bpp == 24:
|
||||
b = read_buf[src_px * 3]
|
||||
g = read_buf[src_px * 3 + 1]
|
||||
r = read_buf[src_px * 3 + 2]
|
||||
else:
|
||||
b = read_buf[src_px * 4]
|
||||
g = read_buf[src_px * 4 + 1]
|
||||
r = read_buf[src_px * 4 + 2]
|
||||
# 0 = Black, 1 = White in conversion for MONO_HLSB canvas
|
||||
lum = (r * 299 + g * 587 + b * 114) // 1000
|
||||
mono_c = 1 if lum >= 128 else 0
|
||||
self.canvas.pixel(screen_x, screen_y, mono_c)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print("Error drawing BMP:", e)
|
||||
return False
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
@@ -0,0 +1,43 @@
|
||||
"""CircuitPython NeoPixel LED utility.
|
||||
|
||||
Uses the neopixel library and time.monotonic() to run breathing and rainbow animations.
|
||||
"""
|
||||
|
||||
import math
|
||||
import time
|
||||
import neopixel
|
||||
|
||||
class BoardLED:
|
||||
def __init__(self, pin):
|
||||
# Initialize 1 NeoPixel on the specified pin
|
||||
self.np = neopixel.NeoPixel(pin, 1, brightness=1.0, auto_write=False)
|
||||
self.base_color = (0, 0, 0)
|
||||
self.off()
|
||||
|
||||
def set_color(self, r, g, b):
|
||||
self.base_color = (r, g, b)
|
||||
self.np[0] = (r, g, b)
|
||||
self.np.show()
|
||||
|
||||
def off(self):
|
||||
self.set_color(0, 0, 0)
|
||||
|
||||
def update_breathing(self, speed_factor=1.0):
|
||||
if self.base_color == (0, 0, 0):
|
||||
return
|
||||
t = time.monotonic() * speed_factor
|
||||
# Sine wave from 0.05 to 1.0
|
||||
factor = 0.525 + 0.475 * math.sin(t * math.pi)
|
||||
br = int(self.base_color[0] * factor)
|
||||
bg = int(self.base_color[1] * factor)
|
||||
bb = int(self.base_color[2] * factor)
|
||||
self.np[0] = (br, bg, bb)
|
||||
self.np.show()
|
||||
|
||||
def update_rainbow(self, speed_factor=0.2):
|
||||
t = time.monotonic() * speed_factor
|
||||
r = int(127.5 * (1.0 + math.sin(t * 2.0 * math.pi)))
|
||||
g = int(127.5 * (1.0 + math.sin(t * 2.0 * math.pi + 2.0 * math.pi / 3.0)))
|
||||
b = int(127.5 * (1.0 + math.sin(t * 2.0 * math.pi + 4.0 * math.pi / 3.0)))
|
||||
self.np[0] = (r, g, b)
|
||||
self.np.show()
|
||||
@@ -0,0 +1,132 @@
|
||||
"""CircuitPython PCF85063 Real-Time Clock (RTC) driver.
|
||||
|
||||
Synchronizes the hardware RTC with CircuitPython's native rtc.RTC() system clock.
|
||||
"""
|
||||
|
||||
import rtc
|
||||
import time
|
||||
|
||||
class PCF85063:
|
||||
ADDR = 0x51
|
||||
TIME_REG_START = 0x04
|
||||
|
||||
def __init__(self, i2c):
|
||||
self.i2c = i2c
|
||||
self._init_rtc()
|
||||
|
||||
def read_reg(self, reg, n=1):
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
try:
|
||||
self.i2c.writeto(self.ADDR, bytes([reg]))
|
||||
buf = bytearray(n)
|
||||
self.i2c.readfrom_into(self.ADDR, buf)
|
||||
return buf
|
||||
except Exception as e:
|
||||
print(f"RTC read failed at reg 0x{reg:02X}: {e}")
|
||||
return None
|
||||
finally:
|
||||
self.i2c.unlock()
|
||||
|
||||
def write_reg(self, reg, data):
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
try:
|
||||
payload = bytearray([reg])
|
||||
if isinstance(data, (bytes, bytearray, list)):
|
||||
payload.extend(data)
|
||||
else:
|
||||
payload.append(data)
|
||||
self.i2c.writeto(self.ADDR, payload)
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"RTC write failed at reg 0x{reg:02X}: {e}")
|
||||
return False
|
||||
finally:
|
||||
self.i2c.unlock()
|
||||
|
||||
def _init_rtc(self):
|
||||
ctrl1 = self.read_reg(0x00, 1)
|
||||
if ctrl1 is not None and (ctrl1[0] & 0x20):
|
||||
print("RTC oscillator was stopped. Starting oscillator...")
|
||||
self.write_reg(0x00, 0x00)
|
||||
|
||||
def _dec2bcd(self, val):
|
||||
return (val // 10 << 4) | (val % 10)
|
||||
|
||||
def _bcd2dec(self, val):
|
||||
return ((val >> 4) * 10) + (val & 0x0F)
|
||||
|
||||
def get_datetime(self):
|
||||
"""Reads current time from hardware RTC.
|
||||
|
||||
Returns:
|
||||
tuple: (year, month, day, weekday, hour, minute, second) or None on error.
|
||||
"""
|
||||
data = self.read_reg(self.TIME_REG_START, 7)
|
||||
if data is None:
|
||||
return None
|
||||
|
||||
second = self._bcd2dec(data[0] & 0x7F)
|
||||
minute = self._bcd2dec(data[1] & 0x7F)
|
||||
hour = self._bcd2dec(data[2] & 0x3F)
|
||||
day = self._bcd2dec(data[3] & 0x3F)
|
||||
weekday = data[4] & 0x07
|
||||
month = self._bcd2dec(data[5] & 0x1F)
|
||||
year = 2000 + self._bcd2dec(data[6])
|
||||
|
||||
return (year, month, day, weekday, hour, minute, second)
|
||||
|
||||
def set_datetime(self, dt):
|
||||
"""Sets the hardware RTC time.
|
||||
|
||||
Args:
|
||||
dt (tuple): (year, month, day, weekday, hour, minute, second)
|
||||
"""
|
||||
try:
|
||||
year, month, day, weekday, hour, minute, second = dt
|
||||
reg_year = year % 100
|
||||
|
||||
data = bytearray(7)
|
||||
data[0] = self._dec2bcd(second) & 0x7F
|
||||
data[1] = self._dec2bcd(minute)
|
||||
data[2] = self._dec2bcd(hour)
|
||||
data[3] = self._dec2bcd(day)
|
||||
data[4] = weekday & 0x07
|
||||
data[5] = self._dec2bcd(month)
|
||||
data[6] = self._dec2bcd(reg_year)
|
||||
|
||||
return self.write_reg(self.TIME_REG_START, data)
|
||||
except Exception as e:
|
||||
print(f"Error setting PCF85063 RTC: {e}")
|
||||
return False
|
||||
|
||||
def sync_to_system(self):
|
||||
"""Synchronizes the CircuitPython system time from the hardware RTC."""
|
||||
dt = self.get_datetime()
|
||||
if dt:
|
||||
year, month, day, weekday, hour, minute, second = dt
|
||||
r = rtc.RTC()
|
||||
r.datetime = time.struct_time((year, month, day, hour, minute, second, weekday, -1, -1))
|
||||
print(f"System clock synced to RTC: {year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}")
|
||||
return True
|
||||
return False
|
||||
|
||||
def sync_from_system(self):
|
||||
"""Synchronizes the hardware RTC time from the system clock."""
|
||||
try:
|
||||
t = time.localtime()
|
||||
dt = (t.tm_year, t.tm_mon, t.tm_mday, t.tm_wday, t.tm_hour, t.tm_min, t.tm_sec)
|
||||
success = self.set_datetime(dt)
|
||||
if success:
|
||||
print(f"RTC synced from System: {t.tm_year:04d}-{t.tm_mon:02d}-{t.tm_mday:02d} {t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}")
|
||||
return success
|
||||
except Exception as e:
|
||||
print(f"Error syncing RTC from system: {e}")
|
||||
return False
|
||||
|
||||
def get_time_string(self):
|
||||
dt = self.get_datetime()
|
||||
if dt:
|
||||
return f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d} {dt[4]:02d}:{dt[5]:02d}:{dt[6]:02d}"
|
||||
return "0000-00-00 00:00:00"
|
||||
@@ -0,0 +1,85 @@
|
||||
"""CircuitPython SD card utility using sdioio and storage.
|
||||
|
||||
Uses SDMMC 1-bit mode on Pins: sck=board.IO2, cmd=board.IO1, data0=board.IO3.
|
||||
"""
|
||||
|
||||
import os
|
||||
import board
|
||||
import sdioio
|
||||
import storage
|
||||
|
||||
class SDCardManager:
|
||||
def __init__(self, mount_point='/sd'):
|
||||
self.mount_point = mount_point
|
||||
self.sd = None
|
||||
self.mounted = False
|
||||
|
||||
def mount(self):
|
||||
if self.mounted:
|
||||
print(f"SD card already mounted at {self.mount_point}")
|
||||
return True
|
||||
|
||||
try:
|
||||
print("Initializing SDCard (SDMMC 1-bit mode: sck=2, cmd=1, d0=3)...")
|
||||
self.sd = sdioio.SDCard(clock=board.IO2, command=board.IO1, data=[board.IO3], frequency=20000000)
|
||||
vfs = storage.VfsFat(self.sd)
|
||||
print(f"Mounting SD card to {self.mount_point}...")
|
||||
storage.mount(vfs, self.mount_point)
|
||||
self.mounted = True
|
||||
print("SD card mounted successfully!")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to mount SD card: {e}")
|
||||
self.sd = None
|
||||
self.mounted = False
|
||||
return False
|
||||
|
||||
def unmount(self):
|
||||
if not self.mounted:
|
||||
return True
|
||||
|
||||
try:
|
||||
print(f"Unmounting SD card from {self.mount_point}...")
|
||||
storage.umount(self.mount_point)
|
||||
if self.sd:
|
||||
try:
|
||||
self.sd.deinit()
|
||||
except:
|
||||
pass
|
||||
self.mounted = False
|
||||
self.sd = None
|
||||
print("SD card unmounted.")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"Failed to unmount SD card: {e}")
|
||||
return False
|
||||
|
||||
def is_mounted(self):
|
||||
return self.mounted
|
||||
|
||||
def list_files(self):
|
||||
if not self.mounted:
|
||||
print("SD card is not mounted.")
|
||||
return None
|
||||
try:
|
||||
return os.listdir(self.mount_point)
|
||||
except Exception as e:
|
||||
print(f"Error listing SD card files: {e}")
|
||||
return None
|
||||
|
||||
def get_info(self):
|
||||
if not self.mounted:
|
||||
return None
|
||||
try:
|
||||
stat = os.statvfs(self.mount_point)
|
||||
block_size = stat[0]
|
||||
total_blocks = stat[2]
|
||||
free_blocks = stat[3]
|
||||
|
||||
return {
|
||||
"total_bytes": total_blocks * block_size,
|
||||
"free_bytes": free_blocks * block_size
|
||||
}
|
||||
except Exception as e:
|
||||
print(f"Error getting SD card info: {e}")
|
||||
return None
|
||||
@@ -0,0 +1,101 @@
|
||||
"""CircuitPython SHTC3 temperature and humidity sensor driver.
|
||||
|
||||
Uses standard I2C transactions with bus locking.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
class SHTC3:
|
||||
ADDR = 0x70
|
||||
|
||||
WAKE = b'\x35\x17'
|
||||
SLEEP = b'\xB0\x98'
|
||||
MEASURE = b'\x78\x66' # High precision, T first, clock stretching disabled
|
||||
|
||||
def __init__(self, i2c):
|
||||
self.i2c = i2c
|
||||
|
||||
def _crc8(self, data):
|
||||
crc = 0xFF
|
||||
for byte in data:
|
||||
crc ^= byte
|
||||
for _ in range(8):
|
||||
if crc & 0x80:
|
||||
crc = (crc << 1) ^ 0x31
|
||||
else:
|
||||
crc <<= 1
|
||||
crc &= 0xFF
|
||||
return crc
|
||||
|
||||
def read_sensor(self):
|
||||
try:
|
||||
# 1. Wakeup
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
try:
|
||||
self.i2c.writeto(self.ADDR, self.WAKE)
|
||||
finally:
|
||||
self.i2c.unlock()
|
||||
time.sleep(0.001)
|
||||
|
||||
# 2. Trigger Measurement
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
try:
|
||||
self.i2c.writeto(self.ADDR, self.MEASURE)
|
||||
finally:
|
||||
self.i2c.unlock()
|
||||
time.sleep(0.015)
|
||||
|
||||
# 3. Read 6 bytes of data
|
||||
# bytes 0, 1: Temp, byte 2: Temp CRC
|
||||
# bytes 3, 4: Hum, byte 5: Hum CRC
|
||||
buf = bytearray(6)
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
try:
|
||||
self.i2c.readfrom_into(self.ADDR, buf)
|
||||
finally:
|
||||
self.i2c.unlock()
|
||||
|
||||
# 4. Enter sleep mode
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
try:
|
||||
self.i2c.writeto(self.ADDR, self.SLEEP)
|
||||
finally:
|
||||
self.i2c.unlock()
|
||||
|
||||
# Verify CRC
|
||||
t_data = buf[0:2]
|
||||
t_crc = buf[2]
|
||||
h_data = buf[3:5]
|
||||
h_crc = buf[5]
|
||||
|
||||
if self._crc8(t_data) != t_crc:
|
||||
print("SHTC3 Temp CRC error")
|
||||
return None, None
|
||||
if self._crc8(h_data) != h_crc:
|
||||
print("SHTC3 Hum CRC error")
|
||||
return None, None
|
||||
|
||||
raw_t = (buf[0] << 8) | buf[1]
|
||||
raw_h = (buf[3] << 8) | buf[4]
|
||||
|
||||
temp = -45.0 + 175.0 * (raw_t / 65536.0)
|
||||
hum = 100.0 * (raw_h / 65536.0)
|
||||
|
||||
return round(temp, 2), round(hum, 2)
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error reading SHTC3 sensor: {e}")
|
||||
try:
|
||||
while not self.i2c.try_lock():
|
||||
pass
|
||||
try:
|
||||
self.i2c.writeto(self.ADDR, self.SLEEP)
|
||||
finally:
|
||||
self.i2c.unlock()
|
||||
except:
|
||||
pass
|
||||
return None, None
|
||||
@@ -0,0 +1,572 @@
|
||||
"""CircuitPython Video Stream Server utility.
|
||||
|
||||
Listens for incoming TCP/UDP video frames and draws them centered and cropped on the display.
|
||||
"""
|
||||
|
||||
import time
|
||||
|
||||
class VideoStreamServer:
|
||||
def __init__(self, display, pool, tcp_port=8081, udp_port=8082, color_port=8083, color_udp_port=8084):
|
||||
self.display = display
|
||||
self.pool = pool
|
||||
self.tcp_port = tcp_port
|
||||
self.udp_port = udp_port
|
||||
self.color_port = color_port
|
||||
self.color_udp_port = color_udp_port
|
||||
|
||||
# Sockets
|
||||
self.tcp_server = None
|
||||
self.tcp_client = None
|
||||
self.udp_sock = None
|
||||
self.color_server = None
|
||||
self.color_client = None
|
||||
self.color_udp_sock = None
|
||||
|
||||
# State
|
||||
self.active = False
|
||||
self.last_packet_time = 0
|
||||
self.timeout_s = 3.0
|
||||
|
||||
# Frame buffering
|
||||
self.buffer = bytearray(15000)
|
||||
self.view = memoryview(self.buffer)
|
||||
self.tcp_bytes_received = 0
|
||||
|
||||
# UDP Reassembly
|
||||
self.udp_temp_buffer = bytearray(1002)
|
||||
self.current_frame_id = -1
|
||||
self.chunks_received = 0
|
||||
self.color_chunks_mask = 0
|
||||
|
||||
# Color buffering (320x240 RGB565 is 153,600 bytes)
|
||||
self.color_buffer = bytearray(153600)
|
||||
self.color_view = memoryview(self.color_buffer)
|
||||
self.color_bytes_received = 0
|
||||
self.color_header = bytearray(16)
|
||||
self.color_header_received = 0
|
||||
self.color_payload_len = 0
|
||||
self.color_x = 0
|
||||
self.color_y = 0
|
||||
self.color_w = 0
|
||||
self.color_h = 0
|
||||
|
||||
# Stats
|
||||
self.debug = False
|
||||
self.frames_drawn = 0
|
||||
self.udp_packets_received = 0
|
||||
self.udp_frames_complete = 0
|
||||
self.dropped_udp_frames = 0
|
||||
self.last_draw_ms = 0
|
||||
self.last_fps = 0.0
|
||||
|
||||
# FPS Calculation
|
||||
self.fps_start_time = time.monotonic()
|
||||
self.fps_frame_count = 0
|
||||
|
||||
def start(self):
|
||||
"""Initializes TCP and UDP sockets."""
|
||||
# 1. Start TCP Server (Mono)
|
||||
try:
|
||||
self.tcp_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
|
||||
try:
|
||||
self.tcp_server.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
|
||||
except:
|
||||
pass
|
||||
self.tcp_server.bind(("", self.tcp_port))
|
||||
self.tcp_server.listen(1)
|
||||
self.tcp_server.setblocking(False)
|
||||
print(f"Video TCP Stream server listening on port {self.tcp_port}...")
|
||||
except Exception as e:
|
||||
print(f"Failed to start TCP stream server: {e}")
|
||||
|
||||
# 2. Start UDP Server (Mono)
|
||||
try:
|
||||
self.udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
|
||||
try:
|
||||
self.udp_sock.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
|
||||
except:
|
||||
pass
|
||||
self.udp_sock.bind(("", self.udp_port))
|
||||
self.udp_sock.setblocking(False)
|
||||
print(f"Video UDP Stream responder listening on port {self.udp_port}...")
|
||||
except Exception as e:
|
||||
print(f"Failed to start UDP stream server: {e}")
|
||||
|
||||
# 3. Start Color TCP Server
|
||||
try:
|
||||
self.color_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
|
||||
try:
|
||||
self.color_server.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
|
||||
except:
|
||||
pass
|
||||
self.color_server.bind(("", self.color_port))
|
||||
self.color_server.listen(1)
|
||||
self.color_server.setblocking(False)
|
||||
print(f"Video Color TCP Stream server listening on port {self.color_port}...")
|
||||
except Exception as e:
|
||||
print(f"Failed to start Color TCP server: {e}")
|
||||
|
||||
# 4. Start Color UDP Server
|
||||
try:
|
||||
self.color_udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
|
||||
try:
|
||||
self.color_udp_sock.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
|
||||
except:
|
||||
pass
|
||||
self.color_udp_sock.bind(("", self.color_udp_port))
|
||||
self.color_udp_sock.setblocking(False)
|
||||
print(f"Video Color UDP Stream responder listening on port {self.color_udp_port}...")
|
||||
except Exception as e:
|
||||
print(f"Failed to start Color UDP stream server: {e}")
|
||||
|
||||
def restart_tcp_server(self):
|
||||
print("Restarting TCP Stream Server...")
|
||||
self.close_tcp_client()
|
||||
if self.tcp_server:
|
||||
try:
|
||||
self.tcp_server.close()
|
||||
except:
|
||||
pass
|
||||
self.tcp_server = None
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
self.tcp_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
|
||||
self.tcp_server.bind(("", self.tcp_port))
|
||||
self.tcp_server.listen(1)
|
||||
self.tcp_server.setblocking(False)
|
||||
except Exception as e:
|
||||
print(f"Restart TCP Server failed: {e}")
|
||||
|
||||
def restart_udp_sock(self):
|
||||
print("Restarting UDP Stream Socket...")
|
||||
if self.udp_sock:
|
||||
try:
|
||||
self.udp_sock.close()
|
||||
except:
|
||||
pass
|
||||
self.udp_sock = None
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
self.udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
|
||||
self.udp_sock.bind(("", self.udp_port))
|
||||
self.udp_sock.setblocking(False)
|
||||
except Exception as e:
|
||||
print(f"Restart UDP Socket failed: {e}")
|
||||
|
||||
def restart_color_server(self):
|
||||
print("Restarting Color TCP Stream Server...")
|
||||
self.close_color_client()
|
||||
if self.color_server:
|
||||
try:
|
||||
self.color_server.close()
|
||||
except:
|
||||
pass
|
||||
self.color_server = None
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
self.color_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
|
||||
self.color_server.bind(("", self.color_port))
|
||||
self.color_server.listen(1)
|
||||
self.color_server.setblocking(False)
|
||||
except Exception as e:
|
||||
print(f"Restart Color TCP Server failed: {e}")
|
||||
|
||||
def restart_color_udp_sock(self):
|
||||
print("Restarting Color UDP Stream Socket...")
|
||||
if self.color_udp_sock:
|
||||
try:
|
||||
self.color_udp_sock.close()
|
||||
except:
|
||||
pass
|
||||
self.color_udp_sock = None
|
||||
time.sleep(0.1)
|
||||
try:
|
||||
self.color_udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
|
||||
self.color_udp_sock.bind(("", self.color_udp_port))
|
||||
self.color_udp_sock.setblocking(False)
|
||||
except Exception as e:
|
||||
print(f"Restart Color UDP Socket failed: {e}")
|
||||
|
||||
def update(self):
|
||||
"""Non-blocking socket check for streaming updates."""
|
||||
now = time.monotonic()
|
||||
|
||||
# Check Stream Active Timeout
|
||||
if self.active and (now - self.last_packet_time) > self.timeout_s:
|
||||
print("Video stream timed out. Returning to dashboard.")
|
||||
self.active = False
|
||||
self.close_tcp_client()
|
||||
self.close_color_client()
|
||||
|
||||
# Calculate FPS periodically
|
||||
fps_elapsed = now - self.fps_start_time
|
||||
if fps_elapsed >= 2.0:
|
||||
self.last_fps = self.fps_frame_count / fps_elapsed
|
||||
self.fps_frame_count = 0
|
||||
self.fps_start_time = now
|
||||
|
||||
# 1. Handle UDP reassembly (Mono)
|
||||
if self.udp_sock:
|
||||
while True:
|
||||
try:
|
||||
# recv_into returns number of bytes read
|
||||
n = self.udp_sock.recv_into(self.udp_temp_buffer)
|
||||
if n == 0:
|
||||
break
|
||||
|
||||
self.udp_packets_received += 1
|
||||
frame_id = self.udp_temp_buffer[0]
|
||||
chunk_idx = self.udp_temp_buffer[1]
|
||||
|
||||
if chunk_idx < 15:
|
||||
self.active = True
|
||||
self.last_packet_time = now
|
||||
if frame_id != self.current_frame_id:
|
||||
if self.chunks_received != 0:
|
||||
self.dropped_udp_frames += 1
|
||||
self.current_frame_id = frame_id
|
||||
self.chunks_received = 0
|
||||
|
||||
# Copy payload to self.buffer
|
||||
start_offset = chunk_idx * 1000
|
||||
self.buffer[start_offset : start_offset + 1000] = self.udp_temp_buffer[2:1002]
|
||||
self.chunks_received |= (1 << chunk_idx)
|
||||
|
||||
if self.chunks_received == 0x7FFF:
|
||||
self.udp_frames_complete += 1
|
||||
self.active = True
|
||||
self.last_packet_time = now
|
||||
self._draw_frame()
|
||||
self.chunks_received = 0
|
||||
except OSError as e:
|
||||
import errno
|
||||
err = getattr(e, 'errno', None)
|
||||
if err is None and e.args:
|
||||
err = e.args[0]
|
||||
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
|
||||
if err in (errno.EAGAIN, ewouldblock) or err is None:
|
||||
break
|
||||
print(f"UDP Socket error: {e}")
|
||||
self.restart_udp_sock()
|
||||
break
|
||||
|
||||
# 1B. Handle UDP reassembly (Color)
|
||||
if self.color_udp_sock:
|
||||
while True:
|
||||
try:
|
||||
n = self.color_udp_sock.recv_into(self.udp_temp_buffer)
|
||||
if n == 0:
|
||||
break
|
||||
|
||||
self.udp_packets_received += 1
|
||||
frame_id = self.udp_temp_buffer[0]
|
||||
chunk_idx = self.udp_temp_buffer[1]
|
||||
|
||||
if chunk_idx < 154:
|
||||
self.active = True
|
||||
self.last_packet_time = now
|
||||
if frame_id != self.current_frame_id:
|
||||
self.current_frame_id = frame_id
|
||||
self.color_chunks_mask = 0
|
||||
|
||||
# Copy payload to self.color_buffer
|
||||
start_offset = chunk_idx * 1000
|
||||
if start_offset + 1000 <= 153600:
|
||||
self.color_buffer[start_offset : start_offset + 1000] = self.udp_temp_buffer[2:1002]
|
||||
self.color_chunks_mask |= (1 << chunk_idx)
|
||||
|
||||
if self.color_chunks_mask == 0x3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:
|
||||
self.udp_frames_complete += 1
|
||||
self.active = True
|
||||
self.last_packet_time = now
|
||||
self.color_x = 0
|
||||
self.color_y = 0
|
||||
self.color_w = 320
|
||||
self.color_h = 240
|
||||
self.color_payload_len = 153600
|
||||
self._draw_color_frame()
|
||||
self.color_chunks_mask = 0
|
||||
except OSError as e:
|
||||
import errno
|
||||
err = getattr(e, 'errno', None)
|
||||
if err is None and e.args:
|
||||
err = e.args[0]
|
||||
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
|
||||
if err in (errno.EAGAIN, ewouldblock) or err is None:
|
||||
break
|
||||
print(f"Color UDP Socket error: {e}")
|
||||
self.restart_color_udp_sock()
|
||||
break
|
||||
|
||||
# 2. Handle TCP stream (Mono)
|
||||
if self.tcp_server:
|
||||
if self.tcp_client is None:
|
||||
try:
|
||||
self.tcp_client, addr = self.tcp_server.accept()
|
||||
self.tcp_client.setblocking(False)
|
||||
self.tcp_bytes_received = 0
|
||||
self.active = True
|
||||
self.last_packet_time = now
|
||||
print(f"TCP Stream client connected from: {addr}")
|
||||
except OSError as e:
|
||||
import errno
|
||||
err = getattr(e, 'errno', None)
|
||||
if err is None and e.args:
|
||||
err = e.args[0]
|
||||
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
|
||||
if err not in (errno.EAGAIN, ewouldblock) and err is not None:
|
||||
print(f"TCP Accept error: {e}")
|
||||
self.restart_tcp_server()
|
||||
|
||||
if self.tcp_client is not None:
|
||||
retries = 0
|
||||
while self.tcp_bytes_received < 15000:
|
||||
remaining = 15000 - self.tcp_bytes_received
|
||||
slice_view = self.view[self.tcp_bytes_received : self.tcp_bytes_received + remaining]
|
||||
try:
|
||||
n = self.tcp_client.recv_into(slice_view)
|
||||
if n > 0:
|
||||
self.tcp_bytes_received += n
|
||||
self.last_packet_time = now
|
||||
self.active = True
|
||||
retries = 0
|
||||
elif n == 0:
|
||||
print("TCP Stream client disconnected.")
|
||||
self.close_tcp_client()
|
||||
break
|
||||
except OSError as e:
|
||||
import errno
|
||||
err = getattr(e, 'errno', None)
|
||||
if err is None and e.args:
|
||||
err = e.args[0]
|
||||
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
|
||||
if err in (errno.EAGAIN, ewouldblock) or err is None:
|
||||
retries += 1
|
||||
if retries > 15:
|
||||
break
|
||||
time.sleep(0.001)
|
||||
else:
|
||||
print(f"TCP Stream recv error: {e}")
|
||||
self.close_tcp_client()
|
||||
break
|
||||
|
||||
if self.tcp_bytes_received == 15000:
|
||||
self.tcp_bytes_received = 0
|
||||
self._draw_frame()
|
||||
|
||||
# 3. Handle Color TCP stream
|
||||
if self.color_server:
|
||||
if self.color_client is None:
|
||||
try:
|
||||
self.color_client, addr = self.color_server.accept()
|
||||
self.color_client.setblocking(False)
|
||||
self.color_bytes_received = 0
|
||||
self.color_header_received = 0
|
||||
self.color_payload_len = 0
|
||||
self.active = True
|
||||
self.last_packet_time = now
|
||||
print(f"Color TCP Stream client connected from: {addr}")
|
||||
except OSError as e:
|
||||
import errno
|
||||
err = getattr(e, 'errno', None)
|
||||
if err is None and e.args:
|
||||
err = e.args[0]
|
||||
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
|
||||
if err not in (errno.EAGAIN, ewouldblock) and err is not None:
|
||||
print(f"Color TCP Accept error: {e}")
|
||||
self.restart_color_server()
|
||||
|
||||
if self.color_client is not None:
|
||||
try:
|
||||
# Read header (16 bytes)
|
||||
if self.color_header_received < 16:
|
||||
start_h = time.monotonic()
|
||||
while self.color_header_received < 16:
|
||||
if (time.monotonic() - start_h) > 0.100:
|
||||
break
|
||||
remaining_h = 16 - self.color_header_received
|
||||
slice_h = memoryview(self.color_header)[self.color_header_received : self.color_header_received + remaining_h]
|
||||
n = self.color_client.recv_into(slice_h)
|
||||
if n > 0:
|
||||
self.color_header_received += n
|
||||
elif n == 0:
|
||||
self.close_color_client()
|
||||
return
|
||||
|
||||
if self.color_header_received == 16:
|
||||
# Detect version byte at index 4
|
||||
version = self.color_header[4]
|
||||
if version == 1:
|
||||
# stream_color.py format: sig (4B), version (1B), format (1B), width (2B), height (2B), payload_len (4B), reserved (2B)
|
||||
self.color_x = 0
|
||||
self.color_y = 0
|
||||
self.color_w = (self.color_header[6] << 8) | self.color_header[7]
|
||||
self.color_h = (self.color_header[8] << 8) | self.color_header[9]
|
||||
self.color_payload_len = (self.color_header[10] << 24) | (self.color_header[11] << 16) | (self.color_header[12] << 8) | self.color_header[13]
|
||||
else:
|
||||
# iPhone app format: sig (4B), x (2B), y (2B), width (2B), height (2B), payload_len (4B)
|
||||
self.color_x = (self.color_header[4] << 8) | self.color_header[5]
|
||||
self.color_y = (self.color_header[6] << 8) | self.color_header[7]
|
||||
self.color_w = (self.color_header[8] << 8) | self.color_header[9]
|
||||
self.color_h = (self.color_header[10] << 8) | self.color_header[11]
|
||||
self.color_payload_len = (self.color_header[12] << 24) | (self.color_header[13] << 16) | (self.color_header[14] << 8) | self.color_header[15]
|
||||
|
||||
# Safety check:
|
||||
if self.color_payload_len > len(self.color_buffer):
|
||||
print(f"Warning: Color payload length {self.color_payload_len} exceeds preallocated buffer {len(self.color_buffer)}. Closing connection.")
|
||||
self.close_color_client()
|
||||
return
|
||||
|
||||
self.color_bytes_received = 0
|
||||
|
||||
# Read payload
|
||||
if self.color_header_received == 16 and self.color_payload_len > 0:
|
||||
retries = 0
|
||||
while self.color_bytes_received < self.color_payload_len:
|
||||
remaining_p = self.color_payload_len - self.color_bytes_received
|
||||
slice_p = self.color_view[self.color_bytes_received : self.color_bytes_received + remaining_p]
|
||||
try:
|
||||
n = self.color_client.recv_into(slice_p)
|
||||
if n > 0:
|
||||
self.color_bytes_received += n
|
||||
self.last_packet_time = now
|
||||
self.active = True
|
||||
retries = 0
|
||||
elif n == 0:
|
||||
self.close_color_client()
|
||||
break
|
||||
except OSError as e:
|
||||
import errno
|
||||
err = getattr(e, 'errno', None)
|
||||
if err is None and e.args:
|
||||
err = e.args[0]
|
||||
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
|
||||
if err in (errno.EAGAIN, ewouldblock) or err is None:
|
||||
retries += 1
|
||||
if retries > 25: # max 25ms wait total per frame
|
||||
break
|
||||
time.sleep(0.001)
|
||||
else:
|
||||
print(f"Color TCP Stream recv error during payload: {e}")
|
||||
self.close_color_client()
|
||||
break
|
||||
|
||||
if self.color_bytes_received == self.color_payload_len:
|
||||
self._draw_color_frame()
|
||||
self.color_header_received = 0
|
||||
self.color_payload_len = 0
|
||||
self.color_bytes_received = 0
|
||||
except OSError as e:
|
||||
import errno
|
||||
err = getattr(e, 'errno', None)
|
||||
if err is None and e.args:
|
||||
err = e.args[0]
|
||||
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
|
||||
if err not in (errno.EAGAIN, ewouldblock) and err is not None:
|
||||
print(f"Color TCP Stream recv error: {e}")
|
||||
self.close_color_client()
|
||||
|
||||
def rlcd_to_mono_cp(self, rlcd_buf, canvas_buf, width, height):
|
||||
for i in range(len(canvas_buf)):
|
||||
canvas_buf[i] = 0
|
||||
|
||||
dx = (400 - width) // 2
|
||||
dy = (300 - height) // 2
|
||||
width_bytes = width // 8
|
||||
|
||||
for index in range(15000):
|
||||
val = rlcd_buf[index]
|
||||
if val == 0:
|
||||
continue
|
||||
|
||||
byte_x = index // 75
|
||||
block_y = index % 75
|
||||
x_base = 2 * byte_x
|
||||
y_base = 299 - 4 * block_y
|
||||
|
||||
for local_y in range(4):
|
||||
for local_x in range(2):
|
||||
bit = 7 - (local_y * 2 + local_x)
|
||||
if val & (1 << bit):
|
||||
x = x_base + local_x
|
||||
y = y_base - local_y
|
||||
screen_x = x - dx
|
||||
screen_y = y - dy
|
||||
|
||||
if 0 <= screen_x < width and 0 <= screen_y < height:
|
||||
byte_idx = screen_y * width_bytes + (screen_x >> 3)
|
||||
bit_idx = 7 - (screen_x & 7)
|
||||
canvas_buf[byte_idx] |= (1 << bit_idx)
|
||||
|
||||
def _draw_frame(self):
|
||||
draw_start = time.monotonic()
|
||||
|
||||
# Check if RLCD display vs standard ILI9341 display
|
||||
disp_name = self.display.__class__.__name__
|
||||
if disp_name == "RLCD":
|
||||
# Direct SPI write commands for RLCD layout
|
||||
self.display.write_cmd(0x2A)
|
||||
self.display.write_data([0x12, 0x2A])
|
||||
self.display.write_cmd(0x2B)
|
||||
self.display.write_data([0x00, 0xC7])
|
||||
self.display.write_cmd(0x2C)
|
||||
self.display.write_data(self.buffer)
|
||||
else:
|
||||
# Map 400x300 RLCD buffer into the ILI9341 320x240 canvas buffer
|
||||
self.rlcd_to_mono_cp(self.buffer, self.display.canvas_buffer, self.display.width, self.display.height)
|
||||
self.display.show()
|
||||
|
||||
self.last_draw_ms = int((time.monotonic() - draw_start) * 1000)
|
||||
self.frames_drawn += 1
|
||||
self.fps_frame_count += 1
|
||||
|
||||
def _draw_color_frame(self):
|
||||
draw_start = time.monotonic()
|
||||
if hasattr(self.display, "draw_rgb565"):
|
||||
self.display.draw_rgb565(
|
||||
self.color_x,
|
||||
self.color_y,
|
||||
self.color_w,
|
||||
self.color_h,
|
||||
self.color_view[:self.color_payload_len],
|
||||
sync_canvas=False,
|
||||
)
|
||||
else:
|
||||
# Fallback if no raw RGB565 method is exposed (e.g. standard RLCD)
|
||||
pass
|
||||
|
||||
self.last_draw_ms = int((time.monotonic() - draw_start) * 1000)
|
||||
self.frames_drawn += 1
|
||||
self.fps_frame_count += 1
|
||||
|
||||
def get_stats(self):
|
||||
return {
|
||||
"frames_drawn": self.frames_drawn,
|
||||
"tcp_bytes_received": self.tcp_bytes_received,
|
||||
"udp_packets_received": self.udp_packets_received,
|
||||
"udp_frames_complete": self.udp_frames_complete,
|
||||
"dropped_udp_frames": self.dropped_udp_frames,
|
||||
"last_draw_ms": self.last_draw_ms,
|
||||
"last_fps": round(self.last_fps, 1),
|
||||
"active": self.active,
|
||||
}
|
||||
|
||||
def close_tcp_client(self):
|
||||
if self.tcp_client:
|
||||
try:
|
||||
self.tcp_client.close()
|
||||
except:
|
||||
pass
|
||||
self.tcp_client = None
|
||||
self.tcp_bytes_received = 0
|
||||
|
||||
def close_color_client(self):
|
||||
if self.color_client:
|
||||
try:
|
||||
self.color_client.close()
|
||||
except:
|
||||
pass
|
||||
self.color_client = None
|
||||
self.color_bytes_received = 0
|
||||
self.color_header_received = 0
|
||||
self.color_payload_len = 0
|
||||
Reference in New Issue
Block a user