diff --git a/circuitpython/code.py b/circuitpython/code.py index 697788a..6c4273a 100644 --- a/circuitpython/code.py +++ b/circuitpython/code.py @@ -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() diff --git a/circuitpython/flash_and_deploy.sh b/circuitpython/flash_and_deploy.sh index ab8633f..1e599b7 100755 --- a/circuitpython/flash_and_deploy.sh +++ b/circuitpython/flash_and_deploy.sh @@ -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 diff --git a/circuitpython/lib/adafruit_ble/__init__.mpy b/circuitpython/lib/adafruit_ble/__init__.mpy new file mode 100644 index 0000000..b09308f Binary files /dev/null and b/circuitpython/lib/adafruit_ble/__init__.mpy differ diff --git a/circuitpython/lib/adafruit_ble/advertising/__init__.mpy b/circuitpython/lib/adafruit_ble/advertising/__init__.mpy new file mode 100644 index 0000000..979425e Binary files /dev/null and b/circuitpython/lib/adafruit_ble/advertising/__init__.mpy differ diff --git a/circuitpython/lib/adafruit_ble/advertising/adafruit.mpy b/circuitpython/lib/adafruit_ble/advertising/adafruit.mpy new file mode 100644 index 0000000..72dffe7 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/advertising/adafruit.mpy differ diff --git a/circuitpython/lib/adafruit_ble/advertising/standard.mpy b/circuitpython/lib/adafruit_ble/advertising/standard.mpy new file mode 100644 index 0000000..4602bb6 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/advertising/standard.mpy differ diff --git a/circuitpython/lib/adafruit_ble/attributes/__init__.mpy b/circuitpython/lib/adafruit_ble/attributes/__init__.mpy new file mode 100644 index 0000000..fd41338 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/attributes/__init__.mpy differ diff --git a/circuitpython/lib/adafruit_ble/characteristics/__init__.mpy b/circuitpython/lib/adafruit_ble/characteristics/__init__.mpy new file mode 100644 index 0000000..08939b5 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/characteristics/__init__.mpy differ diff --git a/circuitpython/lib/adafruit_ble/characteristics/float.mpy b/circuitpython/lib/adafruit_ble/characteristics/float.mpy new file mode 100644 index 0000000..237aee9 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/characteristics/float.mpy differ diff --git a/circuitpython/lib/adafruit_ble/characteristics/int.mpy b/circuitpython/lib/adafruit_ble/characteristics/int.mpy new file mode 100644 index 0000000..03baea1 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/characteristics/int.mpy differ diff --git a/circuitpython/lib/adafruit_ble/characteristics/json.mpy b/circuitpython/lib/adafruit_ble/characteristics/json.mpy new file mode 100644 index 0000000..f2b9180 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/characteristics/json.mpy differ diff --git a/circuitpython/lib/adafruit_ble/characteristics/stream.mpy b/circuitpython/lib/adafruit_ble/characteristics/stream.mpy new file mode 100644 index 0000000..656e9ea Binary files /dev/null and b/circuitpython/lib/adafruit_ble/characteristics/stream.mpy differ diff --git a/circuitpython/lib/adafruit_ble/characteristics/string.mpy b/circuitpython/lib/adafruit_ble/characteristics/string.mpy new file mode 100644 index 0000000..a5f3b53 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/characteristics/string.mpy differ diff --git a/circuitpython/lib/adafruit_ble/services/__init__.mpy b/circuitpython/lib/adafruit_ble/services/__init__.mpy new file mode 100644 index 0000000..eeb55bd Binary files /dev/null and b/circuitpython/lib/adafruit_ble/services/__init__.mpy differ diff --git a/circuitpython/lib/adafruit_ble/services/circuitpython.mpy b/circuitpython/lib/adafruit_ble/services/circuitpython.mpy new file mode 100644 index 0000000..1901da0 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/services/circuitpython.mpy differ diff --git a/circuitpython/lib/adafruit_ble/services/microbit.py b/circuitpython/lib/adafruit_ble/services/microbit.py new file mode 100644 index 0000000..e69de29 diff --git a/circuitpython/lib/adafruit_ble/services/nordic.mpy b/circuitpython/lib/adafruit_ble/services/nordic.mpy new file mode 100644 index 0000000..5056f3a Binary files /dev/null and b/circuitpython/lib/adafruit_ble/services/nordic.mpy differ diff --git a/circuitpython/lib/adafruit_ble/services/sphero.mpy b/circuitpython/lib/adafruit_ble/services/sphero.mpy new file mode 100644 index 0000000..b2bbb19 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/services/sphero.mpy differ diff --git a/circuitpython/lib/adafruit_ble/services/standard/__init__.mpy b/circuitpython/lib/adafruit_ble/services/standard/__init__.mpy new file mode 100644 index 0000000..b264d2e Binary files /dev/null and b/circuitpython/lib/adafruit_ble/services/standard/__init__.mpy differ diff --git a/circuitpython/lib/adafruit_ble/services/standard/device_info.mpy b/circuitpython/lib/adafruit_ble/services/standard/device_info.mpy new file mode 100644 index 0000000..8968c3e Binary files /dev/null and b/circuitpython/lib/adafruit_ble/services/standard/device_info.mpy differ diff --git a/circuitpython/lib/adafruit_ble/services/standard/hid.mpy b/circuitpython/lib/adafruit_ble/services/standard/hid.mpy new file mode 100644 index 0000000..85a633c Binary files /dev/null and b/circuitpython/lib/adafruit_ble/services/standard/hid.mpy differ diff --git a/circuitpython/lib/adafruit_ble/uuid/__init__.mpy b/circuitpython/lib/adafruit_ble/uuid/__init__.mpy new file mode 100644 index 0000000..4484857 Binary files /dev/null and b/circuitpython/lib/adafruit_ble/uuid/__init__.mpy differ diff --git a/circuitpython/lib/audio_cp.py b/circuitpython/lib/audio_cp.py index a325add..240c06e 100644 --- a/circuitpython/lib/audio_cp.py +++ b/circuitpython/lib/audio_cp.py @@ -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() + diff --git a/circuitpython/lib/battery_cp.py b/circuitpython/lib/battery_cp.py new file mode 100644 index 0000000..f2f228a --- /dev/null +++ b/circuitpython/lib/battery_cp.py @@ -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}%)" diff --git a/circuitpython/lib/ble_cp.py b/circuitpython/lib/ble_cp.py new file mode 100644 index 0000000..b009c10 --- /dev/null +++ b/circuitpython/lib/ble_cp.py @@ -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.") diff --git a/circuitpython/lib/button_cp.py b/circuitpython/lib/button_cp.py new file mode 100644 index 0000000..ea4182e --- /dev/null +++ b/circuitpython/lib/button_cp.py @@ -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() diff --git a/circuitpython/lib/download_cp.py b/circuitpython/lib/download_cp.py new file mode 100644 index 0000000..a61e6b3 --- /dev/null +++ b/circuitpython/lib/download_cp.py @@ -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() diff --git a/circuitpython/lib/ft6336u_cp.py b/circuitpython/lib/ft6336u_cp.py new file mode 100644 index 0000000..e812b20 --- /dev/null +++ b/circuitpython/lib/ft6336u_cp.py @@ -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) diff --git a/circuitpython/lib/gif_player.py b/circuitpython/lib/gif_player.py index 0cb93f8..8be34cf 100644 --- a/circuitpython/lib/gif_player.py +++ b/circuitpython/lib/gif_player.py @@ -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)) diff --git a/circuitpython/lib/ili9341_cp.py b/circuitpython/lib/ili9341_cp.py new file mode 100644 index 0000000..2d6c04d --- /dev/null +++ b/circuitpython/lib/ili9341_cp.py @@ -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('= 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 + diff --git a/circuitpython/lib/mcp_server_cp.py b/circuitpython/lib/mcp_server_cp.py new file mode 100644 index 0000000..5fce052 --- /dev/null +++ b/circuitpython/lib/mcp_server_cp.py @@ -0,0 +1,1092 @@ +"""CircuitPython MCP Server implementation. + +Provides a lightweight JSON-RPC HTTP server and UDP discovery responder, +allowing a host PC to invoke hardware controls, screen drawing, and file/audio utility tools. +""" + +import json +import time +import binascii +import sys +import io +import builtins +import os + +try: + import adafruit_framebuf +except ImportError: + adafruit_framebuf = None + +class MCPServer: + def __init__(self, display, led, battery, sensor, rtc, ble, pool, vstream=None, touch=None, session=None, audio=None): + self.display = display + self.led = led + self.battery = battery + self.sensor = sensor + self.rtc = rtc + self.ble = ble + self.pool = pool + self.vstream = vstream + self.touch = touch + self.session = session + self.audio = audio + + self.sock = None + self.udp_sock = None + self.udp_buffer = bytearray(128) + self.active_led_mode = "off" # static, breath, rainbow, off + self.override_active = False + self.port = 80 + + + def start(self, port=80): + """Starts the TCP server non-blockingly.""" + self.port = port + try: + self.sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM) + try: + self.sock.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1) + except: + pass + self.sock.bind(("", port)) + self.sock.listen(3) + self.sock.setblocking(False) + print(f"MCP server listening on port {port}...") + except Exception as e: + print(f"Failed to start TCP MCP server on port {port}: {e}") + self.sock = None + + # Setup UDP socket for auto-discovery on port 5000 + 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(("", 5000)) + self.udp_sock.setblocking(False) + print("UDP Discovery responder listening on port 5000...") + except Exception as ue: + print(f"Failed to bind UDP Discovery socket: {ue}") + self.udp_sock = None + + def restart(self): + """Re-initializes the listening sockets after a fatal error.""" + print("Restarting MCP Server sockets...") + if self.sock: + try: + self.sock.close() + except: + pass + self.sock = None + if self.udp_sock: + try: + self.udp_sock.close() + except: + pass + self.udp_sock = None + + time.sleep(0.1) + self.start(self.port) + + def _send_all(self, client, data): + total_sent = 0 + while total_sent < len(data): + try: + sent = client.send(data[total_sent:]) + if sent is None or sent == 0: + time.sleep(0.01) + continue + total_sent += sent + except OSError as e: + # EWOULDBLOCK or EAGAIN + time.sleep(0.01) + continue + + def _recv(self, client, size): + buf = bytearray(size) + try: + n = client.recv_into(buf) + return buf[:n] + except OSError: + return b"" + + + def update(self): + """Check for incoming HTTP requests and handle them non-blockingly.""" + # 1. Handle UDP Discovery queries + if self.udp_sock is not None: + try: + n, addr = self.udp_sock.recvfrom_into(self.udp_buffer) + if n > 0: + data = self.udp_buffer[:n] + if data == b"DISCOVER_SCREEN": + # Respond back to discovery query + self.udp_sock.sendto(b"SCREEN_IP_80", addr) + except OSError: + pass + + # 2. Check for incoming HTTP TCP connections + if self.sock is None: + return + + client = None + try: + client, addr = self.sock.accept() + 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: + # No incoming connection + return + # Fatal socket error + print(f"Fatal socket error in MCP Server accept(): {e}. Re-initializing...") + self.restart() + return + + if client is not None: + try: + client.settimeout(2.0) + req_bytes = self._recv(client, 2048) + if not req_bytes: + client.close() + return + + header_end = req_bytes.find(b'\r\n\r\n') + if header_end == -1: + header_end = len(req_bytes) + body_start_idx = len(req_bytes) + else: + body_start_idx = header_end + 4 + + header_text = req_bytes[:header_end].decode('utf-8', 'ignore') + lines = header_text.split('\r\n') + if len(lines) == 0 or not lines[0]: + client.close() + return + + first_line = lines[0] + parts = first_line.split(' ') + if len(parts) < 3: + client.close() + return + + method, path = parts[0], parts[1] + + if method == 'POST' and path == '/api/mcp': + # Read content length + content_length = 0 + for line in lines: + if line.lower().startswith('content-length:'): + content_length = int(line.split(':')[1].strip()) + break + + body_bytes = bytearray(req_bytes[body_start_idx:]) + while len(body_bytes) < content_length: + chunk = self._recv(client, min(1024, content_length - len(body_bytes))) + if not chunk: + break + body_bytes.extend(chunk) + + body_text = body_bytes.decode('utf-8', 'ignore') + rpc_req = json.loads(body_text) + rpc_resp = self._handle_rpc(rpc_req) + + resp_body = json.dumps(rpc_resp) + resp = "HTTP/1.1 200 OK\r\n" + resp += "Content-Type: application/json\r\n" + resp += f"Content-Length: {len(resp_body)}\r\n" + resp += "Connection: close\r\n\r\n" + resp += resp_body + + self._send_all(client, resp.encode('utf-8')) + + elif method == 'POST' and path.startswith('/api/screen/raw'): + # Read content length + content_length = 0 + for line in lines: + if line.lower().startswith('content-length:'): + content_length = int(line.split(':')[1].strip()) + break + + body_bytes = bytearray(req_bytes[body_start_idx:]) + while len(body_bytes) < content_length: + chunk = self._recv(client, min(1024, content_length - len(body_bytes))) + if not chunk: + break + body_bytes.extend(chunk) + + # Parse query parameters from path (e.g. /api/screen/raw?x=0&y=0&w=320&h=240&format=mono) + width = getattr(self.display, 'width', 320) + height = getattr(self.display, 'height', 240) + x, y, w, h = 0, 0, width, height + fmt = None + + if '?' in path: + q_str = path.split('?', 1)[1] + for param in q_str.split('&'): + if '=' in param: + k, v = param.split('=', 1) + if k == 'x': x = int(v) + elif k == 'y': y = int(v) + elif k == 'w': w = int(v) + elif k == 'h': h = int(v) + elif k == 'format': fmt = str(v) + + # Auto-detect format if not explicitly specified + expected_mono_len = (w * h) // 8 + expected_color_len = w * h * 2 + is_mono = (fmt in ('mono', '1bit')) + if fmt is None and len(body_bytes) == expected_mono_len and expected_mono_len != expected_color_len: + is_mono = True + + self.override_active = True + if is_mono: + if hasattr(self.display, 'canvas_buffer'): + if x == 0 and y == 0 and w == width and h == height: + self.display.canvas_buffer[:] = body_bytes + else: + if x == 0 and (w % 8) == 0: + dest_stride_bytes = width // 8 + src_stride_bytes = w // 8 + for cy in range(h): + dy = y + cy + if 0 <= dy < height: + dest_offset = dy * dest_stride_bytes + src_offset = cy * src_stride_bytes + self.display.canvas_buffer[dest_offset : dest_offset + src_stride_bytes] = body_bytes[src_offset : src_offset + src_stride_bytes] + else: + import adafruit_framebuf + src_fb = adafruit_framebuf.FrameBuffer(body_bytes, w, h, adafruit_framebuf.MHMSB) + for cy in range(h): + for cx in range(w): + px = src_fb.pixel(cx, cy) + self.display.canvas.pixel(x + cx, y + cy, px) + self.display.show() + else: + if hasattr(self.display, 'draw_rgb565'): + self.display.draw_rgb565(x, y, w, h, body_bytes, sync_canvas=False) + + resp_body = "OK" + resp = "HTTP/1.1 200 OK\r\n" + resp += "Content-Type: text/plain\r\n" + resp += f"Content-Length: {len(resp_body)}\r\n" + resp += "Connection: close\r\n\r\n" + resp += resp_body + + self._send_all(client, resp.encode('utf-8')) + else: + # Return 404 + resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n" + self._send_all(client, resp.encode('utf-8')) + except Exception as e: + print("Error handling client:", e) + finally: + client.close() + + def _handle_rpc(self, req): + rpc_id = req.get('id') + method = req.get('method') + params = req.get('params', {}) + + # Get active display dimensions dynamically to report correct screen resolution + width = getattr(self.display, 'width', 320) + height = getattr(self.display, 'height', 240) + + if method == 'tools/list': + return { + "jsonrpc": "2.0", + "result": { + "tools": [ + { + "name": "clear_screen", + "description": f"Clear the {width}x{height} screen to white (0) or black (1).", + "inputSchema": { + "type": "object", + "properties": { + "color": {"type": "integer", "enum": [0, 1], "description": "0 = White, 1 = Black"} + }, + "required": ["color"] + } + }, + { + "name": "draw_text", + "description": f"Draw text on the screen at specified (x,y) coordinates. Screen resolution is {width}x{height}.", + "inputSchema": { + "type": "object", + "properties": { + "text": {"type": "string", "description": "The message to display"}, + "x": {"type": "integer", "description": f"X coordinate (0-{width-1})"}, + "y": {"type": "integer", "description": f"Y coordinate (0-{height-1})"}, + "size": {"type": "integer", "enum": [1, 2], "description": "Text scale (1=normal, 2=large)"} + }, + "required": ["text", "x", "y"] + } + }, + { + "name": "set_led", + "description": "Control the onboard WS2812 NeoPixel RGB LED.", + "inputSchema": { + "type": "object", + "properties": { + "r": {"type": "integer", "minimum": 0, "maximum": 255, "description": "Red channel (0-255)"}, + "g": {"type": "integer", "minimum": 0, "maximum": 255, "description": "Green channel (0-255)"}, + "b": {"type": "integer", "minimum": 0, "maximum": 255, "description": "Blue channel (0-255)"}, + "mode": {"type": "string", "enum": ["static", "breath", "rainbow", "off"], "description": "LED mode"} + }, + "required": ["r", "g", "b", "mode"] + } + }, + { + "name": "get_battery", + "description": "Read the current battery voltage and estimated capacity percentage.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "get_touch", + "description": "Read the current touch state and coordinates from the capacitive touchscreen.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "get_sensors", + "description": "Read onboard SHTC3 temperature and relative humidity.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "scan_ble", + "description": "Scan for nearby Bluetooth Low Energy devices / tracking tags.", + "inputSchema": { + "type": "object", + "properties": { + "duration_ms": {"type": "integer", "description": "Scan duration in milliseconds (default 3000)"} + } + } + }, + { + "name": "get_screenshot", + "description": "Capture the current screen rendering as a PNG image (PBM format in base64).", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "draw_image", + "description": f"Draw an image (PNG, JPEG, GIF, BMP, etc.) on the screen. The image will be converted to 1-bit monochrome and fit to display boundaries (max {width}x{height}).", + "inputSchema": { + "type": "object", + "properties": { + "image_base64": {"type": "string", "description": "Base64 encoded string of the source image file"}, + "x": {"type": "integer", "description": "X coordinate to place the image (default 0)", "default": 0}, + "y": {"type": "integer", "description": "Y coordinate to place the image (default 0)", "default": 0}, + "dither": {"type": "boolean", "description": "Whether to use Floyd-Steinberg dithering (default true)", "default": True} + }, + "required": ["image_base64"] + } + }, + { + "name": "draw_color_bmp", + "description": f"Draw a color BMP image on the {width}x{height} color screen at specified (x,y) coordinates.", + "inputSchema": { + "type": "object", + "properties": { + "bmp_base64": {"type": "string", "description": "Base64 encoded BMP image file (uncompressed 24-bit or 32-bit format)"}, + "x": {"type": "integer", "description": "X coordinate to place the image (default 0)", "default": 0}, + "y": {"type": "integer", "description": "Y coordinate to place the image (default 0)", "default": 0} + }, + "required": ["bmp_base64"] + } + }, + { + "name": "get_capabilities", + "description": "Get screen capabilities (resolution, color support, and supported formats).", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "sync_time", + "description": "Synchronize the hardware and system clock with an internet NTP server.", + "inputSchema": {"type": "object", "properties": {}} + }, + { + "name": "draw_raw_rgb565", + "description": f"Draw raw RGB565 pixel data on the {width}x{height} screen at specified (x,y) coordinates with width (w) and height (h).", + "inputSchema": { + "type": "object", + "properties": { + "rgb565_base64": {"type": "string", "description": "Base64 encoded raw RGB565 pixel data (Big-Endian, 2 bytes per pixel)"}, + "x": {"type": "integer", "description": "X coordinate to place the image"}, + "y": {"type": "integer", "description": "Y coordinate to place the image"}, + "w": {"type": "integer", "description": "Width of the raw pixel block"}, + "h": {"type": "integer", "description": "Height of the raw pixel block"} + }, + "required": ["rgb565_base64", "x", "y", "w", "h"] + } + }, + { + "name": "write_file", + "description": "Write a file (e.g., python script or config) to the board's flash storage.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "The destination file path (e.g. 'my_script.py')"}, + "content": {"type": "string", "description": "The text content of the file"} + }, + "required": ["path", "content"] + } + }, + { + "name": "read_file", + "description": "Read a text file from the board's flash storage.", + "inputSchema": { + "type": "object", + "properties": { + "path": {"type": "string", "description": "The file path to read (e.g. 'boot.py')"} + }, + "required": ["path"] + } + }, + { + "name": "execute_python", + "description": "Execute arbitrary Python code dynamically on the board. Standard output (prints) and errors will be captured and returned.", + "inputSchema": { + "type": "object", + "properties": { + "code": {"type": "string", "description": "The Python code to execute"} + }, + "required": ["code"] + } + }, + { + "name": "play_tone", + "description": "Play a pure sine wave tone/beep on the board speaker.", + "inputSchema": { + "type": "object", + "properties": { + "frequency": {"type": "integer", "description": "Frequency of the tone in Hz (e.g. 440 for A4, default 440)", "default": 440}, + "duration_ms": {"type": "integer", "description": "Duration of the tone in milliseconds (default 1000)", "default": 1000}, + "volume": {"type": "integer", "description": "Volume of the tone from 0 to 100 (default 50)", "default": 50} + } + } + }, + { + "name": "play_audio", + "description": "Play a standard WAV/MP3 audio file on the speaker.", + "inputSchema": { + "type": "object", + "properties": { + "filename": {"type": "string", "description": "WAV/MP3 file path to play (e.g. 'response.wav')"}, + "volume": {"type": "integer", "description": "Volume from 0 to 100 (default 50)", "default": 50} + }, + "required": ["filename"] + } + }, + { + "name": "record_voice", + "description": "Record voice command from the microphone to a raw stereo PCM file.", + "inputSchema": { + "type": "object", + "properties": { + "duration_sec": {"type": "integer", "description": "Duration in seconds (default 4)", "default": 4}, + "filename": {"type": "string", "description": "Destination file path (e.g. 'recording.pcm')", "default": "recording.pcm"} + } + } + }, + { + "name": "play_audio_base64", + "description": "Decode a base64-encoded WAV file and play it directly on the speaker.", + "inputSchema": { + "type": "object", + "properties": { + "wav_base64": {"type": "string", "description": "Base64 encoded string of the WAV audio file"}, + "volume": {"type": "integer", "description": "Volume from 0 to 100 (default 50)", "default": 50} + }, + "required": ["wav_base64"] + } + }, + { + "name": "play_gif", + "description": "Play an animated GIF file on the screen.", + "inputSchema": { + "type": "object", + "properties": { + "filename": {"type": "string", "description": "GIF file path to play (e.g. 'test.gif')"}, + "loops": {"type": "integer", "description": "Number of loops, -1 for infinite (default 1)", "default": 1}, + "x": {"type": "integer", "description": "X coordinate to place the GIF (default 0)", "default": 0}, + "y": {"type": "integer", "description": "Y coordinate to place the GIF (default 0)", "default": 0}, + "max_frames": {"type": "integer", "description": "Maximum frames to play per loop (default -1)", "default": -1}, + "clear_between_frames": {"type": "boolean", "description": "Clear the screen between frames (default false)", "default": False} + }, + "required": ["filename"] + } + }, + { + "name": "download_file", + "description": "Download a file from a URL over Wi-Fi directly to the board storage or microSD card.", + "inputSchema": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "The URL of the file to download"}, + "filename": {"type": "string", "description": "The destination filename (e.g. 'podcast1.wav')"}, + "use_sd": {"type": "boolean", "description": "Save to microSD card if true, or local flash if false (default true)", "default": True} + }, + "required": ["url", "filename"] + } + }, + { + "name": "get_video_streaming_instructions", + "description": "Get detailed instructions and sample Python code to stream video directly into the screen using TCP or UDP.", + "inputSchema": { + "type": "object", + "properties": { + "protocol": {"type": "string", "enum": ["tcp", "udp", "both"], "description": "The streaming protocol to query (default 'both')"} + } + } + }, + { + "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"] + } + } + ] + }, + "id": rpc_id + } + + elif method == 'tools/call': + tool_name = params.get('name') + args = params.get('arguments', {}) + + try: + content = self._call_tool(tool_name, args) + return { + "jsonrpc": "2.0", + "result": { + "content": [ + { + "type": "text", + "text": content + } + ] + }, + "id": rpc_id + } + except Exception as e: + return { + "jsonrpc": "2.0", + "error": { + "code": -32000, + "message": str(e) + }, + "id": rpc_id + } + + return { + "jsonrpc": "2.0", + "error": { + "code": -32601, + "message": f"Method {method} not found" + }, + "id": rpc_id + } + + def _call_tool(self, name, args): + """Executes hardware actions depending on the called tool name.""" + width = getattr(self.display, 'width', 320) + height = getattr(self.display, 'height', 240) + + if name == "clear_screen": + self.override_active = True + color = int(args.get("color", 0)) + self.display.clear(color) + 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", "")) + x = int(args.get("x", 10)) + y = int(args.get("y", 10)) + size = int(args.get("size", 1)) + + if size == 2: + self.display.text_large(text, x, y, scale=2, color=1) + else: + self.display.text(text, x, y, 1) + self.display.show() + return f"Successfully drew text '{text}' at ({x}, {y}) with size {size}." + + elif name == "set_led": + r = int(args.get("r", 0)) + g = int(args.get("g", 0)) + b = int(args.get("b", 0)) + mode = str(args.get("mode", "static")) + + self.active_led_mode = mode + if mode == "static": + self.led.set_color(r, g, b) + elif mode == "off": + self.led.off() + else: + # Store base color for animations + self.led.base_color = (r, g, b) + return f"LED set to mode '{mode}' with base color ({r}, {g}, {b})." + + elif name == "get_battery": + v = self.battery.read_voltage() + p = self.battery.read_percentage() + return json.dumps({"voltage_v": v, "percentage_pct": p}) + + elif name == "get_sensors": + t, h = self.sensor.read_sensor() + return json.dumps({"temperature_c": t, "humidity_pct": h}) + + elif name == "get_touch": + if self.touch is None: + return json.dumps({"error": "Touchscreen not configured on this device."}) + is_t = self.touch.is_touched() + x, y = None, None + if is_t: + pt = self.touch.read_touch() + if pt: + x, y = pt + return json.dumps({"is_touched": is_t, "x": x, "y": y}) + + elif name == "scan_ble": + dur = int(args.get("duration_ms", 3000)) + devices = self.ble.scan(dur) + return json.dumps(devices) + + elif name == "get_screenshot": + # Direct base64 PBM capture from canvas_buffer memory (no flash writes!) + header = f"P4\n{width} {height}\n".encode() + pbm_data = header + self.display.canvas_buffer + b64 = binascii.b2a_base64(pbm_data).decode('utf-8').strip() + return f"__PBM_BASE64__:{b64}" + + elif name == "draw_image": + self.override_active = True + pbm_b64 = args.get("pbm_base64") or args.get("image_base64") + x = int(args.get("x", 0)) + y = int(args.get("y", 0)) + + if not pbm_b64: + raise ValueError("Missing image/pbm base64 parameter") + + pbm_bytes = binascii.a2b_base64(pbm_b64) + # In-memory PBM parsing and blitting using adafruit_framebuf + try: + self._draw_pbm_from_bytes(pbm_bytes, x, y) + self.display.show() + return "Image drawn successfully." + except Exception as e: + raise RuntimeError(f"Failed to draw image: {e}") + + elif name == "draw_color_bmp": + self.override_active = True + bmp_b64 = args.get("bmp_base64") + x = int(args.get("x", 0)) + y = int(args.get("y", 0)) + + if not bmp_b64: + raise ValueError("Missing bmp_base64 parameter") + + bmp_bytes = binascii.a2b_base64(bmp_b64) + filename = "temp_recv.bmp" + with open(filename, "wb") as f: + f.write(bmp_bytes) + + try: + if hasattr(self.display, "draw_bmp"): + success = self.display.draw_bmp(filename, x, y) + if not success: + raise RuntimeError("draw_bmp returned False") + else: + raise RuntimeError("Display driver does not support draw_bmp.") + finally: + try: + os.remove(filename) + except: + pass + return "Color BMP image displayed successfully." + + elif name == "get_capabilities": + is_color = self.display.__class__.__name__ == "ILI9341" + formats = ["rgb565_base64", "bmp_base64", "pbm_base64"] if is_color else ["pbm_base64", "bmp_base64", "rgb565_base64"] + return json.dumps({ + "color": is_color, + "width": width, + "height": height, + "formats": formats + }) + + elif name == "sync_time": + # NTP over native sockets using custom UDP logic + tz_offset = -4 + try: + import wifi_config + tz_offset = getattr(wifi_config, "TZ_OFFSET", -4) + except ImportError: + pass + + success = False + utc_sec = None + + for attempt in range(3): + try: + # NTP request: + packet = bytearray(48) + packet[0] = 0b00100011 # Client mode + addr = ("pool.ntp.org", 123) + + s = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM) + s.settimeout(2.0) + try: + s.sendto(packet, addr) + buf = bytearray(48) + n, from_addr = s.recvfrom_into(buf) + if n >= 48: + import struct + # Extract Transmit Timestamp (seconds since 1900) + val = struct.unpack("!I", buf[40:44])[0] + utc_sec = val - 2208988800 # Convert to Unix Epoch + success = True + break + finally: + s.close() + except Exception as e: + time.sleep(0.2) + + if success and utc_sec is not None: + local_sec = utc_sec + int(tz_offset * 3600) + t = time.localtime(local_sec) + dt = (t[0], t[1], t[2], t[6], t[3], t[4], t[5]) + if self.rtc: + self.rtc.set_datetime(dt) + self.rtc.sync_to_system() + t_str = f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d} {t[3]:02d}:{t[4]:02d}:{t[5]:02d}" + return f"Successfully synchronized board clock with NTP server. Local time: {t_str}" + raise RuntimeError("Failed to sync clock with NTP server.") + + elif name == "draw_raw_rgb565": + self.override_active = True + rgb_b64 = args.get("rgb565_base64") + x = int(args.get("x", 0)) + y = int(args.get("y", 0)) + w = int(args.get("w", 0)) + h = int(args.get("h", 0)) + + if not rgb_b64: + raise ValueError("Missing rgb565_base64 parameter") + + rgb_bytes = binascii.a2b_base64(rgb_b64) + if len(rgb_bytes) < w * h * 2: + raise ValueError(f"RGB565 data size too small (expected {w * h * 2} bytes, got {len(rgb_bytes)} bytes)") + + if hasattr(self.display, "draw_rgb565"): + try: + self.display.draw_rgb565(x, y, w, h, rgb_bytes) + except Exception as e: + raise RuntimeError(f"Failed to draw RGB565: {e}") + else: + raise RuntimeError("Display does not support draw_rgb565.") + return "Raw RGB565 data displayed successfully." + + elif name == "write_file": + path = str(args.get("path")) + content = str(args.get("content")) + # Auto-create parent directories on the device if present in the path + parts = path.split('/') + if len(parts) > 1: + dir_path = "" + for part in parts[:-1]: + if part: + dir_path = dir_path + "/" + part if dir_path else part + try: + os.mkdir(dir_path) + except OSError: + pass # Directory likely already exists + with open(path, 'w') as f: + f.write(content) + return f"Successfully wrote {len(content)} characters to '{path}'." + + elif name == "read_file": + path = str(args.get("path")) + with open(path, 'r') as f: + content = f.read() + return content + + elif name == "execute_python": + code = str(args.get("code")) + output_buffer = [] + old_print = builtins.print + + def custom_print(*args, **kwargs): + sep = kwargs.get('sep', ' ') + end = kwargs.get('end', '\n') + str_args = [str(arg) for arg in args] + msg = sep.join(str_args) + end + output_buffer.append(msg) + + builtins.print = custom_print + error = None + ldict = { + "mcp": self, + "display": self.display, + "vstream": self.vstream, + "time": time + } + try: + exec(code, ldict) + except Exception as e: + # Capture traceback safely + try: + import traceback + tb_file = io.StringIO() + traceback.print_exception(e, tb_file) + error_msg = tb_file.getvalue() + except: + error_msg = str(e) + output_buffer.append(f"\nExecution Failed:\n{error_msg}") + error = e + finally: + builtins.print = old_print + + output = "".join(output_buffer) + if error: + return output + return f"Execution Succeeded. Console output:\n{output}" + + elif name == "play_tone": + if self.audio is None: + raise RuntimeError("Audio player is not configured on this device.") + freq = int(args.get("frequency", 440)) + duration = int(args.get("duration_ms", 1000)) + vol = int(args.get("volume", 50)) + + duration = max(50, min(5000, duration)) + freq = max(50, min(10000, freq)) + vol = max(0, min(100, vol)) + + success = self.audio.play_tone(frequency=freq, duration_ms=duration, volume=vol) + if success: + return f"Played tone of {freq}Hz for {duration}ms at volume {vol}." + else: + raise RuntimeError("Failed to play tone.") + + elif name == "play_audio": + if self.audio is None: + raise RuntimeError("Audio player is not configured on this device.") + filename = str(args.get("filename")) + vol = int(args.get("volume", 50)) + vol = max(0, min(100, vol)) + + if filename.lower().endswith(".mp3"): + success = self.audio.play_mp3(filename, volume=vol) + else: + success = self.audio.play_wav(filename, volume=vol) + + if success: + return f"Successfully played audio file '{filename}'." + else: + raise RuntimeError(f"Failed to play audio file '{filename}'. Check format.") + + elif name == "record_voice": + # friendly exception for CircuitPython lacking I2SIn + raise RuntimeError("Audio recording is not supported on CircuitPython (I2S input not implemented on ESP32-S3).") + + elif name == "play_audio_base64": + if self.audio is None: + raise RuntimeError("Audio player is not configured on this device.") + b64_data = args.get("wav_base64") + vol = int(args.get("volume", 50)) + vol = max(0, min(100, vol)) + + if not b64_data: + raise ValueError("Missing wav_base64 parameter.") + + audio_bytes = binascii.a2b_base64(b64_data) + filename = "temp_play.wav" + with open(filename, "wb") as f: + f.write(audio_bytes) + + try: + success = self.audio.play_wav(filename, volume=vol) + finally: + try: + os.remove(filename) + except: + pass + + if success: + return "Successfully played base64 audio stream." + else: + raise RuntimeError("Failed to play decoded audio stream. Check format.") + + elif name == "play_gif": + filename = str(args.get("filename")) + loops = int(args.get("loops", 1)) + x = int(args.get("x", 0)) + y = int(args.get("y", 0)) + max_frames = int(args.get("max_frames", -1)) + clear_between_frames = bool(args.get("clear_between_frames", False)) + + try: + # Temporarily disable dashboard redraws so the GIF plays cleanly + self.override_active = True + from gif_player import GIFPlayer + player = GIFPlayer(self.display) + player.play( + filename, + loops=loops, + x=x, + y=y, + max_frames=max_frames, + clear_between_frames=clear_between_frames + ) + return f"Successfully played GIF file '{filename}'." + except Exception as e: + raise RuntimeError(f"Failed to play GIF file '{filename}': {e}") + finally: + self.override_active = False + + elif name == "download_file": + if self.session is None: + raise RuntimeError("Wi-Fi connection/network request session is not active.") + url = str(args.get("url")) + filename = str(args.get("filename")) + use_sd = bool(args.get("use_sd", True)) + + from download_cp import download_file + saved_path = download_file(url, filename, self.session, use_sd=use_sd) + if saved_path: + return f"Successfully downloaded file to '{saved_path}'." + else: + raise RuntimeError(f"Failed to download file from '{url}'.") + + elif name == "get_video_streaming_instructions": + protocol = str(args.get("protocol", "both")).lower() + instructions = [ + "### ESP32-S3 TFT Video Streaming Instructions", + "Dimensions: 400x300 (decoded and centered automatically on the 480x320 screen), 1-bit monochrome (Floyd-Steinberg dithered).", + "Frame Buffer Size: 15,000 bytes. The host must convert and map standard pixels into the specific RLCD hardware buffer layout before sending.", + "Mapping logic (Python):", + " def map_to_rlcd(pil_img):", + " img_1bit = pil_img.convert('1', dither=1)", + " px = img_1bit.load()", + " buf = bytearray(15000)", + " for y in range(300):", + " for x in range(400):", + " if px[x, y]:", + " inv_y = 299 - y", + " bx = x // 2", + " by = inv_y // 4", + " idx = bx * 75 + by", + " lx, ly = x % 2, inv_y % 4", + " bit = 7 - (ly * 2 + lx)", + " buf[idx] |= (1 << bit)", + " return buf" + ] + if protocol in ("tcp", "both"): + instructions.append("\n**TCP Streaming (Port 8081):**") + instructions.append("Open a TCP connection to the device's IP on port 8081 and send consecutive 15,000-byte frame blocks.") + if protocol in ("udp", "both"): + instructions.append("\n**UDP Streaming / Broadcast (Port 8082):**") + instructions.append("Split the 15,000-byte frame into 15 chunks of 1,000 bytes each. Send each chunk as a 1002-byte packet: byte 0 = frame_id (0-255), byte 1 = chunk_idx (0-14), bytes 2..1001 = chunk payload. Send to port 8082 (unicast or broadcast).") + return "\n".join(instructions) + + elif name == "get_stream_stats": + if self.vstream is None: + return json.dumps({"error": "Video stream server not initialized."}) + return json.dumps(self.vstream.get_stats()) + + else: + raise ValueError(f"Unknown tool: {name}") + + def _draw_pbm_from_bytes(self, pbm_bytes, x, y): + if adafruit_framebuf is None: + raise RuntimeError("adafruit_framebuf not loaded") + idx = 0 + newline_idx = pbm_bytes.find(b'\n', idx) + if newline_idx == -1: + raise ValueError("Invalid PBM format") + line1 = pbm_bytes[idx:newline_idx] + if not line1.startswith(b'P4'): + raise ValueError("Not P4 PBM") + idx = newline_idx + 1 + + while True: + newline_idx = pbm_bytes.find(b'\n', idx) + if newline_idx == -1: + raise ValueError("Invalid PBM format") + line = pbm_bytes[idx:newline_idx] + idx = newline_idx + 1 + if not line.startswith(b'#'): + break + + dims = line.split() + if len(dims) < 2: + raise ValueError("Invalid PBM dimensions") + w = int(dims[0]) + h = int(dims[1]) + + data = pbm_bytes[idx:] + width = getattr(self.display, 'width', 320) + height = getattr(self.display, 'height', 240) + if hasattr(self.display, 'canvas_buffer'): + if x == 0 and y == 0 and w == width and h == height: + self.display.canvas_buffer[:] = data + else: + if x == 0 and (w % 8) == 0: + dest_stride_bytes = width // 8 + src_stride_bytes = w // 8 + for cy in range(h): + dy = y + cy + if 0 <= dy < height: + dest_offset = dy * dest_stride_bytes + src_offset = cy * src_stride_bytes + self.display.canvas_buffer[dest_offset : dest_offset + src_stride_bytes] = data[src_offset : src_offset + src_stride_bytes] + else: + src_fb = adafruit_framebuf.FrameBuffer(data, w, h, adafruit_framebuf.MHMSB) + for cy in range(h): + for cx in range(w): + px = src_fb.pixel(cx, cy) + self.display.canvas.pixel(x + cx, y + cy, px) diff --git a/circuitpython/lib/neopixel.mpy b/circuitpython/lib/neopixel.mpy new file mode 100644 index 0000000..53b8f9a Binary files /dev/null and b/circuitpython/lib/neopixel.mpy differ diff --git a/circuitpython/lib/rgb_led_cp.py b/circuitpython/lib/rgb_led_cp.py new file mode 100644 index 0000000..6437fc1 --- /dev/null +++ b/circuitpython/lib/rgb_led_cp.py @@ -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() diff --git a/circuitpython/lib/rtc_cp.py b/circuitpython/lib/rtc_cp.py new file mode 100644 index 0000000..0e28b43 --- /dev/null +++ b/circuitpython/lib/rtc_cp.py @@ -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" diff --git a/circuitpython/lib/sd_cp.py b/circuitpython/lib/sd_cp.py new file mode 100644 index 0000000..c059195 --- /dev/null +++ b/circuitpython/lib/sd_cp.py @@ -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 diff --git a/circuitpython/lib/shtc3_cp.py b/circuitpython/lib/shtc3_cp.py new file mode 100644 index 0000000..1e883a9 --- /dev/null +++ b/circuitpython/lib/shtc3_cp.py @@ -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 diff --git a/circuitpython/lib/video_stream_cp.py b/circuitpython/lib/video_stream_cp.py new file mode 100644 index 0000000..7e4035e --- /dev/null +++ b/circuitpython/lib/video_stream_cp.py @@ -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 diff --git a/iphone_app/tools/stream_color.py b/iphone_app/tools/stream_color.py index 40796b0..9f6a88c 100644 --- a/iphone_app/tools/stream_color.py +++ b/iphone_app/tools/stream_color.py @@ -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__": diff --git a/lib/ili9341.py b/lib/ili9341.py index 89be7f3..16fafb0 100644 --- a/lib/ili9341.py +++ b/lib/ili9341.py @@ -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) + diff --git a/lib/rlcd.py b/lib/rlcd.py index d9a995d..a5dc3ad 100644 --- a/lib/rlcd.py +++ b/lib/rlcd.py @@ -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) \ No newline at end of file + 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) \ No newline at end of file diff --git a/lib/st7796.py b/lib/st7796.py index d6558ec..202050a 100644 --- a/lib/st7796.py +++ b/lib/st7796.py @@ -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) + diff --git a/mcp_server.py b/mcp_server.py index 6c22f3c..f0ed8d9 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -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", "")) diff --git a/pong.py b/pong.py new file mode 100644 index 0000000..c293a53 --- /dev/null +++ b/pong.py @@ -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() diff --git a/scratch/get_stats.py b/scratch/get_stats.py new file mode 100644 index 0000000..787b98b --- /dev/null +++ b/scratch/get_stats.py @@ -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)) diff --git a/scratch/read_serial.py b/scratch/read_serial.py new file mode 100644 index 0000000..3b3885a --- /dev/null +++ b/scratch/read_serial.py @@ -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() diff --git a/scratch/reboot_and_read.py b/scratch/reboot_and_read.py new file mode 100644 index 0000000..fc55c65 --- /dev/null +++ b/scratch/reboot_and_read.py @@ -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() diff --git a/scratch/test_playback.py b/scratch/test_playback.py new file mode 100644 index 0000000..923d61e --- /dev/null +++ b/scratch/test_playback.py @@ -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') diff --git a/scratch/test_raw_stream.py b/scratch/test_raw_stream.py new file mode 100644 index 0000000..f97c9d1 --- /dev/null +++ b/scratch/test_raw_stream.py @@ -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) diff --git a/scratch/test_rpc.py b/scratch/test_rpc.py new file mode 100644 index 0000000..141508f --- /dev/null +++ b/scratch/test_rpc.py @@ -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) diff --git a/scratch/upload_all_fixed.py b/scratch/upload_all_fixed.py new file mode 100644 index 0000000..f99a982 --- /dev/null +++ b/scratch/upload_all_fixed.py @@ -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() diff --git a/screensaver.py b/screensaver.py new file mode 100644 index 0000000..ecf8073 --- /dev/null +++ b/screensaver.py @@ -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() diff --git a/test.gif b/test.gif new file mode 100644 index 0000000..1f5a473 Binary files /dev/null and b/test.gif differ diff --git a/test_stream.py b/test_stream.py index eb19660..a44e1d1 100644 --- a/test_stream.py +++ b/test_stream.py @@ -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