From 8f871e499e261efdea578f93013468410e465d3a Mon Sep 17 00:00:00 2001 From: Adolfo Reyna Date: Mon, 22 Jun 2026 10:13:10 -0400 Subject: [PATCH] Migrate MCP and utilities to CircuitPython, including color GIF and volume fixes --- circuitpython/code.py | 488 ++++++-- circuitpython/flash_and_deploy.sh | 8 +- circuitpython/lib/adafruit_ble/__init__.mpy | Bin 0 -> 3115 bytes .../lib/adafruit_ble/advertising/__init__.mpy | Bin 0 -> 3385 bytes .../lib/adafruit_ble/advertising/adafruit.mpy | Bin 0 -> 617 bytes .../lib/adafruit_ble/advertising/standard.mpy | Bin 0 -> 4769 bytes .../lib/adafruit_ble/attributes/__init__.mpy | Bin 0 -> 388 bytes .../adafruit_ble/characteristics/__init__.mpy | Bin 0 -> 2176 bytes .../adafruit_ble/characteristics/float.mpy | Bin 0 -> 822 bytes .../lib/adafruit_ble/characteristics/int.mpy | Bin 0 -> 1705 bytes .../lib/adafruit_ble/characteristics/json.mpy | Bin 0 -> 974 bytes .../adafruit_ble/characteristics/stream.mpy | Bin 0 -> 1369 bytes .../adafruit_ble/characteristics/string.mpy | Bin 0 -> 1059 bytes .../lib/adafruit_ble/services/__init__.mpy | Bin 0 -> 784 bytes .../adafruit_ble/services/circuitpython.mpy | Bin 0 -> 669 bytes .../lib/adafruit_ble/services/microbit.py | 0 .../lib/adafruit_ble/services/nordic.mpy | Bin 0 -> 1153 bytes .../lib/adafruit_ble/services/sphero.mpy | Bin 0 -> 282 bytes .../services/standard/__init__.mpy | Bin 0 -> 1103 bytes .../services/standard/device_info.mpy | Bin 0 -> 1126 bytes .../adafruit_ble/services/standard/hid.mpy | Bin 0 -> 3726 bytes .../lib/adafruit_ble/uuid/__init__.mpy | Bin 0 -> 697 bytes circuitpython/lib/audio_cp.py | 100 +- circuitpython/lib/battery_cp.py | 42 + circuitpython/lib/ble_cp.py | 101 ++ circuitpython/lib/button_cp.py | 73 ++ circuitpython/lib/download_cp.py | 66 + circuitpython/lib/ft6336u_cp.py | 144 +++ circuitpython/lib/gif_player.py | 13 +- circuitpython/lib/ili9341_cp.py | 481 ++++++++ circuitpython/lib/mcp_server_cp.py | 1092 +++++++++++++++++ circuitpython/lib/neopixel.mpy | Bin 0 -> 1315 bytes circuitpython/lib/rgb_led_cp.py | 43 + circuitpython/lib/rtc_cp.py | 132 ++ circuitpython/lib/sd_cp.py | 85 ++ circuitpython/lib/shtc3_cp.py | 101 ++ circuitpython/lib/video_stream_cp.py | 572 +++++++++ iphone_app/tools/stream_color.py | 55 +- lib/ili9341.py | 59 + lib/rlcd.py | 18 +- lib/st7796.py | 59 + mcp_server.py | 38 + pong.py | 97 ++ scratch/get_stats.py | 25 + scratch/read_serial.py | 30 + scratch/reboot_and_read.py | 39 + scratch/test_playback.py | 68 + scratch/test_raw_stream.py | 64 + scratch/test_rpc.py | 48 + scratch/upload_all_fixed.py | 88 ++ screensaver.py | 117 ++ test.gif | Bin 0 -> 2455 bytes test_stream.py | 1 + 53 files changed, 4239 insertions(+), 108 deletions(-) create mode 100644 circuitpython/lib/adafruit_ble/__init__.mpy create mode 100644 circuitpython/lib/adafruit_ble/advertising/__init__.mpy create mode 100644 circuitpython/lib/adafruit_ble/advertising/adafruit.mpy create mode 100644 circuitpython/lib/adafruit_ble/advertising/standard.mpy create mode 100644 circuitpython/lib/adafruit_ble/attributes/__init__.mpy create mode 100644 circuitpython/lib/adafruit_ble/characteristics/__init__.mpy create mode 100644 circuitpython/lib/adafruit_ble/characteristics/float.mpy create mode 100644 circuitpython/lib/adafruit_ble/characteristics/int.mpy create mode 100644 circuitpython/lib/adafruit_ble/characteristics/json.mpy create mode 100644 circuitpython/lib/adafruit_ble/characteristics/stream.mpy create mode 100644 circuitpython/lib/adafruit_ble/characteristics/string.mpy create mode 100644 circuitpython/lib/adafruit_ble/services/__init__.mpy create mode 100644 circuitpython/lib/adafruit_ble/services/circuitpython.mpy create mode 100644 circuitpython/lib/adafruit_ble/services/microbit.py create mode 100644 circuitpython/lib/adafruit_ble/services/nordic.mpy create mode 100644 circuitpython/lib/adafruit_ble/services/sphero.mpy create mode 100644 circuitpython/lib/adafruit_ble/services/standard/__init__.mpy create mode 100644 circuitpython/lib/adafruit_ble/services/standard/device_info.mpy create mode 100644 circuitpython/lib/adafruit_ble/services/standard/hid.mpy create mode 100644 circuitpython/lib/adafruit_ble/uuid/__init__.mpy create mode 100644 circuitpython/lib/battery_cp.py create mode 100644 circuitpython/lib/ble_cp.py create mode 100644 circuitpython/lib/button_cp.py create mode 100644 circuitpython/lib/download_cp.py create mode 100644 circuitpython/lib/ft6336u_cp.py create mode 100644 circuitpython/lib/ili9341_cp.py create mode 100644 circuitpython/lib/mcp_server_cp.py create mode 100644 circuitpython/lib/neopixel.mpy create mode 100644 circuitpython/lib/rgb_led_cp.py create mode 100644 circuitpython/lib/rtc_cp.py create mode 100644 circuitpython/lib/sd_cp.py create mode 100644 circuitpython/lib/shtc3_cp.py create mode 100644 circuitpython/lib/video_stream_cp.py create mode 100644 pong.py create mode 100644 scratch/get_stats.py create mode 100644 scratch/read_serial.py create mode 100644 scratch/reboot_and_read.py create mode 100644 scratch/test_playback.py create mode 100644 scratch/test_raw_stream.py create mode 100644 scratch/test_rpc.py create mode 100644 scratch/upload_all_fixed.py create mode 100644 screensaver.py create mode 100644 test.gif 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 0000000000000000000000000000000000000000..b09308f979dbd87e38a758509e71b56f28260ec8 GIT binary patch literal 3115 zcmZWp-E$My6~F6GNtVUcN^FEt>_scfM*fmzS>`hl()!zg*R}wG$g*0ktp!_BwJWfb z$;`?z)L}ZU`p`b~p?&U4N0Lnn4P*urGHILkB@dl4L;BR2{sDb&&#q+y?ZfUp_uljI zJLmk)jkTbztR*0%h2=s~R{7MbG{p0A7DRq9zk!ZWLN=RI1y#;v73Adk<)T_FNIZ{P zlnn*B<+c2(v?gWuHIOMQtVzgrS1KrAfULSdIfptfq~S=F6+;zKLVYV|SCDN{D%_Pt z31MYlP*BHGVt$;DO^wH9r!P$+JgrIvLCqBqzLZ;#Abu9%%XuA=u!<~8MSy@zOB;C! zS>jnEuDX#oe9y_c=_>NDQxo#(AG@S-4QB;-+4q3}je z=S+u~(>bk5yuoFxuJifaJqXaDh(Z?fRq{|H2~l;VI)xCesJZ-MINgnN-?MImN*vA? zq-A+sQjl#;P$9B{EC8^ufk?yBP_xxNBHM*@8sdPmP!|SyaaDroZ&hbMfddo352&IeUxfLlZt>+6LAbLeo`F#}i>x&f6*9R54gLz0Tp95)VU=-ly@-VO)XwyOO zH}p+pR9Y;JEN+fV5bo{rmYPdf`OUMXX;WT;a+PAFlr665lPjT8qOH08odHA5^Gg|7 zp$ifurbsl1@XpYB@!_$7nDjka&Cs#^p`15{k`4{7pQVeSy(*^)LSf_3dU$Ysa2++D z$*5{xIWsi00=C7}Ab1-(7{;Ls2XEXvK6x0yK~P7gMi^036AWsg`Tb8$6h5VMsoOB8 zbVksj(RJF=pL~j%otJWSoo%#mSCCf?60{)H+*cn_Bbmc1dO274pHxP5yk33kfi3g_ z$qcg%Nu>|pRhWsNvFAd>t%%k-H!?*UEG{^uD=-brazZ8+~6mUH@X|BhJ?l) zZ=#y)O^#+q3xod7E*fI17TTaXj5>v|> zZpSu!1Y>N+BzE{)k2~G%RJ+?o+1y8{BW_G#x1F-PNs4qkC`UrWN5PtMAEk~a?Coq? z!|%b~<$jNP5AKeE+~Gb(9Rv9|$Zq#>>Ud(ta@8LH2OCSw5I6Dr)D@sH$qxpg!X$wao>Z_{^Z`!yQvB7o#sSKX{y6^GI>)A zv*Xo@%ioVGu0H+O&xxO13%Ul9!`gXI&(r|p^>KUEilgr}I|cu0d@p*v;_y2MtBxT* z5hh;juroVae6OZoUa5(jy?$pP`H?oISv@Dc)jg80;^9yO%T^u3dllzEElhAfd@nx0 z3%x;5yH0yRE!s2D!+4lpU$nY6|Kh-8LN{4>0iOF`vlH;I#l?LOU+=PU?&S`9sftH- zL!0R3BmMTuBayttG&@6;YPbQ~o-tcSlUkkzxQ$XLpANHl`AKmI!v~ydn%;$E!pWCgs=}bib zG7+P%UDS2XPb`i=Z#kxs^EWPz5L5(OJPytCY$5DP&0{9TVQ-3Q#NGPOm-754fO}F4 zxHmP@NKFKrs98{Enm}iynVPHfK<92`hCzI#2U*8(&@q4S>5A0>qZc=r;Nk6H$&T|#l z2RfHNRs zZ(gw+X%3;iile3Gx=?XMYp!UGogmY-5b+IpIOlmoggL+eicRkVr2xk=1L;W>$9ZSu z5srOTYhh28Krk$|R~?3VGQ_BZJFdFMFv5#T#Z|JwdG za%Ljg>tVdzz8>wLnwuHFLoamQw=ms-w9nJaJdTZ%@U(nOt_0EJk3#U`{E=yW@|tCt z*61YD>YRKMCjO-RoWN6mz@8drb|5zHeuRBr;GW;(-fFlE>>2$tY5wjpKYR2q&z#d= zh6#3t%shJvqZ57hUG(JB$FIL!)aM=W`N`W=Xx_z8Lkx;` z>A8+}u8O_2@Fwx1;=BxoWamzZs5m~{xuXB#`aF#$%V%}RFkSa?!Zn{PM`)kV-{0$> z*|*W}=AUzY1SvtA|Gh`vfrX!?0}U^}-UT<1(hS+TYxv#O{qDwVJ6EEe6hz*Jw@5cYq|6Z$kfp)Y7`y!X?Xs0ql~4)7@B- vyKq z6=c=nW(z1h!@pIzuyjKdU~5 zM}Au_kd4n|BoXpWNTpI1SvX^0ghD9_B zQ-7Pw%$OIIWPdx;K6OpO9k>&BVFGt!3+}*m|hw+GoEL18*}8D+|Kw?p$^;|HZ#4cP$y7uZ@z3dlA*5Xon<6H`q+WRvC4O;B zam{P<^V8NRI7E-uBc_$bSSTr|DWByKr?4z$@!@@|9qiuOw>-RO8Gg>PcJYUrqC^9p zG_4Fo-JT(j*E`?|_y_z$)83=Xb^v`!w$onl^jYj{MQbd4-cX-zKUBU=#A9D6uryHD z8j&_-;R~(tV5jkL=csw%%SX-c64yz-`DlS$3Y+6oAoxwqN0(rv2OmdFi?PIGm^L0h z-ENSA7B>Ag{@Z&^;oX=2t7CB-8K;#=*6s5R%nU2r8a)(2-1NYwaG-u%BH84odp-lh z2Vv9G?bv8+3go6C_Zg?Pep;yiv(wlS?&A+WgoC|4ICv&HJUHYI_=bi8!BHQ1M8kAF zTs3WznMT<3ELP=njULwB&K)y<@g>PWHat&x5PcBvfBAqE9*0dke}?$|m})-!B+|a9 z%k}0%b?1>j5n)}QJlKJsPk@z657ke0o_-$t{XJ3yu);d(?5$n5zs@Yq!8^UCM2b%3M#S_CnaT*6g4W~Hvu&pt4VrN zQhx36`}{*bKYBv0Yt*Y@6E{7$O;$i`gw3ClZ#Ah`N$9l11<-*eakfqmHuU0hcue%D z-aSyY&^pz2gdMdH(`;s=+X&6^6Kabe5x{Lw`3-FuDR zdBJ;3rw)>W$6E*<{XYclbX=oiyD%bVWdZ78v<2f&P>KmyY(&iaNW27g)}BN+hz+Yj zf^#2<{btAvsSQGnRZ+7Rg=CiR29+-$n}FyvAUe|oR1Y-A*(400-4@)yxF! z4jin7tBA?z(tTZ8v-jbnIQT1Qb0!b+2o8Rv%LTZ=lla)uDD)X{0Hw!F+lvMl7hr6I z`8|Gb1)=4D0W`ItKj*d z=8OlVwM8&h@n7_3WXo?n2GkQ$sE1ATH5I=B#j*phi61T&He{PGAKMyDvl^YMZ z7Nc6d|Jj4&_unMu-~zx`=+Opw2cCwAS-%VF_wXV;3U>ze`reIT7K(Pbd$z8_<=;XZ V^$}O4E#70MN6ACGF&MAz{{VC$0YU%( literal 0 HcmV?d00001 diff --git a/circuitpython/lib/adafruit_ble/advertising/adafruit.mpy b/circuitpython/lib/adafruit_ble/advertising/adafruit.mpy new file mode 100644 index 0000000000000000000000000000000000000000..72dffe7a26d0e521d124390a92862889fdcf74a8 GIT binary patch literal 617 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 literal 0 HcmV?d00001 diff --git a/circuitpython/lib/adafruit_ble/characteristics/__init__.mpy b/circuitpython/lib/adafruit_ble/characteristics/__init__.mpy new file mode 100644 index 0000000000000000000000000000000000000000..08939b54af770e011edb96bd0e288642a516ca7b GIT binary patch literal 2176 zcmb7DOLN;)6uxp2<2r77ZMnB=46 za@(*HrZ8@YVOX$W26imj$abI$STIdzfHez-VSoj{f*n_K!q6}v zn7uJplxotZ)=_m_tt)YPThb(1S2VS)t8zPzvDzR-=G<8W=n~gT`W4zhX zJDP$q@U(TUBkRCp^-!C@m)3Pnt#)(;_@zU)4W`SLVh(5Xx$KR?$~6EBWR65C09`L-8oy|=}0})x(WU_c`SKn?n@Iltp zx}x1xWd%h4ez9146m0|7ty={V1fDtUbkrK~EL?~!#4Z3f`wRvknr-foo$enAN7n5- zABp-hYpZE7n_e%I>#c}|Y`UBSo|V;d;c5lA)^cePxNol&NHAa>iAx{vEhV@ux#%%*x@sU3bzTcoK{ z9p9De9R&pao2pW;;fA!MfN88W+7@lqW~WgDQJXP*gm>W4l$|D_Z+dimfF3w=G|xXe zw-;wIuBr{PL{paP^RSpqQSP@CBFGxHzg;a!z5_g+hHY;mjPEL1o2Z1eg&1o}t4X~0`HNk8>TquQ?TNQ< zJLP&CbY~`~pPkgb=xkQs025cXb-mTT5|3}G`gW%plbbv7Ly5%GhkrsmEO?07?#GhlG$XUsk4d{Z$pc_s?4?G@q&P=f`4-_FG`LfrX-I&LNhlQj}U>q|jW#f#SA5h87azXt5+uywm!=*XyN1;nXY_2=YS6 zsEI+|eo7`2>5F3DWM&5@!wpR)uxB#CFHDC2(qx3kCKD>j?{54+adWA3$Y^nM0b%Z3 zAYaIvbfiy5e-Se!xlf-HxBB!veIDOH`$Kxxq-W{aPhx)fv_hUtArtXsf>e@HGI`S< z?+=z7PxnYMY4KV~?!iyseBA2Z75Hd`j|K!G5(@4Jf-orkDvBl@dSW>@O_~?;zAQ0G z>Ir%84@q>{I=W`;2OUo`H?2V4Ck~R-ekSE3Nc%64wd`$RXaM;k6iixxoJn&qVA25? zH0j{d`wqE#c}V#eQt6!WsSpT9`lL_BluiF!e~GG$HoZkImXN!}7yXN=bi(*7A_OB= z0=Z1Z+969eILsUjnM6r`pfl5_dY^A>jCAn8Yir;Xdi7s9{pbj%2?`}`E*)rr$m^dx ze*M3Ae2?owKAB1z-*JRQJ{$^$3CMy)A3mTDJ{kN)UuGa@Vh6+>1(T2hdBVX_EFW1| zeszH5&jicpUsxsymixY|eZu-MIaV3U59-_u)BD}<+}Q}fX-qwG0~;XbKLE1CZk`3y zNbcrYib4w}=b+G{DUZEd{OV()m*T?LQh7`0A>$7|8k%no`piY&xPNJ|MD>|Vk4wM) zATE>d%YFLdXGjL17JYdob_?41T%v0jQMZcZ%OkFbmgG z*K;4bg~0r|fJJ$xFOO;$216ogpSYK#NcE4_KS2h(KnCnB5kMl}jl)G!oA}T-e=vAR O20tAYFAhi&8Sy6|u63FK literal 0 HcmV?d00001 diff --git a/circuitpython/lib/adafruit_ble/characteristics/float.mpy b/circuitpython/lib/adafruit_ble/characteristics/float.mpy new file mode 100644 index 0000000000000000000000000000000000000000..237aee94d562fcc87ec69f0df7a6dd4c7beaaf8c GIT binary patch literal 822 zcmZ9J-BQy)7=}0fr_{3jTly2EEiGWCw%`Z{j57jh6|h1}={Pyin50`8Elo@|I6Vv1 z1LO)EucB0CyaI2)bG#K7Dl_8IKKp&|zVm+H@*XhDF*{OAY8#eHM7fJgN=Gszg{Tc2|=~QSXB(B=ve4k%+4GOb`{twnZ!hiy{bb5MrpZMKD-w5W`Xkf6D|olLtbr zBJx`m{@M2KQvmn$D;KE?d^`QEXqZi_-Npu3R8&Ku`Sd>N zXg%?-D%ilrv8rIe{hPmNj;ki{HJjUIkiFHkEVTs!%S*-O;u4sDay8n2QGM5vsBUOF zHi(L;&t9#v3&IA?B+}Xuh`u*eg2$Xxdn!?-u6QhUEez&dkJ=z8iieocy-+Z(;W-k; zV{DjoNLq%XXkcBVRJy-1Ifx8aJevFlc$BUQ1{L>OhY;OdE0qpa(y`>Cq8*iPhA3^^ zd=)=lNo~B6&1$7c<=|7bkY-+|ZC`ai`zlv0SbVN!LkGvCoU8*mgcIn3ZqAYNurB0c z-N-F$ZQ0NZeQ*N$Iag|u^&&6pLq2u_O|X9C7ed}LbqG+0J8S?21RDk^Pq9H1qzoyC zScsso<9`vl`@75wJIqTMMzeDOr4Jqe7zw!_I#HyqFdtj#MC3HH{&s!gojrNx1)&Ve z!U&pc6hIwDWgEuikSl`V^!SL&b(oNc8Ni@qonsoq48tIHn07E|W~ewn)V_SSPuJtg zTs|6`jwkHGeD0!t`Kw&M;FzD``8+s}C1DCiM=vPuS5aV>gwP+7DN7hAQsakH?Lf(N~UHbfSOk-s zSWyL>y@3FFU$L~2=d!E$>=$cKR{(rgyQJ_3z*0P}iNLlclSg33$2Cdb1N3u|1R_x~ zYNaA7U{a72fuyS)r-Q#!FR|c7c6nIzAJh z2EjG?cF-^>&EEo&zDiX&Xke*`~W{o%7z*JStR4a_a%X=a)+HXAmycCmkj_f53I*5KiJCK8DNVJoN2$mk(ww+Ud=dz{8 z)K}1j;vs+%MHWD})0omDcaf!_$LS{0Q)G2;up6#k{?ZMy~^!*C`zrm5;;piJU zc9DKXl?p-ww2ZFtWNLmv%Zvxl3h#26w$S}z{47mrTf)ofHwIUPI2N^)r1HEIn1gc^ANBcutc93H-0d1of z7fzg=Z@$*%8v09Cn6VBvi#^t0hoxPTPVOOX_eqo2Njb)fnlE}J)J^rT)`@lU{vO&+ zpZ4i>T8yy{HOoEP(1%8&=j-nas Wa8v1}8oo*u(tjMJ_Cc|0p#K2en%RQ@ literal 0 HcmV?d00001 diff --git a/circuitpython/lib/adafruit_ble/characteristics/json.mpy b/circuitpython/lib/adafruit_ble/characteristics/json.mpy new file mode 100644 index 0000000000000000000000000000000000000000..f2b91801295e9929cbee594633944f5ba75fda5d GIT binary patch literal 974 zcmZ8e%~IP|82w~yRTu+TMqGle7ULg&93o)~Ua+Y_cI!|I7|3+IXoM_Z+ag9%q$?UP zIwRapC(}jOeGCI_9-yl{Mf)nf#AT-G>O1#*XU=!#T#bjN9jB@Mc_le!5J$TZw z-jMd=OK=VI6~x=YmFq)-*rwAaFw_!(Jzn%3qwT|tu6GG#wKCv;0N-F;zb2l~+F~@) zb&t3XL+Z1g;ri5YZn!h74OQN0T>@|ReTeP$D0Tf^Rqa}|7qk@9xl$jSQ1>1`TO0R! zGVXQ%u%<9M{1V;Bau=CUIQ;T7`!avH5oq~#h$2NUJt0SMq!~8SQ5?NNF~k=m=}+Vs zj>$acoAr8#1T=}_XsQ@ZCu9K&@+6*=<2Wu);i;x5RGH5-^O=#S@pLmpvkW777SA#) zGMtn}EH)3~--@e$SGDE=_Z^zUYdOH{=Uadzk>8%cQp4na&S!Gc+gzoaSzUb{#(R}s z<+1`|5kEx|-Z7e<*VhgU{s?8l2ge+%DMQnI4XFD$0gq4L8;Cb?OF+j$wy-=T$jwD>zp{eeEs z{sic=6O_J{mfn@Q@~_Zrm^W*S@ugC@n@L8c%&5#s%P3ps7`-0PmBKF`=zMZ(gtFTa zMh`RZW66S|e!d-Qh3sA9uj>A-c`N0{=L&cCj>F%?LaB E52w=xzyJUM literal 0 HcmV?d00001 diff --git a/circuitpython/lib/adafruit_ble/characteristics/stream.mpy b/circuitpython/lib/adafruit_ble/characteristics/stream.mpy new file mode 100644 index 0000000000000000000000000000000000000000..656e9ea1fcdb736b069d68e3e741b8c1d335fcd4 GIT binary patch literal 1369 zcmZ{iO;aLQ6oyZOpd#v}fh%H1mhi+QwDxs;SdopI# zGA_(lRVp=$)cgv;$z(tI5m{!HWj6T*xqwc_NoIB5I``aj-shal*&*BSL*TRIZWb8qoL{*T~Qk?@Cd?*p&Obk2;l6!R8*ih zbX`+KLzjW!?$v=Pr`DTi^0W8CR)3k_&kEe5EVrA>?*J+^d*v%<;5cj$0Z#qAsWeW3 zIgp7>(q_{*IgvG3kQ7ZKo}1@-O>GFbS!?g2#NR`KiIS{6RU{dr_tIK}XbVOT4|7|< z-n%pmr3%iK<@ic`8G@Ut(WoA2iY}M>V*z?_77QKGK;{}S(tk9eD|J~V7g+3D7Fx>J zGC1>va&DUkPg7HyvZgCC>08*(=7A=2t_mbp2hUf%76a`#I>?o?!1fOzUl0zmrTs#_ zl!efO*s~+t$AosSokhZ3bD!{TcTc_YOgNJpr+N)WM5RH{G`X&leW8}D3z8&}2-&f6 zat;gkS9LpgnmE|txNNBepJPFIDr+sWCh`Z6AZT(^B`UF$ZoBQIEnFOpwbK)~^99>_ zP1l>P^+e)S(QAenm(+UVZrF*9yHCgs7P~)K?B-rJPSRn-N*3^aEEc1_44R{R+sEv2 zcrO++xN!BG$?qa2vP7vFD`UYH-Ym`x;h_s;Lv}PAwa$z%Hf&?;*v<^&Vg6CxM5D-o z#>kD68O5WF13Q>8JjT05x0)szCv_&6aXikO$WHWR?>5s4F)k-9XsR!#ijwtW4;o#B!$C8$x`z>*mTHT=v@0kMX7z9q{Hb|^I?W%m&`Yt#ZI^T;_tZS@Y)^nMY=Ek zeAA^r>7=ONxw)cr`9*LFU!^jq!QJ`!ADqyyj&Z6KgCg>YAqq{4teHW6(PekfAVzfA z+#jLY!4VLz9{`2f>mWtp#cRp(eC_3rS63N7yO#D%`$;;`Pi{O9QS4x~ z4Lm*MS-L^f9rqmacii)N!Ue*hC?dz=6O literal 0 HcmV?d00001 diff --git a/circuitpython/lib/adafruit_ble/characteristics/string.mpy b/circuitpython/lib/adafruit_ble/characteristics/string.mpy new file mode 100644 index 0000000000000000000000000000000000000000..a5f3b53fb9721674cb148304bc45f87e22911e9e GIT binary patch literal 1059 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..b264d2e472f310fee222f06636e5098a13ced64a GIT binary patch literal 1103 zcmZ9K+ins;7{|Y*fLow+7M79Pl_IUxwiH@KZJH+1leXHZSenSyvK^+f30=tUY-6va z)EHu)z_)1Y>Dsdo;A?p03pgxbBAaaTpKt#2o9{5IUKpDd3R+p)Gi{xzyH%RDsCl3h zYNaiv8D-5Zr&U!q_@J$WL4d>nQc;41(E6d6qMD9W@>~{Q@5B- z;QBT*ZNmDZG4h|wg2^4-U<-Y&$v#(#dmNx{JnOS2`>hr;xeKD(-6D2&HrGJ3ZM_V^ z?M{U-5SX7!&8OyIZn<8kJZ*!}KJKM^)ZnF%Wx_jRLAw68r^545UHL9Uy%IyIS2EfAlqFRwx1;iJOl-6F_K8!O!pyi2<)Fz#O3 z0L<&)?NgoJMoEX=Sz%Q@pr*y=z?+X$)uiRg+6~^k;VmkdyXBB%lMQZ!$-Wy3T_a4itZW8`z9v`v_ z&tYY2J;m+tx*AOirFf~dbvKhtCbuTaCxa*clM5&Q269acqXUWyyNZqcs2jVFkq3FF zU85Hi5B4Zt>@6N6k$VOe5sO^=xb`bP?BhDX^^g+4fnpN{xgJ)6I9Nmo$HxI*DP#ai zk~cemr9wtPAt}gZsE~1?up`5T%m9)dDHk$s6meuEPdrT-`K8uK?mkEfJdPwhAq5hR zUqV*EL#J^NuyRU-qo_4OY-Svd&Yg2VeLCm+&b?9q)<0SJR?(E6?J-yG z4s_Ac?Z-^j9no-E1}?HB=0*hd$7*4SC1xxla^yZsQLQ7Da-o=>ysDhFv6N zaFk4$Z}UB74=?ibI0o$^6tN6To?1CO2PhD0ptwEL9zt%&RNGWd!?n%906VPo^``^Y z8$m#|JP1rj5YcoMMZiP~d4@97Vdb%IpNjjE%JNx0Ju4YyTP9}W)obBN{Fykpws@oO zx|VZ86dy6S?{%xHITX(?hj{D!mPpK2@wOa|+`ehZw+|atq(jIbEw&z&CQ+Drwx3CUL{Fd<-_@tP>rJz zeu+eIGyc0+<7yM2={ZoE0hu|lF$1!5;K~dT=0JG{q~^fp3|QrZsD9SxXbN$3tzCf@ zS?#t+3L}k?!N_6=7;6~0?#X4~==zDueRuu8m*a7BcU|0GO{FtgVJ$cQv{)*Y@{I?D zQdKX!m;M^RYW-7h!~*e;etnIZ-b~|=F8>+xC-T3<3-RI}mne~qJ?=N0TI_skZ;*L6 zwi8=ST-gIsZq_T E2lsw9UH||9 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..85a633cf1c13e62fdd7d076a855563f5c6f0a205 GIT binary patch literal 3726 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 literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..53b8f9afe0e76e2921447c28317d0bd5fb8d8146 GIT binary patch literal 1315 zcmY*X-BTJ@6hF(igzDw8UQwtaq8QPDZjhu(6NM#y)QV87W!lUN%PKoXc4t|lye*_3 z_91WWTRW47K6Ls7>4&v%=}X!^^)=I(_J7D<&FHX^aG{F0XR!aQ7Z#3%ZIXFtjY@bukwUrP1YqC%WFkZ zDiy^K^G#I(r%q)ZP)5~drBe5AR8+{I7Ue+YBU(c1<&F}2ydrbZ7Dob)r*1>TgS1AwmigFC2zB{J( z!7>*B(?c-TpuK?wT`Sh=NAx0_sLFL+RF2ep@HP>IONfF1NDwqhRj3xdwTM(rQ6)`p z!nL-im1-}yqDp(wFP(@|pLC%&0vP|#epIYBB*3)K#h{?I=c2u3l|KO4dZW89PnAqSS<|u|#MNILr%?dF#j(kHSu7FP#%Mw%<+X z@@XEdga41fXIpbI#;_aq2cb}CD>;|Th?C{O z-gsvY9wW9CWYBOahe#=lMoOIaCDZtQ;gC;x|m?_&!Z&&JHpUh=>6t)Pio zzny3Q`u#k$*$W5FeFtA&_hKu1{XEXlB3+M<4WJu8$JSy|ib&v>>*6COuPKPtY5p)V1bU9k-W_<5}nkZw)-hsJk0wlZ#^eWE4*) zj3C&t8?S19_50=U};@U#!W`5doxV+=(Q6Dw}#e8Tv8AE)VU g@x<+1NEqMlUtq}6E)z~J@xbU{8wJ#jnJ9#R0CaP05dZ)H literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..1f5a473c83552cea17a3e3a6b49be9ec010515f9 GIT binary patch literal 2455 zcmbW&dpy(o9|!QSP`k((R!1%;wP=hoV=Fa$o&g*W{sT-39t>$nO^`mP{}<)l6Vr~kTXs5sBH z_QoL7^2F>|Uv247!M;?8E?!tRTbaNi!@bCVH}T z_vY(RpHKB)RSSHY_i#L7fVJ-QOC4NW-%&`HVqxLvQC zIcGL64K@m+o@ZtucI}9@TAZve$Pk&Z#$U}U9}0c9JHGMdgQuBI7(?AcIj>*vqFTI6_=(#FW~WROAGc!~;kI*6p8@FhzH2(Sh) zRt8Muff@nHtHTEk1IqGTJ%W-_L5gsBuE8B?Fb!OuCA!G*JRGCd)cZ5Lo33kpIziKH zsy9WSm_L>ym=a$za&A9rc;ZI?MXbq2B|tBfkf}mlATN&=+q3O9{V{OP+z`*yV&Zf` z7Ug%@UcncdaNaVXDnI2rA3-9Sn;@Lu?ZCwHTtqScfxB}zau2a$W$IHozOMtD;4*vw zXt`|s27b%hy_q>vwjG(;sbiZ~X77u`cZR;ipQ?Q!$=ClywfT6^bPY#2o8T6Ukn$wimnEk#sr?SgpA+!RcF<6|Jwa4G*V*T>QNZIPca$}ykz7(3y5E3Cg^j(%Nl zur97ub!Oop z-y~=BPjwl_o6pqWSXbaSXLY1I=^N7+aF#Q-e#dP3Z%j0hbWu+sa@&L9t+%t07T3~x zVd4066k0?EF9Z*y!lR_5IASn?wHdmk12&&-V)NIFT~X1^<_&YJ+=PDPradUk)*8P+*ym2+NwLvs)|s9 z^KtBuOQzaCBvdR+xQdA;d!P1EZcIpM87bY`W}&NUXa(TqxXs_;_^WYnppb13KoR%@ zF6ieS89(R~D*P_#bzjs1AAjXHkLb=`ICJhGXJ0Cg2DT`bFz~#CELSg?^jbjOPwNj+j!Ff#eRdWfyehov zESVuOjb1xQjN`v~S{PBL{hJa$joi!eoq9&2QGbfiikloS^F3DXmU$T&UZ$f~;IIky ztz1rjQLdUo+Y~_ENGbNqIENy3B)IU|of#S^EtNKYD3GbA5e=W_Z)?w1M_n_If`xac zqxRs)xyM2eT!2Rz38|QUjw}`MQYTk!x{d9SF|wOZE&*8En7G6SICqHaQAyy;j?8*j z1l0Y5Gebe$&kpZ7yFqrB15_V&ZlLk7VO}DepUt|M#Mc2Wm^ldN@sHbvgvn@Z8dJy# zPS2cFg{xo|`Z5TJQ=LbBYW?ZwYXD^!VMAx-HE!6a!>IIOB zD#j1_A!5cl|3LEc0>?gg(|TzdSgo`t#hSX*2{uQHvmDlG?Rh_3+QvD+6emC^B|wpU zX_*-6@$fui^TB5Eh`iDi=>+5w{5FhF6d7O>Co4| z@_%Exr0MU^hvx`hh_4yitOa?%40_TrCoT)QHNlbs7}d;tnYi7Bt(+krq*n?@I0phC z2@s2Nyn+|+;GCreYVeXNKmHR0`|?5V_E5Sm)8~c1Fs8nR_F;I6B+#y}N<6f2vaOMP zbLk^gv)aCu0M*_b_oi^73yFB2pyHntmd;2nV&v9OizWFssZ}Jk-^C>icMb1~N9b{B z&QQB_nY^^x8}p~Da^!D%-P_44$07#k3Txm(>YHFmo+>rMczIFoUHjEr=2^Ibs-Dnb zK0A;s_i}ROtMclahIm>W@s*#zV9>4->@)YMT2r|p5$ansR*!qw-JIcD9Z(TBbNM6G zGB!|X57mw+pDzsN*lJrRd?atT&to)R7^t9Pj`VJ~NmVs@KaU?qu{;T^-_gJHBp-J7 PK^;Qed!(qi2C)7&&R7+G literal 0 HcmV?d00001 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