Migrate MCP and utilities to CircuitPython, including color GIF and volume fixes

This commit is contained in:
Adolfo Reyna
2026-06-22 10:13:10 -04:00
parent 2863f21459
commit 8f871e499e
53 changed files with 4239 additions and 108 deletions
+406 -82
View File
@@ -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 import gc
@@ -10,106 +11,429 @@ import time
import board import board
import busio import busio
import digitalio import digitalio
import wifi
import socketpool
import adafruit_requests
# Import drivers
from audio_cp import BoardAudio from audio_cp import BoardAudio
from gif_player import GIFPlayer
from rlcd_cp import RLCD 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 def detect_and_init():
# how they expose ESP32-S3 pins. If board.IO## is missing, run `dir(board)` on global board_type, display, touch, audio, sensor, rtc_chip, battery, led, buttons, ble_uart
# 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,
}
print("Scanning for board type...")
def exists(path): # Try Hosyond pins first (SDA=IO16, SCL=IO15)
try: try:
os.stat(path) i2c = busio.I2C(scl=board.IO15, sda=board.IO16)
return True while not i2c.try_lock():
except OSError: pass
return False 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(): # Display: ILI9341 (320x240)
spi = busio.SPI(clock=PINS["display_sck"], MOSI=PINS["display_mosi"]) spi = busio.SPI(clock=board.IO12, MOSI=board.IO11)
# Match the working MicroPython code's 20 MHz SPI if the build supports it. display = ILI9341(
while not spi.try_lock(): spi,
pass 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: try:
spi.configure(baudrate=20000000, phase=0, polarity=0) i2c = busio.I2C(scl=board.IO14, sda=board.IO13)
finally: while not i2c.try_lock():
spi.unlock() pass
display = RLCD( 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, spi,
cs=digitalio.DigitalInOut(PINS["display_cs"]), cs=digitalio.DigitalInOut(board.IO10),
dc=digitalio.DigitalInOut(PINS["display_dc"]), dc=digitalio.DigitalInOut(board.IO46),
rst=digitalio.DigitalInOut(PINS["display_rst"]), bl=board.IO45,
rst=None,
) )
return display i2c_bus = busio.I2C(scl=board.IO15, sda=board.IO16)
try:
touch = FT6336U(
def init_audio(): i2c_bus,
i2c = busio.I2C(scl=PINS["i2c_scl"], sda=PINS["i2c_sda"]) rst_pin=digitalio.DigitalInOut(board.IO18),
return BoardAudio( int_pin=digitalio.DigitalInOut(board.IO17),
i2c, width=320,
bit_clock=PINS["i2s_bclk"], height=240,
word_select=PINS["i2s_lrck"], swap_xy=True,
data=PINS["i2s_dout"], invert_x=False,
mclk=PINS["audio_mclk"], invert_y=True,
amp=PINS["audio_amp"], )
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,
) )
battery = BatteryMonitor(board.IO9)
led = BoardLED(board.IO48)
def draw_boot_screen(display, message="CircuitPython RLCD"): buttons = BoardButtons(board.IO0)
display.clear(0)
display.text("ESP32-S3-RLCD", 10, 10, 1) ble_name = "ESP32-S3-Touch"
display.line(10, 22, 390, 22, 1) try:
display.text_large("CIRCUITPY", 18, 45, scale=3, color=1) ble_uart = BLEUART(name=ble_name)
display.text(message, 18, 86, 1) except Exception as ble_err:
display.text("MP3: /demo.mp3", 18, 116, 1) print(f"BLE init failed: {ble_err}")
display.text("GIF: /demo.gif", 18, 132, 1)
display.text("If visible, ST7305 init works", 18, 170, 1)
display.show()
def main(): def main():
display = init_display() global ip_addr
draw_boot_screen(display, "Display initialized") detect_and_init()
time.sleep(1)
# Try GIF first because it verifies display animation support. # Draw initial dashboard
if exists("/demo.gif"): display.clear(0)
draw_boot_screen(display, "Playing GIF...") display.text("CircuitPython Active", 10, 10, 1)
GIFPlayer(display).play("/demo.gif", loops=1) display.text("Connecting Wi-Fi...", 10, 30, 1)
draw_boot_screen(display, "GIF done") display.show()
gc.collect()
# Try MP3 if present. If this fails silently, verify MCLK on IO16 and codec # Sync system clock from RTC chip if available
# register sequence against a known-good WAV/tone first. if rtc_chip:
if exists("/demo.mp3"):
draw_boot_screen(display, "Playing MP3...")
try: try:
audio = init_audio() rtc_chip.sync_to_system()
ok = audio.play_mp3("/demo.mp3", volume=65) except Exception as e:
draw_boot_screen(display, "MP3 ok" if ok else "MP3 failed") print("Failed to sync clock:", e)
except Exception as exc:
draw_boot_screen(display, "MP3 error: " + str(exc)[:28]) # 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: 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()
+4 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash #!/usr/bin/env bash
set -euo pipefail set -euo pipefail
PORT="${PORT:-/dev/ttyACM0}" PORT="${PORT:-/dev/cu.usbmodem101}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FIRMWARE="$ROOT/firmware/adafruit-circuitpython-espressif_esp32s3_devkitc_1_n8r8-en_US-10.2.1.bin" FIRMWARE="$ROOT/firmware/adafruit-circuitpython-espressif_esp32s3_devkitc_1_n8r8-en_US-10.2.1.bin"
CIRCUITPY_MOUNT="${CIRCUITPY_MOUNT:-}" CIRCUITPY_MOUNT="${CIRCUITPY_MOUNT:-}"
@@ -26,13 +26,13 @@ EOF
fi fi
echo "==> Probing ESP32-S3 on $PORT" 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" 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" 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" echo "==> Waiting for CIRCUITPY mount"
for _ in $(seq 1 60); do 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.
+90 -10
View File
@@ -5,13 +5,17 @@ playback using CircuitPython's audiomp3 + audiobusio stack.
""" """
import time import time
import array
import math
import audiobusio import audiobusio
import audiomp3 import audiomp3
import audiocore
import digitalio import digitalio
import pwmio import pwmio
class ES8311: class ES8311:
ADDR = 0x18 ADDR = 0x18
@@ -63,20 +67,29 @@ class ES8311:
def set_volume(self, volume): def set_volume(self, volume):
volume = max(0, min(100, int(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: 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.i2c = i2c
self.bit_clock_pin = bit_clock self.bit_clock_pin = bit_clock
self.word_select_pin = word_select self.word_select_pin = word_select
self.data_pin = data self.data_pin = data
self.mclk_pin = mclk self.mclk_pin = mclk
self.amp_pin = amp self.amp_pin = amp
self.amp_active_level = amp_active_level
self._mclk = None self._mclk = None
self._amp = digitalio.DigitalInOut(amp) 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) self.codec = ES8311(i2c)
def start_mclk(self): def start_mclk(self):
@@ -88,7 +101,7 @@ class BoardAudio:
frequency=12288000, frequency=12288000,
duty_cycle=32768, duty_cycle=32768,
variable_frequency=False, variable_frequency=False,
) )
def stop_mclk(self): def stop_mclk(self):
if self._mclk is not None: if self._mclk is not None:
@@ -106,25 +119,92 @@ class BoardAudio:
try: try:
f = open(filename, "rb") f = open(filename, "rb")
decoder = audiomp3.MP3Decoder(f) decoder = audiomp3.MP3Decoder(f)
# Use decoder-derived rate if available, else fall back.
sample_rate = getattr(decoder, "sample_rate", 44100)
audio = audiobusio.I2SOut( audio = audiobusio.I2SOut(
bit_clock=self.bit_clock_pin, bit_clock=self.bit_clock_pin,
word_select=self.word_select_pin, word_select=self.word_select_pin,
data=self.data_pin, data=self.data_pin,
) )
self._amp.value = True self._amp.value = self.amp_active_level
audio.play(decoder) audio.play(decoder)
while audio.playing: while audio.playing:
time.sleep(0.05) time.sleep(0.05)
return True return True
finally: finally:
self._amp.value = False self._amp.value = not self.amp_active_level
if audio is not None: if audio is not None:
audio.deinit() audio.deinit()
if decoder is not None: if decoder is not None:
decoder.deinit() decoder.deinit()
if f is not None: if f is not None:
f.close() 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()
+42
View File
@@ -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}%)"
+101
View File
@@ -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.")
+73
View File
@@ -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()
+66
View File
@@ -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()
+144
View File
@@ -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 -3
View File
@@ -10,7 +10,7 @@ class GIFPlayer:
def __init__(self, display): def __init__(self, display):
self.display = 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. """Decode a GIF from disk and push frames to the reflective LCD.
CircuitPython gifio currently supports GIFs up to 320 pixels wide, so CircuitPython gifio currently supports GIFs up to 320 pixels wide, so
@@ -23,14 +23,21 @@ class GIFPlayer:
try: try:
count = 0 count = 0
while loops < 0 or count < loops: while loops < 0 or count < loops:
frame_count = 0
while True: while True:
if max_frames > 0 and frame_count >= max_frames:
break
delay = gif.next_frame() delay = gif.next_frame()
if delay is None: if delay is None:
break break
if clear_between_frames: if clear_between_frames:
self.display.clear(0) self.display.clear(0)
self.display.draw_bitmap_threshold(gif.bitmap, x, y, threshold=threshold) if hasattr(self.display, "draw_bitmap_color"):
self.display.show() 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 # gifio delay is seconds in modern CircuitPython builds. If
# an older build plays 100x too slowly, divide by 100 here. # an older build plays 100x too slowly, divide by 100 here.
time.sleep(max(0.0, delay)) time.sleep(max(0.0, delay))
+481
View File
@@ -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.
+43
View File
@@ -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()
+132
View File
@@ -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"
+85
View File
@@ -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
+101
View File
@@ -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
+572
View File
@@ -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
+47 -8
View File
@@ -19,21 +19,53 @@ def rgb565be(image: Image.Image) -> bytes:
return bytes(output) return bytes(output)
def send_frame(sock: socket.socket, image: Image.Image) -> None: def send_frame_tcp(sock: socket.socket, image: Image.Image) -> None:
payload = rgb565be(image) payload = rgb565be(image)
header = struct.pack(">4sBBHHIH", b"IMCR", 1, 1, image.width, image.height, len(payload), 0) header = struct.pack(">4sBBHHIH", b"IMCR", 1, 1, image.width, image.height, len(payload), 0)
sock.sendall(header + payload) sock.sendall(header + payload)
def sleep_us(duration_us: int) -> None:
target = time.perf_counter_ns() + duration_us * 1000
while time.perf_counter_ns() < target:
pass
def send_frame_udp(sock: socket.socket, ip: str, port: int, frame_idx: int, image: Image.Image, pacing_us: int) -> None:
payload = rgb565be(image)
frame_id = frame_idx % 256
for chunk_idx in range(154):
packet = bytearray(1002)
packet[0] = frame_id
packet[1] = chunk_idx
start = chunk_idx * 1000
packet[2:1002] = payload[start : start + 1000]
sock.sendto(packet, (ip, port))
if pacing_us > 0:
sleep_us(pacing_us)
def main() -> None: def main() -> None:
parser = argparse.ArgumentParser() parser = argparse.ArgumentParser()
parser.add_argument("--ip", required=True, help="iPhone Wi-Fi IP") parser.add_argument("--ip", required=True, help="iPhone Wi-Fi IP")
parser.add_argument("--port", type=int, default=8083) parser.add_argument("--protocol", choices=["tcp", "udp"], default="tcp")
parser.add_argument("--width", type=int, default=400) parser.add_argument("--port", type=int)
parser.add_argument("--height", type=int, default=300) parser.add_argument("--width", type=int, default=320)
parser.add_argument("--height", type=int, default=240)
parser.add_argument("--pacing-us", type=int, default=1000, help="Microseconds pacing between UDP chunks")
args = parser.parse_args() args = parser.parse_args()
with socket.create_connection((args.ip, args.port), timeout=8) as sock: port = args.port
if port is None:
port = 8083 if args.protocol == "tcp" else 8084
if args.protocol == "tcp":
sock = socket.create_connection((args.ip, port), timeout=8)
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
else:
sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
try:
frame = 0 frame = 0
while True: while True:
hue = (frame * 4) % 256 hue = (frame * 4) % 256
@@ -45,11 +77,18 @@ def main() -> None:
draw.line((x, 0, x, args.height - 1), fill=(red, 70, blue)) draw.line((x, 0, x, args.height - 1), fill=(red, 70, blue))
draw.rounded_rectangle((20, 20, args.width - 20, args.height - 20), radius=22, draw.rounded_rectangle((20, 20, args.width - 20, args.height - 20), radius=22,
outline=(255, 255, 255), width=4) outline=(255, 255, 255), width=4)
draw.text((40, 45), "iPhone MCP RGB565", fill=(255, 255, 255)) draw.text((40, 45), f"iPhone MCP RGB565 ({args.protocol.upper()})", fill=(255, 255, 255))
draw.text((40, 72), f"Frame {frame}", fill=(255, 240, 80)) draw.text((40, 72), f"Frame {frame}", fill=(255, 240, 80))
send_frame(sock, image)
if args.protocol == "tcp":
send_frame_tcp(sock, image)
else:
send_frame_udp(sock, args.ip, port, frame, image, args.pacing_us)
frame += 1 frame += 1
time.sleep(1 / 20) time.sleep(1 / 15)
finally:
sock.close()
if __name__ == "__main__": if __name__ == "__main__":
+59
View File
@@ -439,3 +439,62 @@ class ILI9341:
self.spi.write(self.row_buffer) self.spi.write(self.row_buffer)
self.cs(1) self.cs(1)
def set_brightness(self, level):
"""Set backlight brightness percentage (0-100)."""
if self.bl is None:
return
from machine import Pin, PWM
level = max(0, min(100, level))
if level == 0:
if hasattr(self, '_bl_pwm') and self._bl_pwm is not None:
try:
self._bl_pwm.deinit()
except:
pass
self._bl_pwm = None
if isinstance(self.bl, Pin):
self.bl.init(Pin.OUT, value=0)
elif level == 100:
if hasattr(self, '_bl_pwm') and self._bl_pwm is not None:
try:
self._bl_pwm.deinit()
except:
pass
self._bl_pwm = None
if isinstance(self.bl, Pin):
self.bl.init(Pin.OUT, value=1)
else:
if not hasattr(self, '_bl_pwm') or self._bl_pwm is None:
self._bl_pwm = PWM(self.bl)
self._bl_pwm.freq(1000)
self._bl_pwm.duty_u16(int(level * 655.35))
def set_power(self, on):
"""Set display power status (True = ON, False = OFF)."""
if on:
self.write_cmd(0x11) # SLPOUT
time.sleep_ms(120)
self.write_cmd(0x29) # DISPON
if self.bl is not None:
if hasattr(self, '_bl_pwm') and self._bl_pwm is not None:
pass
else:
from machine import Pin
if isinstance(self.bl, Pin):
self.bl.init(Pin.OUT, value=1)
else:
self.write_cmd(0x28) # DISPOFF
self.write_cmd(0x10) # SLPIN
time.sleep_ms(10)
if self.bl is not None:
if hasattr(self, '_bl_pwm') and self._bl_pwm is not None:
try:
self._bl_pwm.deinit()
except:
pass
self._bl_pwm = None
from machine import Pin
if isinstance(self.bl, Pin):
self.bl.init(Pin.OUT, value=0)
+17 -1
View File
@@ -255,4 +255,20 @@ class RLCD:
self.write_cmd(0x2C) self.write_cmd(0x2C)
self.cs(0); self.dc(1) self.cs(0); self.dc(1)
self.spi.write(self.hw_buffer) self.spi.write(self.hw_buffer)
self.cs(1) self.cs(1)
def set_brightness(self, level):
"""Set backlight brightness percentage. RLCD is reflective and doesn't support backlight."""
print("RLCD is a reflective LCD and does not support backlight brightness control.")
pass
def set_power(self, on):
"""Set display power status (True = ON, False = OFF)."""
if on:
self.write_cmd(0x11) # SLPOUT
time.sleep_ms(120)
self.write_cmd(0x29) # DISPON
else:
self.write_cmd(0x28) # DISPOFF
self.write_cmd(0x10) # SLPIN
time.sleep_ms(10)
+59
View File
@@ -427,3 +427,62 @@ class ST7796:
self.spi.write(self.row_buffer) self.spi.write(self.row_buffer)
self.cs(1) self.cs(1)
def set_brightness(self, level):
"""Set backlight brightness percentage (0-100)."""
if self.bl is None:
return
from machine import Pin, PWM
level = max(0, min(100, level))
if level == 0:
if hasattr(self, '_bl_pwm') and self._bl_pwm is not None:
try:
self._bl_pwm.deinit()
except:
pass
self._bl_pwm = None
if isinstance(self.bl, Pin):
self.bl.init(Pin.OUT, value=0)
elif level == 100:
if hasattr(self, '_bl_pwm') and self._bl_pwm is not None:
try:
self._bl_pwm.deinit()
except:
pass
self._bl_pwm = None
if isinstance(self.bl, Pin):
self.bl.init(Pin.OUT, value=1)
else:
if not hasattr(self, '_bl_pwm') or self._bl_pwm is None:
self._bl_pwm = PWM(self.bl)
self._bl_pwm.freq(1000)
self._bl_pwm.duty_u16(int(level * 655.35))
def set_power(self, on):
"""Set display power status (True = ON, False = OFF)."""
if on:
self.write_cmd(0x11) # SLPOUT
time.sleep_ms(120)
self.write_cmd(0x29) # DISPON
if self.bl is not None:
if hasattr(self, '_bl_pwm') and self._bl_pwm is not None:
pass
else:
from machine import Pin
if isinstance(self.bl, Pin):
self.bl.init(Pin.OUT, value=1)
else:
self.write_cmd(0x28) # DISPOFF
self.write_cmd(0x10) # SLPIN
time.sleep_ms(10)
if self.bl is not None:
if hasattr(self, '_bl_pwm') and self._bl_pwm is not None:
try:
self._bl_pwm.deinit()
except:
pass
self._bl_pwm = None
from machine import Pin
if isinstance(self.bl, Pin):
self.bl.init(Pin.OUT, value=0)
+38
View File
@@ -474,6 +474,28 @@ class MCPServer:
"name": "get_stream_stats", "name": "get_stream_stats",
"description": "Get real-time device-side performance and frame-rate statistics for the TCP/UDP video stream.", "description": "Get real-time device-side performance and frame-rate statistics for the TCP/UDP video stream.",
"inputSchema": {"type": "object", "properties": {}} "inputSchema": {"type": "object", "properties": {}}
},
{
"name": "set_backlight",
"description": "Adjust the brightness of the LCD backlight.",
"inputSchema": {
"type": "object",
"properties": {
"brightness": {"type": "integer", "minimum": 0, "maximum": 100, "description": "Backlight brightness percentage (0-100)"}
},
"required": ["brightness"]
}
},
{
"name": "set_screen_power",
"description": "Turn the screen/display on or off.",
"inputSchema": {
"type": "object",
"properties": {
"power": {"type": "boolean", "description": "True to turn display ON, False to turn display OFF"}
},
"required": ["power"]
}
} }
] ]
}, },
@@ -526,6 +548,22 @@ class MCPServer:
self.display.show() self.display.show()
return "Screen cleared." return "Screen cleared."
elif name == "set_backlight":
brightness = int(args.get("brightness", 100))
if hasattr(self.display, "set_brightness"):
self.display.set_brightness(brightness)
return f"Backlight brightness set to {brightness}%."
else:
return "Backlight brightness control not supported on this display."
elif name == "set_screen_power":
power = bool(args.get("power", True))
if hasattr(self.display, "set_power"):
self.display.set_power(power)
status = "ON" if power else "OFF"
return f"Screen power set to {status}."
else:
return "Screen power control not supported on this display."
elif name == "draw_text": elif name == "draw_text":
self.override_active = True self.override_active = True
text = str(args.get("text", "")) text = str(args.get("text", ""))
+97
View File
@@ -0,0 +1,97 @@
# pong.py
import time
import board_config
def run_pong(frames=200):
display = board_config.display_instance
if not display:
print("No display found")
return
width = display.width
height = display.height
# Ball state
bx, by = width // 2, height // 2
vx, vy = 6, 4
ball_size = 6
# Paddle state
pad_w, pad_h = 8, 40
p1_y = height // 2 - pad_h // 2
p2_y = height // 2 - pad_h // 2
p1_score = 0
p2_score = 0
for _ in range(frames):
# Update ball
bx += vx
by += vy
# Bounce top/bottom
if by <= 0 or by >= height - ball_size:
vy = -vy
# Paddle AI (simple tracking)
target1 = by - pad_h // 2
p1_y += int((target1 - p1_y) * 0.15)
p1_y = max(0, min(height - pad_h, p1_y))
target2 = by - pad_h // 2
p2_y += int((target2 - p2_y) * 0.15)
p2_y = max(0, min(height - pad_h, p2_y))
# Bounce left paddle
if vx < 0 and bx <= 20 and bx >= 10:
if by + ball_size >= p1_y and by <= p1_y + pad_h:
vx = -vx
vy += int((by - (p1_y + pad_h // 2)) * 0.2)
# Bounce right paddle
if vx > 0 and bx >= width - 20 - pad_w and bx <= width - 10:
if by + ball_size >= p2_y and by <= p2_y + pad_h:
vx = -vx
vy += int((by - (p2_y + pad_h // 2)) * 0.2)
# Score left
if bx < 0:
p2_score += 1
bx, by = width // 2, height // 2
vx = 6
vy = 4
# Score right
if bx > width:
p1_score += 1
bx, by = width // 2, height // 2
vx = -6
vy = -4
# Draw everything
display.clear(0)
# Center line
for y in range(0, height, 15):
display.fill_rect(width // 2 - 1, y, 2, 8, 1)
# Paddles
display.fill_rect(10, p1_y, pad_w, pad_h, 1)
display.fill_rect(width - 20, p2_y, pad_w, pad_h, 1)
# Ball
display.fill_rect(bx, by, ball_size, ball_size, 1)
# Score
display.text(str(p1_score), width // 2 - 30, 10, 1)
display.text(str(p2_score), width // 2 + 20, 10, 1)
display.show()
time.sleep_ms(30)
# Clear screen at the end
display.clear(0)
display.show()
# Run the animation
run_pong()
+25
View File
@@ -0,0 +1,25 @@
import urllib.request
import json
def call_mcp(method, params={}):
url = "http://192.168.68.118/api/mcp"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=5.0) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
return {"error": str(e)}
res = call_mcp("tools/call", {"name": "get_stream_stats", "arguments": {}})
print(json.dumps(res, indent=2))
+30
View File
@@ -0,0 +1,30 @@
import serial
import time
import sys
def main():
port = "/dev/cu.usbmodem411CF91D65091"
print(f"Connecting to {port} at 115200...")
try:
ser = serial.Serial(port, 115200, timeout=1.0)
except Exception as e:
print(f"Error opening serial port: {e}")
return
print("Reading serial output for 3 seconds...")
start_time = time.time()
try:
while time.time() - start_time < 3.0:
if ser.in_waiting > 0:
data = ser.read(ser.in_waiting)
sys.stdout.write(data.decode("utf-8", "ignore"))
sys.stdout.flush()
time.sleep(0.1)
except KeyboardInterrupt:
pass
finally:
ser.close()
print("\nSerial connection closed.")
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
import serial
import time
import sys
def main():
port = "/dev/cu.usbmodem411CF91D65091"
print(f"Connecting to {port} at 115200...")
try:
ser = serial.Serial(port, 115200, timeout=1.0)
except Exception as e:
print(f"Error: {e}")
return
print("Sending Ctrl-C to interrupt...")
ser.write(b'\x03')
time.sleep(0.5)
print("--- Current buffer contents ---")
print(ser.read_all().decode("utf-8", "ignore"))
print("Sending Ctrl-D to trigger soft reboot...")
ser.write(b'\x04')
print("--- Capturing boot output (10 seconds) ---")
start_time = time.time()
try:
while time.time() - start_time < 10.0:
if ser.in_waiting > 0:
data = ser.read(ser.in_waiting)
sys.stdout.write(data.decode("utf-8", "ignore"))
sys.stdout.flush()
time.sleep(0.05)
except KeyboardInterrupt:
pass
finally:
ser.close()
print("\nConnection closed.")
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
import serial
import time
import sys
def run_diagnostic(port):
print(f"Opening serial port {port}...")
try:
ser = serial.Serial(port, 115200, timeout=1)
except Exception as e:
print("Failed to open serial port:", e)
return False
print("Interrupting board execution (Ctrl+C)...")
for _ in range(30):
ser.write(b'\x03')
time.sleep(0.02)
time.sleep(0.5)
ser.read_all()
print("Entering raw REPL...")
ser.write(b'\x01')
time.sleep(0.2)
resp = ser.read_all()
if b'raw REPL' not in resp and b'OK' not in resp and b'raw' not in resp:
print("Could not enter raw REPL:", resp)
ser.close()
return False
# Python code to test playback
test_code = """
import audio_util
try:
print("Starting play_tone test...")
audio_util.play_tone(440, 1000, 50)
print("play_tone test finished!")
except Exception as e:
import sys
sys.print_exception(e)
"""
print("Running diagnostic code on the board...")
# Send code
ser.write(test_code.encode('utf-8'))
ser.write(b'\x04') # Ctrl+D to execute
time.sleep(0.2)
# Read output
print("\n--- Board Output ---")
start = time.time()
while time.time() - start < 5.0:
line = ser.readline()
if line:
print(line.decode('utf-8', 'ignore').strip())
# Reset the board to return to normal
print("Resetting board to normal...")
ser.write(b'\x02') # Ctrl+B to exit raw REPL
time.sleep(0.1)
ser.write(b'\x03')
time.sleep(0.1)
ser.write(b"import machine\nmachine.reset()\n")
ser.close()
return True
if __name__ == "__main__":
run_diagnostic('/dev/cu.usbmodem101')
+64
View File
@@ -0,0 +1,64 @@
import urllib.request
import urllib.parse
import time
from PIL import Image, ImageDraw
IP = "192.168.68.128"
def send_raw_frame(payload, x=0, y=0, w=320, h=240, fmt=None):
params = {"x": x, "y": y, "w": w, "h": h}
if fmt:
params["format"] = fmt
q = urllib.parse.urlencode(params)
url = f"http://{IP}/api/screen/raw?{q}"
req = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/octet-stream"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=5.0) as resp:
return resp.read().decode("utf-8")
except Exception as e:
return f"Error: {e}"
# 1. Test Color Frame (RGB565)
print("Generating raw color frame...")
img_color = Image.new("RGB", (320, 240))
draw = ImageDraw.Draw(img_color)
for x in range(320):
for y in range(240):
r = (x * 255 // 319)
g = (y * 255 // 239)
b = 128
img_color.putpixel((x, y), (r, g, b))
draw.text((20, 20), "RAW COLOR STREAM (RGB565)", fill=(255, 255, 255))
# Convert to RGB565 BE bytes
color_payload = bytearray(320 * 240 * 2)
for idx, (r, g, b) in enumerate(img_color.getdata()):
val = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
color_payload[idx * 2] = (val >> 8) & 0xFF
color_payload[idx * 2 + 1] = val & 0xFF
print("Streaming raw color frame...")
res = send_raw_frame(bytes(color_payload), fmt="rgb565")
print("Response:", res)
time.sleep(3.0)
# 2. Test Monochrome Frame (1-bit)
print("\nGenerating raw monochrome frame...")
img_mono = Image.new("1", (320, 240), 0)
draw_mono = ImageDraw.Draw(img_mono)
draw_mono.rectangle([10, 10, 310, 230], outline=1, width=3)
draw_mono.text((30, 100), "RAW MONOCHROME STREAM (1-BIT)", fill=1)
# Convert PIL 1-bit to MHMSB bytearray
mono_payload = img_mono.tobytes()
print(f"Monochrome payload size: {len(mono_payload)} bytes")
print("Streaming raw monochrome frame...")
res_mono = send_raw_frame(mono_payload, fmt="mono")
print("Response:", res_mono)
+48
View File
@@ -0,0 +1,48 @@
import urllib.request
import json
import time
def call_mcp(method, params={}):
url = "http://192.168.68.128/api/mcp"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=5.0) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
return {"error": str(e)}
# 1. Fetch start stats
print("Fetching start stats...")
res_start = call_mcp("tools/call", {"name": "get_stream_stats", "arguments": {}})
print(json.dumps(res_start, indent=2))
# 2. Wait 3 seconds
print("Waiting 3 seconds...")
time.sleep(3.0)
# 3. Fetch end stats
print("Fetching end stats...")
res_end = call_mcp("tools/call", {"name": "get_stream_stats", "arguments": {}})
print(json.dumps(res_end, indent=2))
# Calculate FPS
try:
start_stats = json.loads(res_start["result"]["content"][0]["text"])
end_stats = json.loads(res_end["result"]["content"][0]["text"])
frames_diff = end_stats["frames_drawn"] - start_stats["frames_drawn"]
packets_diff = end_stats["udp_packets_received"] - start_stats["udp_packets_received"]
print(f"\nResult: Drew {frames_diff} frames in 3.0 seconds ({frames_diff / 3.0:.1f} FPS)")
print(f"Packets received: {packets_diff} in 3.0 seconds ({packets_diff / 3.0:.1f} pps)")
except Exception as e:
print("Failed to calculate FPS:", e)
+88
View File
@@ -0,0 +1,88 @@
import serial
import time
import sys
def upload_via_serial(port, local_path, remote_path):
print(f"Opening serial port {port}...")
try:
ser = serial.Serial(port, 115200, timeout=0.5)
except Exception as e:
print("Failed to open serial port:", e)
return False
print("Interrupting board (Ctrl+C)...")
for _ in range(50):
ser.write(b'\x03')
time.sleep(0.02)
time.sleep(0.5)
ser.read_all()
print("Entering raw REPL...")
ser.write(b'\x01')
time.sleep(0.2)
resp = ser.read_all()
if b'raw REPL' not in resp and b'OK' not in resp and b'raw' not in resp:
print("Could not enter raw REPL:", resp)
ser.close()
return False
with open(local_path, 'rb') as f:
content = f.read()
print(f"Uploading {local_path} to remote {remote_path} ({len(content)} bytes)...")
# Open file
cmd = f"f = open('{remote_path}', 'wb')\n".encode('utf-8')
ser.write(cmd)
ser.write(b'\x04')
time.sleep(0.1)
ser.read_all()
# Write chunks
chunk_size = 256
for i in range(0, len(content), chunk_size):
chunk = content[i:i+chunk_size]
cmd = b"f.write(" + repr(chunk).encode('utf-8') + b")\n"
ser.write(cmd)
ser.write(b'\x04')
time.sleep(0.05)
sys.stdout.write('.')
sys.stdout.flush()
cmd = b"f.close()\n"
ser.write(cmd)
ser.write(b'\x04')
time.sleep(0.1)
ser.read_all()
print(f"\nUploaded {remote_path} successfully!")
ser.write(b'\x02')
ser.close()
return True
def main():
port = '/dev/cu.usbmodem101'
files_to_upload = [
("main.py", "main.py"),
("demo_websocket_voice.py", "demo_websocket_voice.py")
]
for local, remote in files_to_upload:
if not upload_via_serial(port, local, remote):
print(f"Failed to upload {local}. Exiting.")
sys.exit(1)
print("Rebooting board...")
try:
ser = serial.Serial(port, 115200, timeout=0.5)
ser.write(b'\x03')
time.sleep(0.1)
ser.write(b'import machine\nmachine.reset()\n')
ser.close()
print("Reset triggered successfully!")
except Exception as e:
print("Could not trigger reset:", e)
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
# screensaver.py
import time
import board_config
def run_screensaver(duration_sec=120):
display = board_config.display_instance
touch = board_config.touch
if not display:
print("No display found")
return
width = display.width
height = display.height
# Calculate card dimensions based on screen size
card_w = int(width * 0.4)
card_h = int(height * 0.6)
y_offset = int((height - card_h) / 2)
x_gap = int(width * 0.08)
x1 = int((width - (card_w * 2 + x_gap)) / 2)
x2 = x1 + card_w + x_gap
# Use larger text scale on larger screens
scale = 6 if width >= 400 else 5
def draw_card(x, y, val_str, flip_state=0):
# Draw white card body
display.fill_rect(x, y, card_w, card_h, 1)
# Center coordinates for text inside card
text_w = 2 * 8 * scale
text_h = 8 * scale
tx = x + (card_w - text_w) // 2
ty = y + (card_h - text_h) // 2
# Draw text in black on the white card
display.text_large(val_str, tx, ty, scale=scale, c=0)
# Draw split line and side notches to simulate 3D split-flap cards
split_y = y + card_h // 2
if flip_state == 0:
display.line(x, split_y, x + card_w, split_y, 0)
display.fill_rect(x, split_y - 3, 3, 6, 0)
display.fill_rect(x + card_w - 3, split_y - 3, 3, 6, 0)
elif flip_state == 1:
# Widening line (flap rotating slightly down)
display.fill_rect(x, split_y - 3, card_w, 6, 0)
display.fill_rect(x, split_y - 5, 4, 10, 0)
display.fill_rect(x + card_w - 4, split_y - 5, 4, 10, 0)
elif flip_state == 2:
# Thick black bar (flap mid-rotation covering numbers)
display.fill_rect(x, split_y - 12, card_w, 24, 0)
elif flip_state == 3:
# Settling line (flap finishing rotation)
display.fill_rect(x, split_y - 4, card_w, 8, 0)
display.fill_rect(x, split_y - 5, 4, 10, 0)
display.fill_rect(x + card_w - 4, split_y - 5, 4, 10, 0)
start_time = time.time()
last_h, last_m = -1, -1
print("Screensaver running. Touch the screen to exit.")
while time.time() - start_time < duration_sec:
# Check if screen is touched to exit
if touch and touch.is_touched():
print("Touch detected. Exiting screensaver.")
break
t = time.localtime()
h, m = t[3], t[4]
h_str = f"{h:02d}"
m_str = f"{m:02d}"
# Play flip animation if minute changes
if m != last_m:
if last_m != -1:
# Play 3-frame split-flap rotation
for frame in [1, 2, 3]:
display.clear(0)
if h != last_h:
draw_card(x1, y_offset, h_str, flip_state=frame)
else:
draw_card(x1, y_offset, h_str, flip_state=0)
draw_card(x2, y_offset, m_str, flip_state=frame)
display.show()
time.sleep_ms(150)
last_h, last_m = h, m
# Draw standard static state
display.clear(0)
draw_card(x1, y_offset, h_str, flip_state=0)
draw_card(x2, y_offset, m_str, flip_state=0)
# Draw blinking seconds colon between cards
if t[5] % 2 == 0:
cx = width // 2
cy = height // 2
display.fill_rect(cx - 3, cy - 15, 6, 6, 1)
display.fill_rect(cx - 3, cy + 9, 6, 6, 1)
display.show()
# Check for touch during the 1-second pause
for _ in range(10):
if touch and touch.is_touched():
break
time.sleep_ms(100)
# Clear display on exit
display.clear(0)
display.show()
# Run the screensaver
run_screensaver()
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -87,6 +87,7 @@ def connect_socket(esp32_ip, port, protocol):
if protocol == "tcp": if protocol == "tcp":
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((esp32_ip, port)) sock.connect((esp32_ip, port))
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
print(f"Connected to stream server at {esp32_ip}:{port}") print(f"Connected to stream server at {esp32_ip}:{port}")
return sock return sock
else: # udp else: # udp