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
@@ -10,106 +11,429 @@ import time
import board
import busio
import digitalio
import wifi
import socketpool
import adafruit_requests
# Import drivers
from audio_cp import BoardAudio
from gif_player import GIFPlayer
from rlcd_cp import RLCD
from ili9341_cp import ILI9341
from ft6336u_cp import FT6336U
from battery_cp import BatteryMonitor
from shtc3_cp import SHTC3
from rtc_cp import PCF85063
from rgb_led_cp import BoardLED
from button_cp import BoardButtons
from ble_cp import BLEUART
from video_stream_cp import VideoStreamServer
from mcp_server_cp import MCPServer
import wifi_config
# Global variables for hardware
board_type = None # 'HOSYOND' or 'WAVESHARE_RLCD'
display = None
touch = None
audio = None
sensor = None
rtc_chip = None
battery = None
led = None
buttons = None
ble_uart = None
ip_addr = "Offline"
# Keep pin choices in one place because CircuitPython board builds differ in
# how they expose ESP32-S3 pins. If board.IO## is missing, run `dir(board)` on
# the serial console and update these names.
PINS = {
"display_sck": board.IO11,
"display_mosi": board.IO12,
"display_cs": board.IO40,
"display_dc": board.IO5,
"display_rst": board.IO41,
"i2c_sda": board.IO13,
"i2c_scl": board.IO14,
"i2s_bclk": board.IO9,
"i2s_lrck": board.IO45,
"i2s_dout": board.IO8,
"audio_mclk": board.IO16,
"audio_amp": board.IO46,
}
def detect_and_init():
global board_type, display, touch, audio, sensor, rtc_chip, battery, led, buttons, ble_uart
print("Scanning for board type...")
def exists(path):
# Try Hosyond pins first (SDA=IO16, SCL=IO15)
try:
os.stat(path)
return True
except OSError:
return False
i2c = busio.I2C(scl=board.IO15, sda=board.IO16)
while not i2c.try_lock():
pass
devices = i2c.scan()
i2c.unlock()
i2c.deinit()
except Exception as e:
devices = []
if 0x38 in devices:
print("Detected Board: HOSYOND (Color Screen + Touch)")
board_type = 'HOSYOND'
def init_display():
spi = busio.SPI(clock=PINS["display_sck"], MOSI=PINS["display_mosi"])
# Match the working MicroPython code's 20 MHz SPI if the build supports it.
while not spi.try_lock():
pass
# Display: ILI9341 (320x240)
spi = busio.SPI(clock=board.IO12, MOSI=board.IO11)
display = ILI9341(
spi,
cs=digitalio.DigitalInOut(board.IO10),
dc=digitalio.DigitalInOut(board.IO46),
bl=board.IO45,
rst=None,
)
# Touch: FT6336U
i2c_bus = busio.I2C(scl=board.IO15, sda=board.IO16)
touch = FT6336U(
i2c_bus,
rst_pin=digitalio.DigitalInOut(board.IO18),
int_pin=digitalio.DigitalInOut(board.IO17),
width=320,
height=240,
swap_xy=True,
invert_x=False,
invert_y=True,
)
# Audio: ES8311
audio = BoardAudio(
i2c_bus,
bit_clock=board.IO5,
word_select=board.IO7,
data=board.IO8,
mclk=board.IO4,
amp=board.IO1,
amp_active_level=0, # Active Low
)
battery = BatteryMonitor(board.IO9)
led = BoardLED(board.IO48)
buttons = BoardButtons(board.IO0, key_pin=None)
ble_name = "ESP32-S3-Touch"
try:
ble_uart = BLEUART(name=ble_name)
except Exception as ble_err:
print(f"BLE init failed: {ble_err}")
return
# Try Waveshare RLCD pins (SDA=IO13, SCL=IO14)
try:
spi.configure(baudrate=20000000, phase=0, polarity=0)
finally:
spi.unlock()
display = RLCD(
i2c = busio.I2C(scl=board.IO14, sda=board.IO13)
while not i2c.try_lock():
pass
devices = i2c.scan()
i2c.unlock()
i2c.deinit()
except Exception as e:
devices = []
if 0x70 in devices or 0x51 in devices:
print("Detected Board: WAVESHARE_RLCD (Monochrome RLCD)")
board_type = 'WAVESHARE_RLCD'
# Display: RLCD (400x300)
spi = busio.SPI(clock=board.IO11, MOSI=board.IO12)
display = RLCD(
spi,
cs=digitalio.DigitalInOut(board.IO40),
dc=digitalio.DigitalInOut(board.IO5),
rst=digitalio.DigitalInOut(board.IO41),
)
# Audio: ES8311
i2c_bus = busio.I2C(scl=board.IO14, sda=board.IO13)
audio = BoardAudio(
i2c_bus,
bit_clock=board.IO9,
word_select=board.IO45,
data=board.IO8,
mclk=board.IO16,
amp=board.IO46,
amp_active_level=1, # Active High
)
battery = BatteryMonitor(board.IO9)
led = BoardLED(board.IO38)
buttons = BoardButtons(board.IO0, key_pin=board.IO47)
# Environment & Clock
try:
sensor = SHTC3(i2c_bus)
except Exception as e:
print("Failed to init SHTC3:", e)
try:
rtc_chip = PCF85063(i2c_bus)
except Exception as e:
print("Failed to init PCF85063:", e)
ble_name = "ESP32-S3-RLCD"
try:
ble_uart = BLEUART(name=ble_name)
except Exception as ble_err:
print(f"BLE init failed: {ble_err}")
return
# Fallback to Hosyond
print("Board scan failed. Falling back to HOSYOND defaults...")
board_type = 'HOSYOND'
spi = busio.SPI(clock=board.IO12, MOSI=board.IO11)
display = ILI9341(
spi,
cs=digitalio.DigitalInOut(PINS["display_cs"]),
dc=digitalio.DigitalInOut(PINS["display_dc"]),
rst=digitalio.DigitalInOut(PINS["display_rst"]),
cs=digitalio.DigitalInOut(board.IO10),
dc=digitalio.DigitalInOut(board.IO46),
bl=board.IO45,
rst=None,
)
return display
def init_audio():
i2c = busio.I2C(scl=PINS["i2c_scl"], sda=PINS["i2c_sda"])
return BoardAudio(
i2c,
bit_clock=PINS["i2s_bclk"],
word_select=PINS["i2s_lrck"],
data=PINS["i2s_dout"],
mclk=PINS["audio_mclk"],
amp=PINS["audio_amp"],
i2c_bus = busio.I2C(scl=board.IO15, sda=board.IO16)
try:
touch = FT6336U(
i2c_bus,
rst_pin=digitalio.DigitalInOut(board.IO18),
int_pin=digitalio.DigitalInOut(board.IO17),
width=320,
height=240,
swap_xy=True,
invert_x=False,
invert_y=True,
)
except Exception as te:
print("Fallback touch init failed:", te)
audio = BoardAudio(
i2c_bus,
bit_clock=board.IO5,
word_select=board.IO7,
data=board.IO8,
mclk=board.IO4,
amp=board.IO1,
amp_active_level=0,
)
def draw_boot_screen(display, message="CircuitPython RLCD"):
display.clear(0)
display.text("ESP32-S3-RLCD", 10, 10, 1)
display.line(10, 22, 390, 22, 1)
display.text_large("CIRCUITPY", 18, 45, scale=3, color=1)
display.text(message, 18, 86, 1)
display.text("MP3: /demo.mp3", 18, 116, 1)
display.text("GIF: /demo.gif", 18, 132, 1)
display.text("If visible, ST7305 init works", 18, 170, 1)
display.show()
battery = BatteryMonitor(board.IO9)
led = BoardLED(board.IO48)
buttons = BoardButtons(board.IO0)
ble_name = "ESP32-S3-Touch"
try:
ble_uart = BLEUART(name=ble_name)
except Exception as ble_err:
print(f"BLE init failed: {ble_err}")
def main():
display = init_display()
draw_boot_screen(display, "Display initialized")
time.sleep(1)
global ip_addr
detect_and_init()
# Try GIF first because it verifies display animation support.
if exists("/demo.gif"):
draw_boot_screen(display, "Playing GIF...")
GIFPlayer(display).play("/demo.gif", loops=1)
draw_boot_screen(display, "GIF done")
gc.collect()
# Draw initial dashboard
display.clear(0)
display.text("CircuitPython Active", 10, 10, 1)
display.text("Connecting Wi-Fi...", 10, 30, 1)
display.show()
# Try MP3 if present. If this fails silently, verify MCLK on IO16 and codec
# register sequence against a known-good WAV/tone first.
if exists("/demo.mp3"):
draw_boot_screen(display, "Playing MP3...")
# Sync system clock from RTC chip if available
if rtc_chip:
try:
audio = init_audio()
ok = audio.play_mp3("/demo.mp3", volume=65)
draw_boot_screen(display, "MP3 ok" if ok else "MP3 failed")
except Exception as exc:
draw_boot_screen(display, "MP3 error: " + str(exc)[:28])
rtc_chip.sync_to_system()
except Exception as e:
print("Failed to sync clock:", e)
# Connect to Wi-Fi
WIFI_SSID = getattr(wifi_config, "WIFI_SSID", "FamReynaMesh")
WIFI_PASS = getattr(wifi_config, "WIFI_PASS", "Gloria2020")
print(f"Connecting to Wi-Fi SSID '{WIFI_SSID}'...")
try:
wifi.radio.connect(WIFI_SSID, WIFI_PASS)
ip_addr = str(wifi.radio.ipv4_address)
print(f"Wi-Fi Connected! IP Address: {ip_addr}")
except Exception as wifi_err:
print(f"Wi-Fi Connection failed: {wifi_err}")
ip_addr = "Disconnected"
# Initialize socket pool and request session
pool = socketpool.SocketPool(wifi.radio)
session = adafruit_requests.Session(pool)
# Start Video Stream Server
vstream = VideoStreamServer(display, pool, tcp_port=8081, udp_port=8082, color_port=8083)
vstream.start()
# Start MCP Server
mcp = MCPServer(
display, led, battery, sensor, rtc_chip, ble_uart,
pool=pool, vstream=vstream, touch=touch, session=session, audio=audio
)
mcp.start(port=80)
# Sync time from NTP if connected
if ip_addr != "Disconnected":
try:
mcp._call_tool("sync_time", {})
except Exception as ntp_err:
print(f"NTP Time sync failed: {ntp_err}")
# Set default LED mode: Green Breathing
led_modes = [
("Red (Breathing)", lambda l: l.set_color(40, 0, 0), "breath"),
("Green (Breathing)", lambda l: l.set_color(0, 40, 0), "breath"),
("Blue (Breathing)", lambda l: l.set_color(0, 0, 40), "breath"),
("Cyan (Breathing)", lambda l: l.set_color(0, 30, 30), "breath"),
("Magenta (Breathing)", lambda l: l.set_color(30, 0, 30), "breath"),
("Rainbow Cycle", lambda l: l.set_color(30, 30, 30), "rainbow"),
("LED Off", lambda l: l.off(), "off")
]
local_led_mode_idx = 1 # Green breathing
led_modes[local_led_mode_idx][1](led)
mcp.active_led_mode = led_modes[local_led_mode_idx][2]
# Button callbacks
last_action_str = "Boot finished."
force_dashboard_redraw = True
def on_key_click():
nonlocal local_led_mode_idx, last_action_str, force_dashboard_redraw
local_led_mode_idx = (local_led_mode_idx + 1) % len(led_modes)
mode_name, color_fn, mode_type = led_modes[local_led_mode_idx]
color_fn(led)
mcp.active_led_mode = mode_type
print(f"Local Button: Cycle LED -> {mode_name}")
last_action_str = f"Local Button: {mode_name}"
mcp.override_active = False
force_dashboard_redraw = True
def on_boot_click():
nonlocal last_action_str, force_dashboard_redraw
print("Local Button: Force Dashboard refresh.")
last_action_str = "Dashboard Refreshed"
mcp.override_active = False
force_dashboard_redraw = True
if buttons:
buttons.boot.on_click(on_boot_click)
if buttons.key:
buttons.key.on_click(on_key_click)
last_dashboard_update = 0
dashboard_update_interval_s = 5
last_led_update = 0
last_wifi_check = 0
print("ESP32 CircuitPython main loop running...")
while True:
time.sleep(5)
now = time.monotonic()
# A. Check Wi-Fi connection and reconnect if lost (every 10s)
if now - last_wifi_check >= 10.0:
last_wifi_check = now
if not wifi.radio.connected:
print("Wi-Fi connection lost. Attempting reconnect...")
last_action_str = "Wi-Fi Disconnected"
if ip_addr != "Disconnected":
ip_addr = "Disconnected"
force_dashboard_redraw = True
try:
wifi.radio.connect(WIFI_SSID, WIFI_PASS)
except Exception as e:
print("Wi-Fi reconnect trigger failed:", e)
elif ip_addr == "Disconnected":
ip_addr = str(wifi.radio.ipv4_address)
print(f"Wi-Fi Connected! IP Address: {ip_addr}")
last_action_str = f"Wi-Fi Connected: {ip_addr}"
force_dashboard_redraw = True
try:
mcp._call_tool("sync_time", {})
except:
pass
main()
# B. Check for incoming MCP JSON-RPC TCP/UDP queries
mcp.update()
# C. Check for incoming Video Streaming TCP/UDP frames
vstream.update()
# D. Update button debouncers
if buttons:
buttons.update()
# E. Update BLE UART connection poll
if ble_uart:
ble_uart.update()
# F. Draw Local Dashboard periodically
# Do not draw if screen is overridden by MCP commands (e.g. draw_text, draw_image) or active video stream
if not mcp.override_active and not vstream.active:
if force_dashboard_redraw or (now - last_dashboard_update >= dashboard_update_interval_s):
force_dashboard_redraw = False
last_dashboard_update = now
# SHTC3 Sensor
t, h = sensor.read_sensor() if sensor else (None, None)
t_str = f"{t:.1f} C" if t is not None else "N/A"
h_str = f"{h:.1f} %" if h is not None else "N/A"
# Battery
bat_v = battery.read_voltage() if battery else None
bat_p = battery.read_percentage() if battery else 0
bat_str = f"{bat_v:.2f}V ({bat_p}%)" if bat_v is not None else "N/A"
# Time
dt = rtc_chip.get_datetime() if rtc_chip else None
if dt:
time_str = f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d} {dt[4]:02d}:{dt[5]:02d}:{dt[6]:02d}"
else:
time_str = "N/A (RTC Error)"
display.clear(0)
line_w = display.width - 10
if board_type == 'WAVESHARE_RLCD':
title_text = "Waveshare ESP32-S3-RLCD Server"
display.text(title_text, 10, 10, 1)
display.line(10, 20, line_w, 20, 1)
display.text_large("ENVIRONMENT", 15, 30, scale=2, color=1)
display.text(f"Temp : {t_str}", 25, 55, 1)
display.text(f"Humid : {h_str}", 25, 70, 1)
display.line(10, 95, line_w, 95, 1)
display.text_large("MCP NET CONNECTION", 15, 105, scale=2, color=1)
display.text(f"IP Address : {ip_addr}", 25, 130, 1)
display.text(f"Port / Path : 80 /api/mcp", 25, 145, 1)
display.text(f"BLE Name : ESP32-S3-RLCD", 25, 160, 1)
display.line(10, 185, line_w, 185, 1)
display.text_large("SYSTEM STATUS", 15, 195, scale=2, color=1)
display.text(f"Battery : {bat_str}", 25, 220, 1)
display.text(f"Time : {time_str}", 25, 235, 1)
display.line(10, 255, line_w, 255, 1)
display.text(f"Status: {last_action_str}", 15, 265, 1)
else:
title_text = "Hosyond ESP32-S3 Server"
display.text(title_text, 10, 8, 1)
display.line(10, 18, line_w, 18, 1)
# Left Column
display.text("SYSTEM & ENV", 10, 28, 1)
display.line(10, 38, 150, 38, 1)
display.text(f"Temp : {t_str}", 10, 46, 1)
display.text(f"Hum : {h_str}", 10, 58, 1)
display.text(f"Bat : {bat_str}", 10, 70, 1)
display.text(f"Time : {time_str[11:19]}", 10, 82, 1)
display.text(f"Date : {time_str[0:10]}", 10, 94, 1)
# Right Column
display.text("MCP NETWORK", 170, 28, 1)
display.line(170, 38, line_w, 38, 1)
display.text(f"IP : {ip_addr}", 170, 46, 1)
display.text("Port: 80/api/mcp", 170, 58, 1)
display.text("BLE : Touch", 170, 70, 1)
# Bottom Status
display.line(10, 115, line_w, 115, 1)
display.text(f"Status: {last_action_str}", 10, 125, 1)
display.show()
gc.collect()
# G. Update NeoPixel LED animations smoothly (every 50ms)
if now - last_led_update >= 0.05:
last_led_update = now
mode = mcp.active_led_mode
if mode == "breath":
led.update_breathing(1.5)
elif mode == "rainbow":
led.update_rainbow(0.4)
elif mode == "off":
led.off()
# H. Sleep to prevent CPU hogging, poll faster if streaming is active
if vstream.active:
time.sleep(0.002)
else:
time.sleep(0.020)
if __name__ == "__main__":
main()
+4 -4
View File
@@ -1,7 +1,7 @@
#!/usr/bin/env bash
set -euo pipefail
PORT="${PORT:-/dev/ttyACM0}"
PORT="${PORT:-/dev/cu.usbmodem101}"
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
FIRMWARE="$ROOT/firmware/adafruit-circuitpython-espressif_esp32s3_devkitc_1_n8r8-en_US-10.2.1.bin"
CIRCUITPY_MOUNT="${CIRCUITPY_MOUNT:-}"
@@ -26,13 +26,13 @@ EOF
fi
echo "==> Probing ESP32-S3 on $PORT"
uvx esptool --chip esp32s3 --port "$PORT" flash-id
python3 -m esptool --chip esp32s3 --port "$PORT" flash_id
echo "==> Erasing flash"
uvx esptool --chip esp32s3 --port "$PORT" erase-flash
python3 -m esptool --chip esp32s3 --port "$PORT" erase_flash
echo "==> Flashing CircuitPython N8R8 firmware"
uvx esptool --chip esp32s3 --port "$PORT" --baud 460800 write-flash -z 0x0 "$FIRMWARE"
python3 -m esptool --chip esp32s3 --port "$PORT" --baud 460800 write_flash -z 0x0 "$FIRMWARE"
echo "==> Waiting for CIRCUITPY mount"
for _ in $(seq 1 60); do
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+90 -10
View File
@@ -5,13 +5,17 @@ playback using CircuitPython's audiomp3 + audiobusio stack.
"""
import time
import array
import math
import audiobusio
import audiomp3
import audiocore
import digitalio
import pwmio
class ES8311:
ADDR = 0x18
@@ -63,20 +67,29 @@ class ES8311:
def set_volume(self, volume):
volume = max(0, min(100, int(volume)))
self._write(0x32, int(volume * 255 / 100))
if volume <= 0:
reg_val = 0
elif volume <= 50:
# 1..50 maps to 0..191 (0xBF = 0dB)
reg_val = int((volume / 50.0) * 191)
else:
# 51..100 maps to 192..255 (0xFF = +32dB)
reg_val = 191 + int(((volume - 50) / 50.0) * (255 - 191))
self._write(0x32, reg_val)
class BoardAudio:
def __init__(self, i2c, *, bit_clock, word_select, data, mclk, amp):
def __init__(self, i2c, *, bit_clock, word_select, data, mclk, amp, amp_active_level=1):
self.i2c = i2c
self.bit_clock_pin = bit_clock
self.word_select_pin = word_select
self.data_pin = data
self.mclk_pin = mclk
self.amp_pin = amp
self.amp_active_level = amp_active_level
self._mclk = None
self._amp = digitalio.DigitalInOut(amp)
self._amp.switch_to_output(value=False)
self._amp.switch_to_output(value=not amp_active_level)
self.codec = ES8311(i2c)
def start_mclk(self):
@@ -88,7 +101,7 @@ class BoardAudio:
frequency=12288000,
duty_cycle=32768,
variable_frequency=False,
)
)
def stop_mclk(self):
if self._mclk is not None:
@@ -106,25 +119,92 @@ class BoardAudio:
try:
f = open(filename, "rb")
decoder = audiomp3.MP3Decoder(f)
# Use decoder-derived rate if available, else fall back.
sample_rate = getattr(decoder, "sample_rate", 44100)
audio = audiobusio.I2SOut(
bit_clock=self.bit_clock_pin,
word_select=self.word_select_pin,
data=self.data_pin,
)
self._amp.value = True
self._amp.value = self.amp_active_level
audio.play(decoder)
while audio.playing:
time.sleep(0.05)
return True
finally:
self._amp.value = False
self._amp.value = not self.amp_active_level
if audio is not None:
audio.deinit()
if decoder is not None:
decoder.deinit()
if f is not None:
f.close()
# Leave MCLK running for repeated playback; call stop_mclk() before
# deep sleep if power matters.
def play_wav(self, filename, volume=60):
"""Play a WAV file from CIRCUITPY storage over the onboard speaker."""
self.start_mclk()
self.codec.init()
self.codec.set_volume(volume)
wav = None
audio = None
f = None
try:
f = open(filename, "rb")
wav = audiocore.WaveFile(f)
audio = audiobusio.I2SOut(
bit_clock=self.bit_clock_pin,
word_select=self.word_select_pin,
data=self.data_pin,
)
self._amp.value = self.amp_active_level
audio.play(wav)
while audio.playing:
time.sleep(0.05)
return True
except Exception as e:
print(f"Error playing WAV: {e}")
return False
finally:
self._amp.value = not self.amp_active_level
if audio is not None:
audio.deinit()
if wav is not None:
wav.deinit()
if f is not None:
f.close()
def play_tone(self, frequency=440, duration_ms=1000, volume=50):
"""Generate and play a pure sine wave tone on the speaker."""
self.start_mclk()
self.codec.init()
self.codec.set_volume(volume)
sample_rate = 16000
length = int(sample_rate / frequency)
if length < 4:
length = 4
sine_wave = array.array("h", [0] * length)
for i in range(length):
sine_wave[i] = int(math.sin(2 * math.pi * i / length) * 32767)
sample = audiocore.RawSample(sine_wave, sample_rate=sample_rate)
audio = None
try:
audio = audiobusio.I2SOut(
bit_clock=self.bit_clock_pin,
word_select=self.word_select_pin,
data=self.data_pin,
)
self._amp.value = self.amp_active_level
audio.play(sample, loop=True)
time.sleep(duration_ms / 1000.0)
audio.stop()
return True
except Exception as e:
print(f"Error playing tone: {e}")
return False
finally:
self._amp.value = not self.amp_active_level
if audio is not None:
audio.deinit()
sample.deinit()
+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):
self.display = display
def play(self, filename, *, loops=1, x=40, y=0, threshold=1, clear_between_frames=False):
def play(self, filename, *, loops=1, x=40, y=0, threshold=1, clear_between_frames=False, max_frames=-1):
"""Decode a GIF from disk and push frames to the reflective LCD.
CircuitPython gifio currently supports GIFs up to 320 pixels wide, so
@@ -23,14 +23,21 @@ class GIFPlayer:
try:
count = 0
while loops < 0 or count < loops:
frame_count = 0
while True:
if max_frames > 0 and frame_count >= max_frames:
break
delay = gif.next_frame()
if delay is None:
break
if clear_between_frames:
self.display.clear(0)
self.display.draw_bitmap_threshold(gif.bitmap, x, y, threshold=threshold)
self.display.show()
if hasattr(self.display, "draw_bitmap_color"):
self.display.draw_bitmap_color(gif.bitmap, gif.palette, x, y)
else:
self.display.draw_bitmap_threshold(gif.bitmap, x, y, threshold=threshold)
self.display.show()
frame_count += 1
# gifio delay is seconds in modern CircuitPython builds. If
# an older build plays 100x too slowly, divide by 100 here.
time.sleep(max(0.0, delay))
+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)
def send_frame(sock: socket.socket, image: Image.Image) -> None:
def send_frame_tcp(sock: socket.socket, image: Image.Image) -> None:
payload = rgb565be(image)
header = struct.pack(">4sBBHHIH", b"IMCR", 1, 1, image.width, image.height, len(payload), 0)
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:
parser = argparse.ArgumentParser()
parser.add_argument("--ip", required=True, help="iPhone Wi-Fi IP")
parser.add_argument("--port", type=int, default=8083)
parser.add_argument("--width", type=int, default=400)
parser.add_argument("--height", type=int, default=300)
parser.add_argument("--protocol", choices=["tcp", "udp"], default="tcp")
parser.add_argument("--port", type=int)
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()
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
while True:
hue = (frame * 4) % 256
@@ -45,11 +77,18 @@ def main() -> None:
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,
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))
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
time.sleep(1 / 20)
time.sleep(1 / 15)
finally:
sock.close()
if __name__ == "__main__":
+59
View File
@@ -439,3 +439,62 @@ class ILI9341:
self.spi.write(self.row_buffer)
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.cs(0); self.dc(1)
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.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",
"description": "Get real-time device-side performance and frame-rate statistics for the TCP/UDP video stream.",
"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()
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":
self.override_active = True
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":
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((esp32_ip, port))
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
print(f"Connected to stream server at {esp32_ip}:{port}")
return sock
else: # udp