Compare commits

...

16 Commits

Author SHA1 Message Date
Adolfo Reyna 8f871e499e Migrate MCP and utilities to CircuitPython, including color GIF and volume fixes 2026-06-22 10:13:10 -04:00
Adolfo Reyna 2863f21459 Document safe iPhone app updates 2026-06-21 21:35:55 -04:00
Adolfo Reyna ad30662a48 Add jailbroken iPhone MCP screen app 2026-06-21 21:27:06 -04:00
aeroreyna 8dbc5f4c7b Add RLCD animations and screensavers 2026-06-21 21:19:51 -04:00
aeroreyna edf3da1a30 Add CircuitPython RLCD migration prototype 2026-06-21 21:14:18 -04:00
Adolfo Reyna 2bc8fbeaca Fix Waveshare RLCD audio playback: initialize ES8311 DAC on audio_start event and track byte counts/errors 2026-06-19 20:52:00 -04:00
Adolfo Reyna 6ee92b6ede Disable WebSocket heartbeat on server and increase client timeout to 30s to resolve connection drops 2026-06-19 20:03:54 -04:00
Adolfo Reyna dc9b47714e Import json module in main.py to fix WebSocket start payload NameError 2026-06-19 19:57:56 -04:00
Adolfo Reyna 9c50ad349e Update main.py to use WebSocket audio streaming automatically at boot 2026-06-19 19:53:21 -04:00
Adolfo Reyna b88cb34196 Implement WebSocket real-time audio streaming for Hermes gateway 2026-06-19 13:58:42 -04:00
Adolfo Reyna b2bcdb5b79 Add high-speed persistent TCP color video stream port (8083) using tight read loop optimization 2026-06-19 13:45:18 -04:00
Adolfo Reyna 30aff5eb8b Optimize draw_rgb565 display write speed by skipping slow synchronous canvas updates during raw video frames 2026-06-19 13:38:25 -04:00
Adolfo Reyna c8853cc5df Fix keyword argument decoding issue on MicroPython and finalize raw image endpoint and clock sync changes 2026-06-19 13:30:26 -04:00
Adolfo Reyna c944d8c48f docs: add websocket audio streaming implementation plan 2026-06-18 23:40:30 -04:00
Adolfo Reyna d778391c5c fix(audio): resolve ES7210 microphone popping sound by correcting OSR and clock division configuration 2026-06-18 23:23:26 -04:00
aeroreyna 9eca3549c3 Add Hermes ESP32 voice gateway implementation 2026-06-18 23:02:54 -04:00
116 changed files with 13322 additions and 214 deletions
+24
View File
@@ -1,3 +1,13 @@
# Local-only CircuitPython configuration / secrets
settings.toml
.env
# Firmware downloads and generated bundles
firmware/
*.bin
*.uf2
*.zip
# Temporary generated media and screenshots
*.pbm
*.png
@@ -8,9 +18,23 @@
__pycache__/
*.pyc
# iPhone app build output
iphone_app/.manual_build/
iphone_app/.manual_pkg/
iphone_app/.theos/
iphone_app/packages/
# OS generated files
.DS_Store
# Log files
desktop_client/*.log
# Helper scripts and datasheets
deploy_main_serial.py
dtr_reset.py
hard_reset.py
pcm2wav.py
plot_audio.py
*.pdf
+8
View File
@@ -2,6 +2,8 @@
This repository contains the MicroPython firmware and host-side bridge script to expose the **Waveshare ESP32-S3-RLCD-4.2** development board as a physical, Model Context Protocol (MCP)-compliant agentic companion.
It also includes a native jailbroken iPhone 6 target in [`iphone_app/`](iphone_app/README.md). The iPhone implementation exposes the same MCP-style screen, audio, camera, file, and monochrome streaming features, plus RGB565 color streaming and Hermes voice turns.
With this setup, local AI harnesses (such as Claude Desktop, OpenClaude, Cursor, or Hermes) can directly control the board's screen, NeoPixels, audio codec, microphone array, sensors, and remote filesystem.
---
@@ -137,3 +139,9 @@ Here is a summary of the MCP tools exposed by the bridge:
| `write_file` | `path`, `content` | Writes text file to board flash. |
| `read_file` | `path` | Reads text file from board flash. |
| `execute_python` | `code` | Executes arbitrary Python code dynamically. |
---
## 5. Hardware & Troubleshooting Findings
For detailed technical findings regarding the board's hardware (e.g. I2S bit-depth configuration, I2C bus lockup recovery, pinout configuration, and ES7210 microphone clock/OSR registers to resolve popping sound issues), refer to [hardware_findings.md](hardware_findings.md).
+83
View File
@@ -0,0 +1,83 @@
# CircuitPython migration prototype for Waveshare ESP32-S3-RLCD-4.2
This folder is an initial CircuitPython port attempt for the existing MicroPython ESP32-S3-RLCD project.
## Goal
Move the board firmware toward CircuitPython so we can use built-in CircuitPython media modules:
- `audiomp3` for MP3 playback over I2S
- `gifio` for frame-by-frame GIF decoding
The current MicroPython project already has working low-level knowledge for:
- ST7305 reflective LCD init and 2x4 packed monochrome framebuffer
- ES8311 speaker DAC over I2C + I2S
- GPIO46 speaker amp enable
- ESP32-S3 board pins
## Current status
This is not a full replacement firmware yet. It is a bootable/iterable prototype layout:
- `code.py` — demo entrypoint for CircuitPython
- `lib/rlcd_cp.py` — custom ST7305/RLCD driver using `busio`/`digitalio`
- `lib/audio_cp.py` — ES8311 + I2S MP3 playback helper
- `lib/gif_player.py` — GIF-to-RLCD playback helper using `gifio`
## Why a custom display driver is needed
Antigravitys review confirmed the important constraint: ST7305/RLCD is not a normal `displayio` panel here. The existing `rlcd.py` packs pixels into 15,000 bytes where each byte represents a 2×4 pixel block, with vertical inversion. CircuitPython does not appear to have a native `displayio` ST7305 driver for this exact layout, so this port keeps a Python-side canvas and manually packs it before SPI transfer.
## Hardware pins copied from the working MicroPython firmware
Display SPI:
- SCK: IO11
- MOSI: IO12
- CS: IO40
- DC: IO5
- RST: IO41
Audio/I2C:
- I2C SDA: IO13
- I2C SCL: IO14
- I2S BCLK: IO9
- I2S LRCK/WS: IO45
- I2S DOUT: IO8
- MCLK PWM: IO16
- Speaker amp enable: IO46
## Copy to CIRCUITPY
After flashing a compatible CircuitPython build for the ESP32-S3 N16R8-style board:
```bash
cp circuitpython/code.py /media/$USER/CIRCUITPY/code.py
mkdir -p /media/$USER/CIRCUITPY/lib
cp circuitpython/lib/*.py /media/$USER/CIRCUITPY/lib/
```
Optional test assets:
```bash
cp demo.mp3 /media/$USER/CIRCUITPY/demo.mp3
cp demo.gif /media/$USER/CIRCUITPY/demo.gif
```
## Expected next hardware tests
1. Boot with only `code.py` and libraries copied.
2. Confirm display init + dashboard text appears.
3. Copy a tiny 320px-wide-or-smaller monochrome/low-color GIF and test GIF playback. `gifio` cannot load 400px-wide GIFs on current CircuitPython builds; the prototype centers 320px GIFs on the 400px screen.
4. Copy a short MP3 and test I2S/ES8311 playback.
5. If display is scrambled, compare `pack()` output against MicroPython `rlcd.py` and host `notetaker.py` mapping.
## Known risks
- CircuitPython may not expose every `board.IO##` name on the selected build. If so, update `PINS` in `code.py` with the names present in `dir(board)`.
- `pwmio.PWMOut` at 12.288 MHz on IO16 may not work on all CircuitPython ESP32-S3 builds. If MCLK fails, MP3 playback may be silent even if I2S is writing.
- Pure Python display packing loops may be slow. This prototype favors correctness first; optimize with lookup tables after the first visible frame works.
- GIF playback on a 1-bit reflective LCD will be low-framerate and monochrome-thresholded.
- CircuitPython SPI configuration is lock-scoped, so `rlcd_cp.py` configures 20 MHz SPI after every lock.
+439
View File
@@ -0,0 +1,439 @@
"""CircuitPython entrypoint for Waveshare ESP32-S3-RLCD-4.2 and Hosyond ESP32-S3 Touchscreen.
Performs I2C scanning to auto-detect the connected board type, dynamically initializes
all peripheral drivers, connects to Wi-Fi, and starts the MCP server and video stream servers.
"""
import gc
import os
import time
import board
import busio
import digitalio
import wifi
import socketpool
import adafruit_requests
# Import drivers
from audio_cp import BoardAudio
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"
def detect_and_init():
global board_type, display, touch, audio, sensor, rtc_chip, battery, led, buttons, ble_uart
print("Scanning for board type...")
# Try Hosyond pins first (SDA=IO16, SCL=IO15)
try:
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'
# 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:
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(board.IO10),
dc=digitalio.DigitalInOut(board.IO46),
bl=board.IO45,
rst=None,
)
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,
)
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():
global ip_addr
detect_and_init()
# Draw initial dashboard
display.clear(0)
display.text("CircuitPython Active", 10, 10, 1)
display.text("Connecting Wi-Fi...", 10, 30, 1)
display.show()
# Sync system clock from RTC chip if available
if rtc_chip:
try:
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:
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
# 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()
+29
View File
@@ -0,0 +1,29 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
CIRCUITPY_MOUNT="${CIRCUITPY_MOUNT:-}"
if [[ -z "$CIRCUITPY_MOUNT" ]]; then
for candidate in \
"/media/$USER/CIRCUITPY" \
"/run/media/$USER/CIRCUITPY" \
"/media/$(id -un)/CIRCUITPY" \
"/Volumes/CIRCUITPY"; do
if [[ -d "$candidate" ]]; then
CIRCUITPY_MOUNT="$candidate"
break
fi
done
fi
if [[ -z "$CIRCUITPY_MOUNT" || ! -d "$CIRCUITPY_MOUNT" ]]; then
echo "CIRCUITPY mount not found. Set CIRCUITPY_MOUNT=/path/to/CIRCUITPY" >&2
exit 1
fi
mkdir -p "$CIRCUITPY_MOUNT/lib"
cp "$ROOT/circuitpython/code.py" "$CIRCUITPY_MOUNT/code.py"
cp "$ROOT/circuitpython/lib/"*.py "$CIRCUITPY_MOUNT/lib/"
cp "$ROOT/circuitpython/lib/"*.mpy "$CIRCUITPY_MOUNT/lib/"
sync
find "$CIRCUITPY_MOUNT" -maxdepth 2 -type f | sort
+74
View File
@@ -0,0 +1,74 @@
#!/usr/bin/env bash
set -euo pipefail
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:-}"
if [[ ! -f "$FIRMWARE" ]]; then
echo "Firmware not found: $FIRMWARE" >&2
exit 1
fi
if [[ ! -r "$PORT" || ! -w "$PORT" ]]; then
cat >&2 <<EOF
Cannot access $PORT as $(id -un).
Temporary fix from a local terminal:
sudo chmod 666 $PORT
Permanent fix:
sudo usermod -aG dialout $(id -un)
# then log out/in, or start a new shell with: newgrp dialout
EOF
exit 2
fi
echo "==> Probing ESP32-S3 on $PORT"
python3 -m esptool --chip esp32s3 --port "$PORT" flash_id
echo "==> Erasing flash"
python3 -m esptool --chip esp32s3 --port "$PORT" erase_flash
echo "==> Flashing CircuitPython N8R8 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
if [[ -n "$CIRCUITPY_MOUNT" && -d "$CIRCUITPY_MOUNT" ]]; then
break
fi
for candidate in \
"/media/$USER/CIRCUITPY" \
"/run/media/$USER/CIRCUITPY" \
"/media/$(id -un)/CIRCUITPY" \
"/Volumes/CIRCUITPY"; do
if [[ -d "$candidate" ]]; then
CIRCUITPY_MOUNT="$candidate"
break 2
fi
done
sleep 1
done
if [[ -z "$CIRCUITPY_MOUNT" || ! -d "$CIRCUITPY_MOUNT" ]]; then
cat >&2 <<EOF
Flashing finished, but CIRCUITPY did not auto-mount.
Reset the board once, then rerun just deployment with:
CIRCUITPY_MOUNT=/path/to/CIRCUITPY bash circuitpython/deploy_only.sh
EOF
exit 3
fi
echo "==> Deploying prototype to $CIRCUITPY_MOUNT"
mkdir -p "$CIRCUITPY_MOUNT/lib"
cp "$ROOT/circuitpython/code.py" "$CIRCUITPY_MOUNT/code.py"
cp "$ROOT/circuitpython/lib/"*.py "$CIRCUITPY_MOUNT/lib/"
cp "$ROOT/circuitpython/lib/"*.mpy "$CIRCUITPY_MOUNT/lib/"
sync
echo "==> Deployed. Files on CIRCUITPY:"
find "$CIRCUITPY_MOUNT" -maxdepth 2 -type f | sort
echo "==> Done. Reset the board if it does not reload automatically."
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
+210
View File
@@ -0,0 +1,210 @@
"""CircuitPython audio helpers for Waveshare ESP32-S3-RLCD-4.2.
Ports the ES8311 setup sequence from MicroPython audio_util.py and adds MP3
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
def __init__(self, i2c):
self.i2c = i2c
def _write(self, register, value):
# CircuitPython I2CDevice is not used to keep this dependency-free.
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, bytes([register & 0xFF, value & 0xFF]))
finally:
self.i2c.unlock()
def init(self, sample_rate=44100):
"""Initialize ES8311 for I2S DAC playback.
The register sequence is the existing MicroPython project's known-good
sequence. The original comments targeted 16 kHz WAV playback, but the
I2S peripheral is configured with the MP3 decoder sample rate at play
time. If MP3 output is silent, hardware-test this sequence first with a
16 kHz WAV/tone and then tune codec dividers for the MP3 sample rate.
"""
del sample_rate # Kept for API clarity; sequence currently fixed.
self._write(0x00, 0x1F)
time.sleep(0.01)
self._write(0x00, 0x00)
time.sleep(0.01)
self._write(0x01, 0x3F)
self._write(0x02, 0x48)
self._write(0x03, 0x10)
self._write(0x04, 0x20)
self._write(0x05, 0x00)
self._write(0x06, 0x03)
self._write(0x07, 0x00)
self._write(0x08, 0xFF)
self._write(0x09, 0x0C)
self._write(0x0A, 0x0C)
self._write(0x0D, 0x01)
self._write(0x0E, 0x02)
self._write(0x12, 0x00)
self._write(0x13, 0x10)
self._write(0x1C, 0x6A)
self._write(0x37, 0x08)
self._write(0x32, 0xBF)
self._write(0x31, 0x00)
self._write(0x00, 0x80)
def set_volume(self, volume):
volume = max(0, min(100, int(volume)))
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, 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=not amp_active_level)
self.codec = ES8311(i2c)
def start_mclk(self):
if self._mclk is None:
# External MCLK for ES8311. CircuitPython I2SOut on ESP32-S3 does
# not accept a main_clock parameter, so keep this independent.
self._mclk = pwmio.PWMOut(
self.mclk_pin,
frequency=12288000,
duty_cycle=32768,
variable_frequency=False,
)
def stop_mclk(self):
if self._mclk is not None:
self._mclk.deinit()
self._mclk = None
def play_mp3(self, filename, volume=60):
"""Play an MP3 file from CIRCUITPY storage over the onboard speaker."""
self.start_mclk()
self.codec.init()
self.codec.set_volume(volume)
decoder = None
audio = None
f = None
try:
f = open(filename, "rb")
decoder = audiomp3.MP3Decoder(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(decoder)
while audio.playing:
time.sleep(0.05)
return True
finally:
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()
def play_wav(self, filename, volume=60):
"""Play a WAV file from CIRCUITPY storage over the onboard speaker."""
self.start_mclk()
self.codec.init()
self.codec.set_volume(volume)
wav = None
audio = None
f = None
try:
f = open(filename, "rb")
wav = audiocore.WaveFile(f)
audio = audiobusio.I2SOut(
bit_clock=self.bit_clock_pin,
word_select=self.word_select_pin,
data=self.data_pin,
)
self._amp.value = self.amp_active_level
audio.play(wav)
while audio.playing:
time.sleep(0.05)
return True
except Exception as e:
print(f"Error playing WAV: {e}")
return False
finally:
self._amp.value = not self.amp_active_level
if audio is not None:
audio.deinit()
if wav is not None:
wav.deinit()
if f is not None:
f.close()
def play_tone(self, frequency=440, duration_ms=1000, volume=50):
"""Generate and play a pure sine wave tone on the speaker."""
self.start_mclk()
self.codec.init()
self.codec.set_volume(volume)
sample_rate = 16000
length = int(sample_rate / frequency)
if length < 4:
length = 4
sine_wave = array.array("h", [0] * length)
for i in range(length):
sine_wave[i] = int(math.sin(2 * math.pi * i / length) * 32767)
sample = audiocore.RawSample(sine_wave, sample_rate=sample_rate)
audio = None
try:
audio = audiobusio.I2SOut(
bit_clock=self.bit_clock_pin,
word_select=self.word_select_pin,
data=self.data_pin,
)
self._amp.value = self.amp_active_level
audio.play(sample, loop=True)
time.sleep(duration_ms / 1000.0)
audio.stop()
return True
except Exception as e:
print(f"Error playing tone: {e}")
return False
finally:
self._amp.value = not self.amp_active_level
if audio is not None:
audio.deinit()
sample.deinit()
+42
View File
@@ -0,0 +1,42 @@
"""CircuitPython Battery Monitor utility.
Wraps analogio.AnalogIn to read voltage via a 2x divider and estimate capacity.
"""
import analogio
import board
class BatteryMonitor:
def __init__(self, pin=board.IO9):
self.adc = analogio.AnalogIn(pin)
def read_voltage(self):
try:
# AnalogIn value is 16-bit (0-65535). Map to 3.3V reference.
# Divider is 2x, so actual voltage is scale * 2.
raw = self.adc.value
voltage = (raw / 65535.0) * 3.3 * 2.0
return round(voltage, 3)
except Exception as e:
print(f"Error reading battery ADC: {e}")
return None
def read_percentage(self):
voltage = self.read_voltage()
if voltage is None:
return 0
v_min = 3.0
v_max = 4.2
if voltage <= v_min:
return 0
if voltage >= v_max:
return 100
pct = (voltage - v_min) / (v_max - v_min) * 100.0
return int(pct)
def get_status_summary(self):
v = self.read_voltage()
p = self.read_percentage()
if v is None:
return "Battery: Error"
return f"Battery: {v:.2f}V ({p}%)"
+101
View File
@@ -0,0 +1,101 @@
"""CircuitPython BLE UART utility.
Wraps the adafruit_ble library to advertise Nordic UART Service and scan for nearby devices.
"""
import time
from adafruit_ble import BLERadio
from adafruit_ble.services.nordic import UARTService
from adafruit_ble.advertising.standard import ProvideServicesAdvertisement
class BLEUART:
def __init__(self, ble=None, name="ESP32-S3-RLCD"):
self.ble = BLERadio()
self.ble.name = name
self.uart = UARTService()
self.advertisement = ProvideServicesAdvertisement(self.uart)
self.rx_callback = None
self._is_advertising = False
self._start_advertise()
def _start_advertise(self):
if not self.ble.connected and not self._is_advertising:
try:
self.ble.start_advertising(self.advertisement)
self._is_advertising = True
print(f"BLE advertising started as '{self.ble.name}'")
except Exception as e:
print(f"Failed to start BLE advertising: {e}")
def on_rx(self, callback):
self.rx_callback = callback
return callback
def update(self):
"""Polls the UART stream for incoming data and triggers callbacks."""
if self.ble.connected:
self._is_advertising = False
try:
if self.uart.in_waiting:
data = self.uart.read(self.uart.in_waiting)
if self.rx_callback and data:
try:
decoded = data.decode("utf-8").strip()
self.rx_callback(decoded)
except:
self.rx_callback(data)
except Exception as e:
print(f"Error reading BLE RX: {e}")
else:
if not self._is_advertising:
self._start_advertise()
def write(self, data):
if not self.ble.connected:
return False
if isinstance(data, str):
data = data.encode("utf-8")
try:
self.uart.write(data)
return True
except Exception as e:
print(f"Error sending BLE data: {e}")
return False
def is_connected(self):
return self.ble.connected
def scan(self, duration_ms=3000):
"""Scans for nearby BLE devices."""
duration_s = duration_ms / 1000.0
results = {}
was_advertising = self._is_advertising
if was_advertising:
try:
self.ble.stop_advertising()
except:
pass
self._is_advertising = False
try:
print("Starting BLE scan...")
for adv in self.ble.start_scan(timeout=duration_s):
# Clean up address representation (mac address)
mac = str(adv.address).replace("Address(", "").replace(")", "").strip()
name = adv.complete_name or ""
results[mac] = {"rssi": adv.rssi, "name": name}
self.ble.stop_scan()
except Exception as e:
print(f"BLE scan error: {e}")
if was_advertising:
self._start_advertise()
return results
def close(self):
try:
self.ble.stop_advertising()
except:
pass
self._is_advertising = False
print("BLE closed.")
+73
View File
@@ -0,0 +1,73 @@
"""CircuitPython polled and debounced Button driver.
Replaces the MicroPython Pin IRQ implementation with active loop polling.
"""
import time
import digitalio
class Button:
def __init__(self, pin_obj, name="Button", debounce_ms=50, long_press_ms=800):
self.pin = digitalio.DigitalInOut(pin_obj)
self.pin.switch_to_input(pull=digitalio.Pull.UP)
self.name = name
self.debounce_s = debounce_ms / 1000.0
self.long_press_s = long_press_ms / 1000.0
self.last_state = True
self.press_time = 0.0
self.last_debounce_time = 0.0
self.click_callback = None
self.long_press_callback = None
def is_pressed(self):
return not self.pin.value
def on_click(self, callback):
self.click_callback = callback
return callback
def on_long_press(self, callback):
self.long_press_callback = callback
return callback
def update(self):
now = time.monotonic()
val = self.pin.value
if (now - self.last_debounce_time) < self.debounce_s:
return
if val != self.last_state:
self.last_debounce_time = now
self.last_state = val
if not val:
# Pressed (Active Low)
self.press_time = now
else:
# Released
if self.press_time > 0.0:
duration = now - self.press_time
self.press_time = 0.0
if duration >= self.long_press_s:
if self.long_press_callback:
self.long_press_callback()
else:
if self.click_callback:
self.click_callback()
class BoardButtons:
def __init__(self, boot_pin, key_pin=None):
self.boot = Button(boot_pin, "BOOT")
if key_pin is not None:
self.key = Button(key_pin, "KEY")
else:
self.key = None
def update(self):
self.boot.update()
if self.key is not None:
self.key.update()
+66
View File
@@ -0,0 +1,66 @@
"""CircuitPython file download utility.
Downloads files over Wi-Fi in chunks to local flash or microSD card storage.
"""
import os
from sd_cp import SDCardManager
def download_file(url, dest_filename, session, use_sd=True):
"""Downloads a file from a URL over the network.
Args:
url (str): Source URL.
dest_filename (str): Target filename.
session (Session): adafruit_requests Session object.
use_sd (bool): Save to microSD card if True, else local flash.
"""
dest_path = dest_filename
sd_manager = None
if use_sd:
sd_manager = SDCardManager(mount_point='/sd')
if not sd_manager.mount():
print("Download Error: Could not mount SD card.")
return None
dest_path = f"/sd/{dest_filename}"
print(f"Starting download from: {url} -> {dest_path}")
try:
res = session.get(url)
except Exception as e:
print(f"HTTP Connection failed: {e}")
return None
if res.status_code != 200:
print(f"HTTP Error: Received status code {res.status_code}")
res.close()
return None
try:
chunk_size = 4096
total_downloaded = 0
with open(dest_path, 'wb') as f:
for chunk in res.iter_content(chunk_size):
if not chunk:
break
f.write(chunk)
total_downloaded += len(chunk)
if total_downloaded % (chunk_size * 25) == 0:
print(f"Downloaded {total_downloaded // 1024} KB...")
print(f"Download complete! Saved {total_downloaded} bytes to '{dest_path}'.")
return dest_path
except Exception as e:
print(f"Error writing to file: {e}")
try:
os.remove(dest_path)
except:
pass
return None
finally:
res.close()
+144
View File
@@ -0,0 +1,144 @@
"""CircuitPython FT6336U touch controller driver.
Ports the MicroPython ft6336u.py driver using busio.I2C and digitalio.DigitalInOut.
"""
import time
class FT6336U:
ADDR = 0x38
# Registers
REG_DEV_MODE = 0x00
REG_TD_STATUS = 0x02
REG_P1_XH = 0x03
REG_P1_XL = 0x04
REG_P1_YH = 0x05
REG_P1_YL = 0x06
REG_CTRL = 0x86
REG_CHIPID = 0xA3
REG_G_MODE = 0xA4
# Modes
CTRL_KEEP_ACTIVE = 0x00
G_MODE_TRIGGER = 0x01
def __init__(self, i2c, rst_pin, int_pin, width=480, height=320, swap_xy=True, invert_x=True, invert_y=False):
self.i2c = i2c
self.rst = rst_pin
self.int = int_pin
self.width = width
self.height = height
self.swap_xy = swap_xy
self.invert_x = invert_x
self.invert_y = invert_y
self.initialized = False
# Configure reset and interrupt pins
self.rst.switch_to_output(value=True)
self.int.switch_to_input(pull=None) # Typically pulled up externally or on-board
self.reset()
self.init_chip()
def reset(self):
self.rst.value = False
time.sleep(0.010)
self.rst.value = True
time.sleep(0.300) # Wait for chip to wake up
def read_reg(self, reg, n=1):
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, bytes([reg]))
buf = bytearray(n)
self.i2c.readfrom_into(self.ADDR, buf)
return buf
except Exception as e:
print(f"I2C read failed at reg 0x{reg:02X}: {e}")
return None
finally:
self.i2c.unlock()
def write_reg(self, reg, val):
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, bytes([reg, val]))
return True
except Exception as e:
print(f"I2C write failed at reg 0x{reg:02X}: {e}")
return False
finally:
self.i2c.unlock()
def init_chip(self):
# 1. Read Chip ID
chip_id = self.read_reg(self.REG_CHIPID)
if chip_id is None or chip_id[0] != 0x64:
time.sleep(0.100)
chip_id = self.read_reg(self.REG_CHIPID)
if chip_id is None or chip_id[0] != 0x64:
print(f"FT6336U error: Invalid Chip ID (got {chip_id[0] if chip_id else None}, expected 0x64)")
self.initialized = False
return False
print(f"FT6336U touch controller detected (Chip ID: 0x{chip_id[0]:02X})")
# 2. Configure operating mode (0 = Normal Mode)
self.write_reg(self.REG_DEV_MODE, 0x00)
# 3. Configure CTRL mode (0 = Keep Active)
self.write_reg(self.REG_CTRL, self.CTRL_KEEP_ACTIVE)
self.initialized = True
return True
def is_touched(self):
"""Returns True if screen is touched by checking the TD_STATUS register."""
if not self.initialized:
return False
td_status = self.read_reg(self.REG_TD_STATUS)
if td_status is None:
return False
touch_count = td_status[0] & 0x0F
if 0 < touch_count < 3:
# Read P1 coordinates to clear register/interrupt state on the chip
self.read_reg(self.REG_P1_XH, 6)
return True
return False
def read_touch(self):
"""Reads touch point coordinates."""
if not self.initialized:
return None
td_status = self.read_reg(self.REG_TD_STATUS)
if td_status is None:
return None
touch_count = td_status[0] & 0x0F
if touch_count == 0:
return None
buf = self.read_reg(self.REG_P1_XH, 6)
if buf is None or len(buf) < 6:
return None
raw_x = ((buf[0] & 0x0F) << 8) | buf[1]
raw_y = ((buf[2] & 0x0F) << 8) | buf[3]
if self.swap_xy:
raw_x, raw_y = raw_y, raw_x
if self.invert_x:
raw_x = self.width - 1 - raw_x
if self.invert_y:
raw_y = self.height - 1 - raw_y
x = max(0, min(self.width - 1, raw_x))
y = max(0, min(self.height - 1, raw_y))
return (x, y)
+55
View File
@@ -0,0 +1,55 @@
"""GIF playback helper for the custom RLCD CircuitPython driver."""
import gc
import time
import gifio
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, 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
x defaults to 40 to center a 320-wide GIF on this 400-wide display.
The ST7305/RLCD display is monochrome in this project, so frames are
thresholded from palette indexes: palette index 0 is white; any index
>= threshold is black.
"""
gif = gifio.OnDiskGif(filename)
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)
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))
count += 1
if loops < 0 or count < loops:
# OnDiskGif has no reliable seek/rewind API; reopen to loop.
gif.deinit()
gc.collect()
gif = gifio.OnDiskGif(filename)
finally:
try:
gif.deinit()
except AttributeError:
pass
gc.collect()
+481
View File
@@ -0,0 +1,481 @@
"""CircuitPython ILI9341 driver for Hosyond ESP32-S3 Touchscreen board.
This driver wraps adafruit_framebuf using a 1-bit MONO_HLSB canvas buffer,
then converts it row-by-row to 16-bit RGB565 via a lookup table (LUT) during show().
"""
import time
try:
import adafruit_framebuf
except ImportError:
adafruit_framebuf = None
class ILI9341:
WIDTH = 320
HEIGHT = 240
def __init__(self, spi, cs, dc, rst=None, bl=None, width=WIDTH, height=HEIGHT, invert_color=True):
if adafruit_framebuf is None:
raise RuntimeError("adafruit_framebuf is required in CIRCUITPY/lib")
self.spi = spi
self.cs = cs
self.dc = dc
self.rst = rst
self.width = width
self.height = height
self.invert_color = invert_color
# 1-bit canvas buffer (1 = White/On, 0 = Black/Off)
self.hw_len = (width * height) // 8
self.canvas_buffer = bytearray(self.hw_len)
self.canvas = adafruit_framebuf.FrameBuffer(
self.canvas_buffer,
width,
height,
adafruit_framebuf.MHMSB, # Matches MONO_HLSB (most significant bit first)
)
# Pre-allocate chunk buffer for conversion (16 rows: 320 * 16 * 2 = 10,240 bytes)
self.chunk_rows = 16
self.row_buffer = bytearray(width * self.chunk_rows * 2)
# Precompute lookup table for fast 1-bit to 16-bit conversion
# Each byte (8 pixels) maps to 16 bytes of RGB565 (8 pixels * 2 bytes)
self.lut = []
for i in range(256):
entry = bytearray(16)
for bit in range(8):
if i & (1 << (7 - bit)):
# White pixel: 0xFFFF (High byte: 0xFF, Low byte: 0xFF)
entry[bit * 2] = 0xFF
entry[bit * 2 + 1] = 0xFF
else:
# Black pixel: 0x0000
entry[bit * 2] = 0x00
entry[bit * 2 + 1] = 0x00
self.lut.append(bytes(entry))
# Setup CS and DC
self.cs.switch_to_output(value=True)
self.dc.switch_to_output(value=False)
# Setup Reset if present
if self.rst is not None:
self.rst.switch_to_output(value=True)
# Setup Backlight PWM if present
if bl is not None:
import pwmio
self.bl_pwm = pwmio.PWMOut(bl, frequency=1000, duty_cycle=65535)
else:
self.bl_pwm = None
self.reset()
self.init_display()
self.clear(0)
self.show()
def reset(self):
if self.rst is not None:
self.rst.value = True
time.sleep(0.005)
self.rst.value = False
time.sleep(0.015)
self.rst.value = True
time.sleep(0.015)
else:
# Software reset command if no reset pin
self.write_cmd(0x01)
time.sleep(0.150)
def _lock_spi(self):
while not self.spi.try_lock():
pass
self.spi.configure(baudrate=40000000, phase=0, polarity=0)
def _unlock_spi(self):
self.spi.unlock()
def write_cmd(self, cmd):
self._lock_spi()
try:
self.cs.value = False
self.dc.value = False
self.spi.write(bytes([cmd & 0xFF]))
self.cs.value = True
finally:
self._unlock_spi()
def write_data(self, data):
if isinstance(data, int):
payload = bytes([data & 0xFF])
elif isinstance(data, (bytes, bytearray, memoryview)):
payload = data
else:
payload = bytes(data)
self._lock_spi()
try:
self.cs.value = False
self.dc.value = True
self.spi.write(payload)
self.cs.value = True
finally:
self._unlock_spi()
def init_display(self):
# SWRESET
self.write_cmd(0x01)
time.sleep(0.150)
self.write_cmd(0xCF); self.write_data(b"\x00\xC1\x30")
self.write_cmd(0xED); self.write_data(b"\x64\x03\x12\x81")
self.write_cmd(0xE8); self.write_data(b"\x85\x00\x78")
self.write_cmd(0xCB); self.write_data(b"\x39\x2C\x00\x34\x02")
self.write_cmd(0xF7); self.write_data(b"\x20")
self.write_cmd(0xEA); self.write_data(b"\x00\x00")
self.write_cmd(0xC0); self.write_data(b"\x13") # Power Control 1
self.write_cmd(0xC1); self.write_data(b"\x13") # Power Control 2
self.write_cmd(0xC5); self.write_data(b"\x22\x35") # VCOM Control 1
self.write_cmd(0xC7); self.write_data(b"\xBD") # VCOM Control 2
# Memory Access Control (MADCTL) = 0x68 (Landscape: MV=1, MX=1, MY=0, BGR color filter)
self.write_cmd(0x36); self.write_data(b"\x68")
self.write_cmd(0xB6); self.write_data(b"\x0A\xA2") # Display Function Control
self.write_cmd(0x3A); self.write_data(b"\x55") # Pixel Format (COLMOD) = 16-bit RGB565
self.write_cmd(0xF6); self.write_data(b"\x01\x30")
self.write_cmd(0xB1); self.write_data(b"\x00\x1B") # Frame Rate Control
self.write_cmd(0xF2); self.write_data(b"\x00")
self.write_cmd(0x26); self.write_data(b"\x01") # Gamma Curve
self.write_cmd(0xE0); self.write_data(b"\x0F\x35\x31\x0B\x0E\x06\x49\xA7\x33\x07\x0F\x03\x0C\x0A\x00")
self.write_cmd(0xE1); self.write_data(b"\x00\x0A\x0F\x04\x11\x08\x36\x58\x4D\x07\x10\x0C\x32\x34\x0F")
if self.invert_color:
self.write_cmd(0x21) # INVON
else:
self.write_cmd(0x20) # INVOFF
self.write_cmd(0x11) # SLPOUT
time.sleep(0.120)
self.write_cmd(0x29) # DISPON
time.sleep(0.010)
def invert(self, enable):
self.write_cmd(0x21 if enable else 0x20)
def set_window(self, x0, y0, x1, y1):
self.write_cmd(0x2A)
self.write_data(bytes([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF]))
self.write_cmd(0x2B)
self.write_data(bytes([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF]))
self.write_cmd(0x2C)
def clear(self, color=0):
self.canvas.fill(1 if color else 0)
def pixel(self, x, y, color):
self.canvas.pixel(x, y, 1 if color else 0)
def line(self, x0, y0, x1, y1, color):
self.canvas.line(x0, y0, x1, y1, 1 if color else 0)
def rect(self, x, y, width, height, color):
self.canvas.rect(x, y, width, height, 1 if color else 0)
def fill_rect(self, x, y, width, height, color):
self.canvas.fill_rect(x, y, width, height, 1 if color else 0)
def text(self, text, x, y, color=1):
self.canvas.text(str(text), x, y, 1 if color else 0)
def text_large(self, text, x, y, scale=2, color=1):
tmp = bytearray(8)
fb = adafruit_framebuf.FrameBuffer(tmp, 8, 8, adafruit_framebuf.MHMSB)
color = 1 if color else 0
for ch in str(text):
fb.fill(0)
fb.text(ch, 0, 0, 1)
for py in range(8):
for px in range(8):
if fb.pixel(px, py):
self.canvas.fill_rect(x + px * scale, y + py * scale, scale, scale, color)
x += 8 * scale
def draw_bitmap_threshold(self, bitmap, x=0, y=0, threshold=1):
width = min(getattr(bitmap, "width", self.width), self.width - x)
height = min(getattr(bitmap, "height", self.height), self.height - y)
for yy in range(height):
for xx in range(width):
self.canvas.pixel(x + xx, y + yy, 1 if bitmap[xx, yy] >= threshold else 0)
def draw_bitmap_color(self, bitmap, palette, x=0, y=0):
width = min(getattr(bitmap, "width", self.width), self.width - x)
height = min(getattr(bitmap, "height", self.height), self.height - y)
row_buf = bytearray(width * 2)
for yy in range(height):
idx = 0
for xx in range(width):
val = bitmap[xx, yy]
if palette is None:
rgb = val
else:
color = palette[val]
if isinstance(color, tuple) or isinstance(color, list):
r, g, b = color[0], color[1], color[2]
elif isinstance(color, int):
r = (color >> 16) & 0xFF
g = (color >> 8) & 0xFF
b = color & 0xFF
else:
r, g, b = 0, 0, 0
r5 = r >> 3
g6 = g >> 2
b5 = b >> 3
rgb = (r5 << 11) | (g6 << 5) | b5
row_buf[idx] = (rgb >> 8) & 0xFF
row_buf[idx + 1] = rgb & 0xFF
idx += 2
self.draw_rgb565(x, yy + y, width, 1, row_buf, sync_canvas=False)
def show(self):
"""Optimized conversion of 1-bit frame buffer to 16-bit RGB565 over SPI."""
self.set_window(0, 0, self.width - 1, self.height - 1)
self.dc.value = True
self.cs.value = False
lut = self.lut
canvas_buf = self.canvas_buffer
row_buf = self.row_buffer
width_bytes = self.width // 8 # 40 bytes per row
num_chunks = self.height // self.chunk_rows # 240 // 16 = 15 chunks
for chunk in range(num_chunks):
start_row = chunk * self.chunk_rows
idx = 0
# Loop for 16 rows * 40 bytes/row = 640 bytes. Slice assignment maps directly to LUT.
for y in range(start_row, start_row + self.chunk_rows):
offset = y * width_bytes
for x_byte_idx in range(width_bytes):
val = canvas_buf[offset + x_byte_idx]
row_buf[idx : idx + 16] = lut[val]
idx += 16
self._lock_spi()
try:
self.spi.write(row_buf)
finally:
self._unlock_spi()
self.cs.value = True
def set_brightness(self, level):
if self.bl_pwm is not None:
level = max(0, min(100, level))
self.bl_pwm.duty_cycle = int(level * 65535 / 100)
def set_power(self, on):
if on:
self.write_cmd(0x11) # SLPOUT
time.sleep(0.120)
self.write_cmd(0x29) # DISPON
if self.bl_pwm is not None:
self.bl_pwm.duty_cycle = 65535
else:
self.write_cmd(0x28) # DISPOFF
self.write_cmd(0x10) # SLPIN
time.sleep(0.010)
if self.bl_pwm is not None:
self.bl_pwm.duty_cycle = 0
def _update_mono_canvas_rgb565(self, x, y, w, h, data):
for cy in range(h):
screen_y = y + cy
if screen_y < 0 or screen_y >= self.height:
continue
for cx in range(w):
screen_x = x + cx
if screen_x < 0 or screen_x >= self.width:
continue
idx = (cy * w + cx) * 2
h_byte = data[idx]
l_byte = data[idx + 1]
# Extract RGB from RGB565
r = (h_byte & 0xF8)
g = ((h_byte & 0x07) << 5) | ((l_byte & 0xE0) >> 3)
b = (l_byte & 0x1F) << 3
# Convert to luminance
lum = (r * 299 + g * 587 + b * 114) // 1000
mono = 1 if lum >= 128 else 0
self.canvas.pixel(screen_x, screen_y, mono)
def draw_rgb565(self, x, y, w, h, data, sync_canvas=True):
"""Draw raw RGB565 pixel data on the screen at specified (x,y) with width and height."""
# Clip coordinates
x_start = max(0, x)
x_end = min(self.width - 1, x + w - 1)
y_start = max(0, y)
y_end = min(self.height - 1, y + h - 1)
if x_start > x_end or y_start > y_end:
return True
# Fast path: if completely visible on screen, draw in one go
if x_start == x and x_end == x + w - 1 and y_start == y and y_end == y + h - 1:
self.set_window(x_start, y_start, x_end, y_end)
self.dc.value = True
self.cs.value = False
self._lock_spi()
try:
self.spi.write(data)
finally:
self._unlock_spi()
self.cs.value = True
else:
# Slow path: row-by-row clipping
for cy in range(y_start, y_end + 1):
src_y = cy - y
src_row_offset = (src_y * w + (x_start - x)) * 2
row_len_bytes = (x_end - x_start + 1) * 2
self.set_window(x_start, cy, x_end, cy)
self.dc.value = True
self.cs.value = False
self._lock_spi()
try:
self.spi.write(memoryview(data)[src_row_offset : src_row_offset + row_len_bytes])
finally:
self._unlock_spi()
self.cs.value = True
# Sync the internal 1-bit canvas buffer
if sync_canvas:
self._update_mono_canvas_rgb565(x, y, w, h, data)
return True
def _convert_bgr24_to_rgb565(self, bgr_buf, rgb565_buf, width, src_offset, num_pixels):
idx = 0
for i in range(src_offset, src_offset + num_pixels):
b = bgr_buf[i * 3]
g = bgr_buf[i * 3 + 1]
r = bgr_buf[i * 3 + 2]
r_5 = r >> 3
g_6 = g >> 2
b_5 = b >> 3
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
idx += 2
def _convert_bgra32_to_rgb565(self, bgra_buf, rgb565_buf, width, src_offset, num_pixels):
idx = 0
for i in range(src_offset, src_offset + num_pixels):
b = bgra_buf[i * 4]
g = bgra_buf[i * 4 + 1]
r = bgra_buf[i * 4 + 2]
r_5 = r >> 3
g_6 = g >> 2
b_5 = b >> 3
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
idx += 2
def draw_bmp(self, filename, x=0, y=0):
import struct
try:
with open(filename, 'rb') as f:
header = f.read(54)
if len(header) < 54 or header[0:2] != b'BM':
print("Err: Not a valid BMP file")
return False
pixel_offset = struct.unpack('<I', header[10:14])[0]
width, height = struct.unpack('<ii', header[18:26])
planes, bpp = struct.unpack('<HH', header[26:30])
compression = struct.unpack('<I', header[30:34])[0]
if bpp not in (24, 32):
print("Err: Only 24-bit and 32-bit BMP formats supported")
return False
if compression != 0:
print("Err: Only uncompressed BMP supported")
return False
f.seek(pixel_offset)
bottom_up = True
if height < 0:
height = -height
bottom_up = False
row_bytes = (width * bpp) // 8
row_padded = ((width * bpp + 31) // 32) * 4
read_buf = bytearray(row_padded)
rgb565_buf = bytearray(width * 2)
for row_idx in range(height):
n = f.readinto(read_buf)
if n < row_padded:
break
screen_y = y + (height - 1 - row_idx) if bottom_up else y + row_idx
if screen_y < 0 or screen_y >= self.height:
continue
x_start = x
x_end = x + width - 1
if x_start >= self.width or x_end < 0:
continue
win_x0 = max(0, x_start)
win_x1 = min(self.width - 1, x_end)
if win_x1 < win_x0:
continue
src_offset_pixels = win_x0 - x_start
win_w = win_x1 - win_x0 + 1
# Convert pixel data to RGB565 row buffer
if bpp == 24:
self._convert_bgr24_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
elif bpp == 32:
self._convert_bgra32_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
# Draw directly to the screen via SPI window
self.set_window(win_x0, screen_y, win_x1, screen_y)
self.dc.value = True
self.cs.value = False
self._lock_spi()
try:
self.spi.write(memoryview(rgb565_buf)[:win_w * 2])
finally:
self._unlock_spi()
self.cs.value = True
# Also update internal 1-bit canvas buffer for screenshots/refresh consistency
for px in range(win_w):
screen_x = win_x0 + px
src_px = src_offset_pixels + px
if bpp == 24:
b = read_buf[src_px * 3]
g = read_buf[src_px * 3 + 1]
r = read_buf[src_px * 3 + 2]
else:
b = read_buf[src_px * 4]
g = read_buf[src_px * 4 + 1]
r = read_buf[src_px * 4 + 2]
# 0 = Black, 1 = White in conversion for MONO_HLSB canvas
lum = (r * 299 + g * 587 + b * 114) // 1000
mono_c = 1 if lum >= 128 else 0
self.canvas.pixel(screen_x, screen_y, mono_c)
return True
except Exception as e:
print("Error drawing BMP:", e)
return False
File diff suppressed because it is too large Load Diff
Binary file not shown.
+43
View File
@@ -0,0 +1,43 @@
"""CircuitPython NeoPixel LED utility.
Uses the neopixel library and time.monotonic() to run breathing and rainbow animations.
"""
import math
import time
import neopixel
class BoardLED:
def __init__(self, pin):
# Initialize 1 NeoPixel on the specified pin
self.np = neopixel.NeoPixel(pin, 1, brightness=1.0, auto_write=False)
self.base_color = (0, 0, 0)
self.off()
def set_color(self, r, g, b):
self.base_color = (r, g, b)
self.np[0] = (r, g, b)
self.np.show()
def off(self):
self.set_color(0, 0, 0)
def update_breathing(self, speed_factor=1.0):
if self.base_color == (0, 0, 0):
return
t = time.monotonic() * speed_factor
# Sine wave from 0.05 to 1.0
factor = 0.525 + 0.475 * math.sin(t * math.pi)
br = int(self.base_color[0] * factor)
bg = int(self.base_color[1] * factor)
bb = int(self.base_color[2] * factor)
self.np[0] = (br, bg, bb)
self.np.show()
def update_rainbow(self, speed_factor=0.2):
t = time.monotonic() * speed_factor
r = int(127.5 * (1.0 + math.sin(t * 2.0 * math.pi)))
g = int(127.5 * (1.0 + math.sin(t * 2.0 * math.pi + 2.0 * math.pi / 3.0)))
b = int(127.5 * (1.0 + math.sin(t * 2.0 * math.pi + 4.0 * math.pi / 3.0)))
self.np[0] = (r, g, b)
self.np.show()
+199
View File
@@ -0,0 +1,199 @@
"""CircuitPython ST7305/RLCD driver for Waveshare ESP32-S3-RLCD-4.2.
This is a direct migration attempt from the project's MicroPython rlcd.py.
It intentionally does not use displayio's display bus because the panel uses a
non-standard 2x4 packed 1-bit memory layout.
"""
import time
try:
import adafruit_framebuf
except ImportError: # CircuitPython bundle dependency.
adafruit_framebuf = None
class RLCD:
WIDTH = 400
HEIGHT = 300
BUFFER_SIZE = (WIDTH * HEIGHT) // 8
def __init__(self, spi, cs, dc, rst, width=WIDTH, height=HEIGHT):
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.hw_buffer = bytearray((width * height) // 8)
self.canvas_buffer = bytearray((width * height) // 8)
self.canvas = adafruit_framebuf.FrameBuffer(
self.canvas_buffer,
width,
height,
adafruit_framebuf.MHMSB,
)
self.cs.switch_to_output(value=True)
self.dc.switch_to_output(value=False)
self.rst.switch_to_output(value=True)
self.reset()
self.init_display()
def reset(self):
self.rst.value = True
time.sleep(0.05)
self.rst.value = False
time.sleep(0.02)
self.rst.value = True
time.sleep(0.05)
def _lock_spi(self):
while not self.spi.try_lock():
pass
# CircuitPython SPI settings are only guaranteed while the bus is
# locked, so configure after every lock instead of only at startup.
self.spi.configure(baudrate=20000000, 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):
# Ported from MicroPython rlcd.py. The old file had one decimal 19 in
# the 0xC5 payload; this port uses 0x19 consistently.
self.write_cmd(0xD6); self.write_data([0x17, 0x02])
self.write_cmd(0xD1); self.write_data(0x01)
self.write_cmd(0xC0); self.write_data([0x11, 0x04])
self.write_cmd(0xC1); self.write_data([0x41, 0x41, 0x41, 0x41])
self.write_cmd(0xC2); self.write_data([0x19, 0x19, 0x19, 0x19])
self.write_cmd(0xC4); self.write_data([0x41, 0x41, 0x41, 0x41])
self.write_cmd(0xC5); self.write_data([0x19, 0x19, 0x19, 0x19])
self.write_cmd(0xD8); self.write_data([0xA6, 0xE9])
self.write_cmd(0xB2); self.write_data(0x05)
self.write_cmd(0xB3); self.write_data([0xE5, 0xF6, 0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45])
self.write_cmd(0xB4); self.write_data([0x05, 0x46, 0x77, 0x77, 0x77, 0x77, 0x76, 0x45])
self.write_cmd(0x62); self.write_data([0x32, 0x03, 0x1F])
self.write_cmd(0xB7); self.write_data(0x13)
self.write_cmd(0xB0); self.write_data(0x64)
self.write_cmd(0x11)
time.sleep(0.2)
self.write_cmd(0xC9); self.write_data(0x00)
self.write_cmd(0x36); self.write_data(0x48)
self.write_cmd(0x3A); self.write_data(0x11)
self.write_cmd(0xB9); self.write_data(0x20)
self.write_cmd(0xB8); self.write_data(0x29)
self.write_cmd(0x21)
self.write_cmd(0x2A); self.write_data([0x12, 0x2A])
self.write_cmd(0x2B); self.write_data([0x00, 0xC7])
self.write_cmd(0x35); self.write_data(0x00)
self.write_cmd(0xD0); self.write_data(0xFF)
self.write_cmd(0x38)
self.write_cmd(0x29)
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):
# Simple nearest-neighbor scaled 8x8 font rendering using a temp buffer.
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):
"""Copy any indexable bitmap-like object to the 1-bit canvas.
gifio/displayio bitmaps return palette indexes. Treat index 0 as white
and anything >= threshold as black by default.
"""
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 pack(self):
"""Convert standard horizontal 1-bit canvas into the RLCD 2x4 layout."""
buf = self.hw_buffer
for i in range(len(buf)):
buf[i] = 0
# Correctness-first port of MicroPython native loop.
height = self.height
for y in range(height):
inv_y = height - 1 - y
block_y = inv_y // 4
local_y = inv_y & 0x03
local_y_shift = local_y * 2
row_offset = block_y
for x in range(self.width):
if self.canvas.pixel(x, y):
byte_x = x >> 1
index = byte_x * 75 + row_offset
local_x = x & 0x01
bit = 7 - (local_y_shift + local_x)
buf[index] |= 1 << bit
return buf
def show(self):
self.pack()
self.write_cmd(0x2A)
self.write_data([0x12, 0x2A])
self.write_cmd(0x2B)
self.write_data([0x00, 0xC7])
self.write_cmd(0x2C)
self.write_data(self.hw_buffer)
def invert(self, enable=True):
self.write_cmd(0x21 if enable else 0x20)
+132
View File
@@ -0,0 +1,132 @@
"""CircuitPython PCF85063 Real-Time Clock (RTC) driver.
Synchronizes the hardware RTC with CircuitPython's native rtc.RTC() system clock.
"""
import rtc
import time
class PCF85063:
ADDR = 0x51
TIME_REG_START = 0x04
def __init__(self, i2c):
self.i2c = i2c
self._init_rtc()
def read_reg(self, reg, n=1):
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, bytes([reg]))
buf = bytearray(n)
self.i2c.readfrom_into(self.ADDR, buf)
return buf
except Exception as e:
print(f"RTC read failed at reg 0x{reg:02X}: {e}")
return None
finally:
self.i2c.unlock()
def write_reg(self, reg, data):
while not self.i2c.try_lock():
pass
try:
payload = bytearray([reg])
if isinstance(data, (bytes, bytearray, list)):
payload.extend(data)
else:
payload.append(data)
self.i2c.writeto(self.ADDR, payload)
return True
except Exception as e:
print(f"RTC write failed at reg 0x{reg:02X}: {e}")
return False
finally:
self.i2c.unlock()
def _init_rtc(self):
ctrl1 = self.read_reg(0x00, 1)
if ctrl1 is not None and (ctrl1[0] & 0x20):
print("RTC oscillator was stopped. Starting oscillator...")
self.write_reg(0x00, 0x00)
def _dec2bcd(self, val):
return (val // 10 << 4) | (val % 10)
def _bcd2dec(self, val):
return ((val >> 4) * 10) + (val & 0x0F)
def get_datetime(self):
"""Reads current time from hardware RTC.
Returns:
tuple: (year, month, day, weekday, hour, minute, second) or None on error.
"""
data = self.read_reg(self.TIME_REG_START, 7)
if data is None:
return None
second = self._bcd2dec(data[0] & 0x7F)
minute = self._bcd2dec(data[1] & 0x7F)
hour = self._bcd2dec(data[2] & 0x3F)
day = self._bcd2dec(data[3] & 0x3F)
weekday = data[4] & 0x07
month = self._bcd2dec(data[5] & 0x1F)
year = 2000 + self._bcd2dec(data[6])
return (year, month, day, weekday, hour, minute, second)
def set_datetime(self, dt):
"""Sets the hardware RTC time.
Args:
dt (tuple): (year, month, day, weekday, hour, minute, second)
"""
try:
year, month, day, weekday, hour, minute, second = dt
reg_year = year % 100
data = bytearray(7)
data[0] = self._dec2bcd(second) & 0x7F
data[1] = self._dec2bcd(minute)
data[2] = self._dec2bcd(hour)
data[3] = self._dec2bcd(day)
data[4] = weekday & 0x07
data[5] = self._dec2bcd(month)
data[6] = self._dec2bcd(reg_year)
return self.write_reg(self.TIME_REG_START, data)
except Exception as e:
print(f"Error setting PCF85063 RTC: {e}")
return False
def sync_to_system(self):
"""Synchronizes the CircuitPython system time from the hardware RTC."""
dt = self.get_datetime()
if dt:
year, month, day, weekday, hour, minute, second = dt
r = rtc.RTC()
r.datetime = time.struct_time((year, month, day, hour, minute, second, weekday, -1, -1))
print(f"System clock synced to RTC: {year:04d}-{month:02d}-{day:02d} {hour:02d}:{minute:02d}:{second:02d}")
return True
return False
def sync_from_system(self):
"""Synchronizes the hardware RTC time from the system clock."""
try:
t = time.localtime()
dt = (t.tm_year, t.tm_mon, t.tm_mday, t.tm_wday, t.tm_hour, t.tm_min, t.tm_sec)
success = self.set_datetime(dt)
if success:
print(f"RTC synced from System: {t.tm_year:04d}-{t.tm_mon:02d}-{t.tm_mday:02d} {t.tm_hour:02d}:{t.tm_min:02d}:{t.tm_sec:02d}")
return success
except Exception as e:
print(f"Error syncing RTC from system: {e}")
return False
def get_time_string(self):
dt = self.get_datetime()
if dt:
return f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d} {dt[4]:02d}:{dt[5]:02d}:{dt[6]:02d}"
return "0000-00-00 00:00:00"
+85
View File
@@ -0,0 +1,85 @@
"""CircuitPython SD card utility using sdioio and storage.
Uses SDMMC 1-bit mode on Pins: sck=board.IO2, cmd=board.IO1, data0=board.IO3.
"""
import os
import board
import sdioio
import storage
class SDCardManager:
def __init__(self, mount_point='/sd'):
self.mount_point = mount_point
self.sd = None
self.mounted = False
def mount(self):
if self.mounted:
print(f"SD card already mounted at {self.mount_point}")
return True
try:
print("Initializing SDCard (SDMMC 1-bit mode: sck=2, cmd=1, d0=3)...")
self.sd = sdioio.SDCard(clock=board.IO2, command=board.IO1, data=[board.IO3], frequency=20000000)
vfs = storage.VfsFat(self.sd)
print(f"Mounting SD card to {self.mount_point}...")
storage.mount(vfs, self.mount_point)
self.mounted = True
print("SD card mounted successfully!")
return True
except Exception as e:
print(f"Failed to mount SD card: {e}")
self.sd = None
self.mounted = False
return False
def unmount(self):
if not self.mounted:
return True
try:
print(f"Unmounting SD card from {self.mount_point}...")
storage.umount(self.mount_point)
if self.sd:
try:
self.sd.deinit()
except:
pass
self.mounted = False
self.sd = None
print("SD card unmounted.")
return True
except Exception as e:
print(f"Failed to unmount SD card: {e}")
return False
def is_mounted(self):
return self.mounted
def list_files(self):
if not self.mounted:
print("SD card is not mounted.")
return None
try:
return os.listdir(self.mount_point)
except Exception as e:
print(f"Error listing SD card files: {e}")
return None
def get_info(self):
if not self.mounted:
return None
try:
stat = os.statvfs(self.mount_point)
block_size = stat[0]
total_blocks = stat[2]
free_blocks = stat[3]
return {
"total_bytes": total_blocks * block_size,
"free_bytes": free_blocks * block_size
}
except Exception as e:
print(f"Error getting SD card info: {e}")
return None
+101
View File
@@ -0,0 +1,101 @@
"""CircuitPython SHTC3 temperature and humidity sensor driver.
Uses standard I2C transactions with bus locking.
"""
import time
class SHTC3:
ADDR = 0x70
WAKE = b'\x35\x17'
SLEEP = b'\xB0\x98'
MEASURE = b'\x78\x66' # High precision, T first, clock stretching disabled
def __init__(self, i2c):
self.i2c = i2c
def _crc8(self, data):
crc = 0xFF
for byte in data:
crc ^= byte
for _ in range(8):
if crc & 0x80:
crc = (crc << 1) ^ 0x31
else:
crc <<= 1
crc &= 0xFF
return crc
def read_sensor(self):
try:
# 1. Wakeup
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, self.WAKE)
finally:
self.i2c.unlock()
time.sleep(0.001)
# 2. Trigger Measurement
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, self.MEASURE)
finally:
self.i2c.unlock()
time.sleep(0.015)
# 3. Read 6 bytes of data
# bytes 0, 1: Temp, byte 2: Temp CRC
# bytes 3, 4: Hum, byte 5: Hum CRC
buf = bytearray(6)
while not self.i2c.try_lock():
pass
try:
self.i2c.readfrom_into(self.ADDR, buf)
finally:
self.i2c.unlock()
# 4. Enter sleep mode
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, self.SLEEP)
finally:
self.i2c.unlock()
# Verify CRC
t_data = buf[0:2]
t_crc = buf[2]
h_data = buf[3:5]
h_crc = buf[5]
if self._crc8(t_data) != t_crc:
print("SHTC3 Temp CRC error")
return None, None
if self._crc8(h_data) != h_crc:
print("SHTC3 Hum CRC error")
return None, None
raw_t = (buf[0] << 8) | buf[1]
raw_h = (buf[3] << 8) | buf[4]
temp = -45.0 + 175.0 * (raw_t / 65536.0)
hum = 100.0 * (raw_h / 65536.0)
return round(temp, 2), round(hum, 2)
except Exception as e:
print(f"Error reading SHTC3 sensor: {e}")
try:
while not self.i2c.try_lock():
pass
try:
self.i2c.writeto(self.ADDR, self.SLEEP)
finally:
self.i2c.unlock()
except:
pass
return None, None
+572
View File
@@ -0,0 +1,572 @@
"""CircuitPython Video Stream Server utility.
Listens for incoming TCP/UDP video frames and draws them centered and cropped on the display.
"""
import time
class VideoStreamServer:
def __init__(self, display, pool, tcp_port=8081, udp_port=8082, color_port=8083, color_udp_port=8084):
self.display = display
self.pool = pool
self.tcp_port = tcp_port
self.udp_port = udp_port
self.color_port = color_port
self.color_udp_port = color_udp_port
# Sockets
self.tcp_server = None
self.tcp_client = None
self.udp_sock = None
self.color_server = None
self.color_client = None
self.color_udp_sock = None
# State
self.active = False
self.last_packet_time = 0
self.timeout_s = 3.0
# Frame buffering
self.buffer = bytearray(15000)
self.view = memoryview(self.buffer)
self.tcp_bytes_received = 0
# UDP Reassembly
self.udp_temp_buffer = bytearray(1002)
self.current_frame_id = -1
self.chunks_received = 0
self.color_chunks_mask = 0
# Color buffering (320x240 RGB565 is 153,600 bytes)
self.color_buffer = bytearray(153600)
self.color_view = memoryview(self.color_buffer)
self.color_bytes_received = 0
self.color_header = bytearray(16)
self.color_header_received = 0
self.color_payload_len = 0
self.color_x = 0
self.color_y = 0
self.color_w = 0
self.color_h = 0
# Stats
self.debug = False
self.frames_drawn = 0
self.udp_packets_received = 0
self.udp_frames_complete = 0
self.dropped_udp_frames = 0
self.last_draw_ms = 0
self.last_fps = 0.0
# FPS Calculation
self.fps_start_time = time.monotonic()
self.fps_frame_count = 0
def start(self):
"""Initializes TCP and UDP sockets."""
# 1. Start TCP Server (Mono)
try:
self.tcp_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
try:
self.tcp_server.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
except:
pass
self.tcp_server.bind(("", self.tcp_port))
self.tcp_server.listen(1)
self.tcp_server.setblocking(False)
print(f"Video TCP Stream server listening on port {self.tcp_port}...")
except Exception as e:
print(f"Failed to start TCP stream server: {e}")
# 2. Start UDP Server (Mono)
try:
self.udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
try:
self.udp_sock.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
except:
pass
self.udp_sock.bind(("", self.udp_port))
self.udp_sock.setblocking(False)
print(f"Video UDP Stream responder listening on port {self.udp_port}...")
except Exception as e:
print(f"Failed to start UDP stream server: {e}")
# 3. Start Color TCP Server
try:
self.color_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
try:
self.color_server.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
except:
pass
self.color_server.bind(("", self.color_port))
self.color_server.listen(1)
self.color_server.setblocking(False)
print(f"Video Color TCP Stream server listening on port {self.color_port}...")
except Exception as e:
print(f"Failed to start Color TCP server: {e}")
# 4. Start Color UDP Server
try:
self.color_udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
try:
self.color_udp_sock.setsockopt(self.pool.SOL_SOCKET, self.pool.SO_REUSEADDR, 1)
except:
pass
self.color_udp_sock.bind(("", self.color_udp_port))
self.color_udp_sock.setblocking(False)
print(f"Video Color UDP Stream responder listening on port {self.color_udp_port}...")
except Exception as e:
print(f"Failed to start Color UDP stream server: {e}")
def restart_tcp_server(self):
print("Restarting TCP Stream Server...")
self.close_tcp_client()
if self.tcp_server:
try:
self.tcp_server.close()
except:
pass
self.tcp_server = None
time.sleep(0.1)
try:
self.tcp_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
self.tcp_server.bind(("", self.tcp_port))
self.tcp_server.listen(1)
self.tcp_server.setblocking(False)
except Exception as e:
print(f"Restart TCP Server failed: {e}")
def restart_udp_sock(self):
print("Restarting UDP Stream Socket...")
if self.udp_sock:
try:
self.udp_sock.close()
except:
pass
self.udp_sock = None
time.sleep(0.1)
try:
self.udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
self.udp_sock.bind(("", self.udp_port))
self.udp_sock.setblocking(False)
except Exception as e:
print(f"Restart UDP Socket failed: {e}")
def restart_color_server(self):
print("Restarting Color TCP Stream Server...")
self.close_color_client()
if self.color_server:
try:
self.color_server.close()
except:
pass
self.color_server = None
time.sleep(0.1)
try:
self.color_server = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_STREAM)
self.color_server.bind(("", self.color_port))
self.color_server.listen(1)
self.color_server.setblocking(False)
except Exception as e:
print(f"Restart Color TCP Server failed: {e}")
def restart_color_udp_sock(self):
print("Restarting Color UDP Stream Socket...")
if self.color_udp_sock:
try:
self.color_udp_sock.close()
except:
pass
self.color_udp_sock = None
time.sleep(0.1)
try:
self.color_udp_sock = self.pool.socket(self.pool.AF_INET, self.pool.SOCK_DGRAM)
self.color_udp_sock.bind(("", self.color_udp_port))
self.color_udp_sock.setblocking(False)
except Exception as e:
print(f"Restart Color UDP Socket failed: {e}")
def update(self):
"""Non-blocking socket check for streaming updates."""
now = time.monotonic()
# Check Stream Active Timeout
if self.active and (now - self.last_packet_time) > self.timeout_s:
print("Video stream timed out. Returning to dashboard.")
self.active = False
self.close_tcp_client()
self.close_color_client()
# Calculate FPS periodically
fps_elapsed = now - self.fps_start_time
if fps_elapsed >= 2.0:
self.last_fps = self.fps_frame_count / fps_elapsed
self.fps_frame_count = 0
self.fps_start_time = now
# 1. Handle UDP reassembly (Mono)
if self.udp_sock:
while True:
try:
# recv_into returns number of bytes read
n = self.udp_sock.recv_into(self.udp_temp_buffer)
if n == 0:
break
self.udp_packets_received += 1
frame_id = self.udp_temp_buffer[0]
chunk_idx = self.udp_temp_buffer[1]
if chunk_idx < 15:
self.active = True
self.last_packet_time = now
if frame_id != self.current_frame_id:
if self.chunks_received != 0:
self.dropped_udp_frames += 1
self.current_frame_id = frame_id
self.chunks_received = 0
# Copy payload to self.buffer
start_offset = chunk_idx * 1000
self.buffer[start_offset : start_offset + 1000] = self.udp_temp_buffer[2:1002]
self.chunks_received |= (1 << chunk_idx)
if self.chunks_received == 0x7FFF:
self.udp_frames_complete += 1
self.active = True
self.last_packet_time = now
self._draw_frame()
self.chunks_received = 0
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err in (errno.EAGAIN, ewouldblock) or err is None:
break
print(f"UDP Socket error: {e}")
self.restart_udp_sock()
break
# 1B. Handle UDP reassembly (Color)
if self.color_udp_sock:
while True:
try:
n = self.color_udp_sock.recv_into(self.udp_temp_buffer)
if n == 0:
break
self.udp_packets_received += 1
frame_id = self.udp_temp_buffer[0]
chunk_idx = self.udp_temp_buffer[1]
if chunk_idx < 154:
self.active = True
self.last_packet_time = now
if frame_id != self.current_frame_id:
self.current_frame_id = frame_id
self.color_chunks_mask = 0
# Copy payload to self.color_buffer
start_offset = chunk_idx * 1000
if start_offset + 1000 <= 153600:
self.color_buffer[start_offset : start_offset + 1000] = self.udp_temp_buffer[2:1002]
self.color_chunks_mask |= (1 << chunk_idx)
if self.color_chunks_mask == 0x3FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFF:
self.udp_frames_complete += 1
self.active = True
self.last_packet_time = now
self.color_x = 0
self.color_y = 0
self.color_w = 320
self.color_h = 240
self.color_payload_len = 153600
self._draw_color_frame()
self.color_chunks_mask = 0
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err in (errno.EAGAIN, ewouldblock) or err is None:
break
print(f"Color UDP Socket error: {e}")
self.restart_color_udp_sock()
break
# 2. Handle TCP stream (Mono)
if self.tcp_server:
if self.tcp_client is None:
try:
self.tcp_client, addr = self.tcp_server.accept()
self.tcp_client.setblocking(False)
self.tcp_bytes_received = 0
self.active = True
self.last_packet_time = now
print(f"TCP Stream client connected from: {addr}")
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err not in (errno.EAGAIN, ewouldblock) and err is not None:
print(f"TCP Accept error: {e}")
self.restart_tcp_server()
if self.tcp_client is not None:
retries = 0
while self.tcp_bytes_received < 15000:
remaining = 15000 - self.tcp_bytes_received
slice_view = self.view[self.tcp_bytes_received : self.tcp_bytes_received + remaining]
try:
n = self.tcp_client.recv_into(slice_view)
if n > 0:
self.tcp_bytes_received += n
self.last_packet_time = now
self.active = True
retries = 0
elif n == 0:
print("TCP Stream client disconnected.")
self.close_tcp_client()
break
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err in (errno.EAGAIN, ewouldblock) or err is None:
retries += 1
if retries > 15:
break
time.sleep(0.001)
else:
print(f"TCP Stream recv error: {e}")
self.close_tcp_client()
break
if self.tcp_bytes_received == 15000:
self.tcp_bytes_received = 0
self._draw_frame()
# 3. Handle Color TCP stream
if self.color_server:
if self.color_client is None:
try:
self.color_client, addr = self.color_server.accept()
self.color_client.setblocking(False)
self.color_bytes_received = 0
self.color_header_received = 0
self.color_payload_len = 0
self.active = True
self.last_packet_time = now
print(f"Color TCP Stream client connected from: {addr}")
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err not in (errno.EAGAIN, ewouldblock) and err is not None:
print(f"Color TCP Accept error: {e}")
self.restart_color_server()
if self.color_client is not None:
try:
# Read header (16 bytes)
if self.color_header_received < 16:
start_h = time.monotonic()
while self.color_header_received < 16:
if (time.monotonic() - start_h) > 0.100:
break
remaining_h = 16 - self.color_header_received
slice_h = memoryview(self.color_header)[self.color_header_received : self.color_header_received + remaining_h]
n = self.color_client.recv_into(slice_h)
if n > 0:
self.color_header_received += n
elif n == 0:
self.close_color_client()
return
if self.color_header_received == 16:
# Detect version byte at index 4
version = self.color_header[4]
if version == 1:
# stream_color.py format: sig (4B), version (1B), format (1B), width (2B), height (2B), payload_len (4B), reserved (2B)
self.color_x = 0
self.color_y = 0
self.color_w = (self.color_header[6] << 8) | self.color_header[7]
self.color_h = (self.color_header[8] << 8) | self.color_header[9]
self.color_payload_len = (self.color_header[10] << 24) | (self.color_header[11] << 16) | (self.color_header[12] << 8) | self.color_header[13]
else:
# iPhone app format: sig (4B), x (2B), y (2B), width (2B), height (2B), payload_len (4B)
self.color_x = (self.color_header[4] << 8) | self.color_header[5]
self.color_y = (self.color_header[6] << 8) | self.color_header[7]
self.color_w = (self.color_header[8] << 8) | self.color_header[9]
self.color_h = (self.color_header[10] << 8) | self.color_header[11]
self.color_payload_len = (self.color_header[12] << 24) | (self.color_header[13] << 16) | (self.color_header[14] << 8) | self.color_header[15]
# Safety check:
if self.color_payload_len > len(self.color_buffer):
print(f"Warning: Color payload length {self.color_payload_len} exceeds preallocated buffer {len(self.color_buffer)}. Closing connection.")
self.close_color_client()
return
self.color_bytes_received = 0
# Read payload
if self.color_header_received == 16 and self.color_payload_len > 0:
retries = 0
while self.color_bytes_received < self.color_payload_len:
remaining_p = self.color_payload_len - self.color_bytes_received
slice_p = self.color_view[self.color_bytes_received : self.color_bytes_received + remaining_p]
try:
n = self.color_client.recv_into(slice_p)
if n > 0:
self.color_bytes_received += n
self.last_packet_time = now
self.active = True
retries = 0
elif n == 0:
self.close_color_client()
break
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err in (errno.EAGAIN, ewouldblock) or err is None:
retries += 1
if retries > 25: # max 25ms wait total per frame
break
time.sleep(0.001)
else:
print(f"Color TCP Stream recv error during payload: {e}")
self.close_color_client()
break
if self.color_bytes_received == self.color_payload_len:
self._draw_color_frame()
self.color_header_received = 0
self.color_payload_len = 0
self.color_bytes_received = 0
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err not in (errno.EAGAIN, ewouldblock) and err is not None:
print(f"Color TCP Stream recv error: {e}")
self.close_color_client()
def rlcd_to_mono_cp(self, rlcd_buf, canvas_buf, width, height):
for i in range(len(canvas_buf)):
canvas_buf[i] = 0
dx = (400 - width) // 2
dy = (300 - height) // 2
width_bytes = width // 8
for index in range(15000):
val = rlcd_buf[index]
if val == 0:
continue
byte_x = index // 75
block_y = index % 75
x_base = 2 * byte_x
y_base = 299 - 4 * block_y
for local_y in range(4):
for local_x in range(2):
bit = 7 - (local_y * 2 + local_x)
if val & (1 << bit):
x = x_base + local_x
y = y_base - local_y
screen_x = x - dx
screen_y = y - dy
if 0 <= screen_x < width and 0 <= screen_y < height:
byte_idx = screen_y * width_bytes + (screen_x >> 3)
bit_idx = 7 - (screen_x & 7)
canvas_buf[byte_idx] |= (1 << bit_idx)
def _draw_frame(self):
draw_start = time.monotonic()
# Check if RLCD display vs standard ILI9341 display
disp_name = self.display.__class__.__name__
if disp_name == "RLCD":
# Direct SPI write commands for RLCD layout
self.display.write_cmd(0x2A)
self.display.write_data([0x12, 0x2A])
self.display.write_cmd(0x2B)
self.display.write_data([0x00, 0xC7])
self.display.write_cmd(0x2C)
self.display.write_data(self.buffer)
else:
# Map 400x300 RLCD buffer into the ILI9341 320x240 canvas buffer
self.rlcd_to_mono_cp(self.buffer, self.display.canvas_buffer, self.display.width, self.display.height)
self.display.show()
self.last_draw_ms = int((time.monotonic() - draw_start) * 1000)
self.frames_drawn += 1
self.fps_frame_count += 1
def _draw_color_frame(self):
draw_start = time.monotonic()
if hasattr(self.display, "draw_rgb565"):
self.display.draw_rgb565(
self.color_x,
self.color_y,
self.color_w,
self.color_h,
self.color_view[:self.color_payload_len],
sync_canvas=False,
)
else:
# Fallback if no raw RGB565 method is exposed (e.g. standard RLCD)
pass
self.last_draw_ms = int((time.monotonic() - draw_start) * 1000)
self.frames_drawn += 1
self.fps_frame_count += 1
def get_stats(self):
return {
"frames_drawn": self.frames_drawn,
"tcp_bytes_received": self.tcp_bytes_received,
"udp_packets_received": self.udp_packets_received,
"udp_frames_complete": self.udp_frames_complete,
"dropped_udp_frames": self.dropped_udp_frames,
"last_draw_ms": self.last_draw_ms,
"last_fps": round(self.last_fps, 1),
"active": self.active,
}
def close_tcp_client(self):
if self.tcp_client:
try:
self.tcp_client.close()
except:
pass
self.tcp_client = None
self.tcp_bytes_received = 0
def close_color_client(self):
if self.color_client:
try:
self.color_client.close()
except:
pass
self.color_client = None
self.color_bytes_received = 0
self.color_header_received = 0
self.color_payload_len = 0
+36 -31
View File
@@ -2,9 +2,8 @@ import time
import machine
from machine import Pin, I2S
import board_config
import audio_util
def play_raw_pcm(filename, channels=2, rate=16000, bits=16, volume=90):
def play_ram_pcm(audio_chunks, channels=2, rate=16000, bits=16, volume=90):
from audio_util import ES8311
# 1. Start MCLK PWM using board config parameters
@@ -38,16 +37,11 @@ def play_raw_pcm(filename, channels=2, rate=16000, bits=16, volume=90):
amp_pin = Pin(board_config.audio_amp_pin, Pin.OUT, value=on_val)
try:
print(f"Streaming raw audio to speaker from '{filename}'...")
with open(filename, 'rb') as f:
buf = bytearray(2048)
while True:
bytes_read = f.readinto(buf)
if bytes_read == 0:
break
i2s.write(buf[:bytes_read])
print(f"Streaming raw audio to speaker from RAM ({len(audio_chunks)} chunks)...")
for chunk in audio_chunks:
i2s.write(chunk)
except Exception as e:
print("Error during raw playback:", e)
print("Error during RAM playback:", e)
finally:
time.sleep_ms(100) # Let buffer play out
amp_pin.value(off_val) # Disable amp
@@ -58,10 +52,10 @@ def play_raw_pcm(filename, channels=2, rate=16000, bits=16, volume=90):
def main():
display = board_config.display_instance
print("=== Dynamic Audio Loopback Utility Script ===")
print("=== Dynamic RAM-based Audio Loopback Script ===")
print(f"Board Detected: {board_config.BOARD_TYPE}")
# Initialize buttons using polling Pins instead of BoardButtons class (to avoid edge-triggered interrupt storms)
# Initialize buttons using polling Pins instead of BoardButtons class
key_pin = Pin(18, Pin.IN, Pin.PULL_UP)
boot_pin = Pin(0, Pin.IN, Pin.PULL_UP)
@@ -71,8 +65,6 @@ def main():
return board_config.touch.is_touched()
return key_pin.value() == 0
filename = "local_audio_test.pcm"
while True:
if display:
display.clear(0)
@@ -81,13 +73,13 @@ def main():
display.line(10, 20, 390, 20, 1)
display.text("1. Press KEY button to record 10s", 15, 60, 1)
display.text("2. Playback will start automatically", 15, 80, 1)
display.text("Ready...", 15, 120, 1)
display.text("Ready (RAM-based)...", 15, 120, 1)
else:
display.text("Touch Audio Loopback Test", 10, 10, 1)
display.line(10, 20, 310, 20, 1)
display.text("1. Press screen/KEY to record 10s", 10, 50, 1)
display.text("2. Playback starts automatically", 10, 70, 1)
display.text("Ready...", 10, 100, 1)
display.text("Ready (RAM-based)...", 10, 100, 1)
display.show()
print("Ready: Press and hold key/screen to record...")
@@ -129,8 +121,6 @@ def main():
display.text("Speak now!", 10, 80, 1)
display.show()
# We record as long as the button is pressed (or up to 10 seconds max)
# 1. Start MCLK PWM using board config parameters
mclk_pwm = None
if board_config.audio_mclk_pin is not None:
@@ -139,26 +129,28 @@ def main():
mclk_pwm.freq(board_config.audio_mclk_freq)
mclk_pwm.duty_u16(32768)
# 2. Configure I2S RX (Stereo 16kHz)
# 2. Configure I2S RX (Stereo 16kHz) - ibuf set to 16000 for safety
i2s_rx = I2S(1,
sck=Pin(board_config.audio_i2s_sck),
ws=Pin(board_config.audio_i2s_ws),
sd=Pin(board_config.audio_i2s_rx_sd),
mode=I2S.RX,
ibuf=8000,
ibuf=16000,
rate=16000,
bits=16,
format=I2S.STEREO)
# 3. Wake up and configure the microphone chip (ES7210 vs ES8311)
init_ok = False
if board_config.audio_mic_codec == "ES7210":
from audio_util import ES7210
mic_adc = ES7210(board_config.i2c_bus)
mic_adc.init(sample_rate=16000, bit_width=16)
init_ok = mic_adc.init(sample_rate=16000, bit_width=16)
else:
from audio_util import ES8311
mic_adc = ES8311(board_config.i2c_bus)
if mic_adc.init(sample_rate=16000):
init_ok = True
mic_adc.set_volume(80)
try:
mic_adc._write(0x14, 0x1A) # Enable analog mic input & PGA
@@ -167,17 +159,30 @@ def main():
except:
pass
# Record loop
buffer = bytearray(1024)
if not init_ok:
print("Microphone codec initialization failed! Aborting recording.")
i2s_rx.deinit()
if mclk_pwm:
mclk_pwm.deinit()
if display:
display.clear(0)
display.text("Codec Init Failed!", 15, 80, 1)
display.show()
time.sleep(3)
continue
# Record loop to RAM - using ticks_ms for safe timing
buffer = bytearray(2048)
audio_chunks = []
total_bytes = 0
start_rec_time = time.time()
start_rec_time = time.ticks_ms()
max_duration_ms = 10000 # 10 seconds
try:
with open(filename, 'wb') as f:
while (time.time() - start_rec_time) < 10:
while time.ticks_diff(time.ticks_ms(), start_rec_time) < max_duration_ms:
bytes_read = i2s_rx.readinto(buffer)
if bytes_read > 0:
f.write(buffer[:bytes_read])
audio_chunks.append(bytes(buffer[:bytes_read]))
total_bytes += bytes_read
except Exception as e:
print("Recording failed:", e)
@@ -186,12 +191,12 @@ def main():
if mclk_pwm:
mclk_pwm.deinit()
print(f"Recorded {total_bytes} bytes to '{filename}'.")
print(f"Recorded {total_bytes} bytes in RAM ({len(audio_chunks)} chunks).")
# Wait for release of key/trigger to debounce
time.sleep_ms(200)
# 4. Playback
# 4. Playback from RAM
if display:
display.clear(0)
if board_config.BOARD_TYPE == 'WAVESHARE_RLCD':
@@ -206,7 +211,7 @@ def main():
display.text(f"Bytes: {total_bytes}", 10, 80, 1)
display.show()
play_raw_pcm(filename, channels=2, rate=16000, bits=16, volume=95)
play_ram_pcm(audio_chunks, channels=2, rate=16000, bits=16, volume=95)
if display:
display.clear(0)
+358
View File
@@ -0,0 +1,358 @@
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
"""Client firmware application for real-time WebSocket audio streaming to Hermes.
Dynamically uses board_config to work across Hosyond and Waveshare RLCD boards.
"""
import time
import struct
import machine
from machine import Pin, I2S
import json
import board_config
from websocket_client import WebSocketClient
# --- CONFIGURATION ---
HERMES_WS_URL = "ws://192.168.68.126:8642/api/esp32/voice/ws"
# Determine dynamic device ID based on board configuration
DEVICE_ID = "esp32_screen" if board_config.BOARD_TYPE == 'WAVESHARE_RLCD' else "little32"
# Placeholder for API Key. Since the key is stored on the Hermes server,
# please copy-paste the token string here.
HERMES_API_KEY = "mcT1YA1vOr9wXSiHpCYalweEGGZKX-PIfZv2drp8BSg"
def main():
print("=== Starting Hermes WebSocket Voice Assistant Demo (Type: {}) ===".format(board_config.BOARD_TYPE))
# 1. Use pre-initialized display and touch from board_config
display = board_config.display_instance
touch = board_config.touch
if display:
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.show()
# 2. Configure Audio Amp control pin based on board config
amp_pin = None
on_val = 0
off_val = 1
if board_config.audio_amp_pin is not None:
on_val = 0 if board_config.audio_amp_active_level == 0 else 1
off_val = 1 if board_config.audio_amp_active_level == 0 else 0
amp_pin = Pin(board_config.audio_amp_pin, Pin.OUT, value=off_val)
# Helper function to check if trigger is active (touch screen or button)
def is_talk_trigger_active():
if touch:
return touch.is_touched()
elif hasattr(board_config, 'buttons') and board_config.buttons and board_config.buttons.key:
return board_config.buttons.key.is_pressed()
return False
while True:
if display:
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.line(10, 22, display.width - 10, 22, 1)
display.text("Hold screen & ask", 50, 70, 1)
display.text("a question...", 50, 90, 1)
display.text("Status: Idle (WS)", 10, display.height - 20, 1)
display.show()
# Wait for touch or button trigger
while not is_talk_trigger_active():
time.sleep_ms(30)
print("Touch/Button detected! Connecting WebSocket...")
if display:
display.clear(0)
display.text("Connecting...", 80, 80, 1)
display.show()
headers = {
"Authorization": f"Bearer {HERMES_API_KEY}",
"X-Device-ID": DEVICE_ID
}
ws = WebSocketClient(HERMES_WS_URL, headers=headers, timeout=30)
try:
ws.connect()
# Send start event
ws.send_text(json.dumps({
"event": "start",
"device_id": DEVICE_ID,
"sample_rate": 16000,
"channels": 1,
"sample_width": 2,
"format": "pcm_s16le"
}))
# Read ready and listening events from server
ws.recv_frame() # ready
ws.recv_frame() # listening
print("WebSocket connected and streaming started.")
if display:
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.line(10, 22, display.width - 10, 22, 1)
display.text("Listening...", 80, 80, 1)
display.fill_rect(130, 110, 30, 30, 1)
display.text("Status: Streaming", 10, display.height - 20, 1)
display.show()
# 3. Configure MCLK PWM
mclk_pwm = None
if board_config.audio_mclk_pin is not None:
mclk_pin = Pin(board_config.audio_mclk_pin, Pin.OUT)
mclk_pwm = machine.PWM(mclk_pin)
mclk_pwm.freq(board_config.audio_mclk_freq)
mclk_pwm.duty_u16(32768)
# 4. Initialize microphone codec
i2c = board_config.i2c_bus
if board_config.audio_mic_codec == "ES7210":
from audio_util import ES7210
codec = ES7210(i2c)
codec.init(sample_rate=16000, bit_width=16)
else:
from audio_util import ES8311
codec = ES8311(i2c)
if codec.init(sample_rate=16000):
codec.set_volume(80)
try:
codec._write(0x14, 0x1A)
codec._write(0x16, 0x01)
codec._write(0x17, 0xC8)
except:
pass
# 5. Open I2S RX for recording (Stereo 16kHz for ES7210, mono for ES8311)
is_stereo = (board_config.audio_mic_codec == "ES7210")
i2s_format = I2S.STEREO if is_stereo else I2S.MONO
i2s_rx = I2S(1,
sck=Pin(board_config.audio_i2s_sck),
ws=Pin(board_config.audio_i2s_ws),
sd=Pin(board_config.audio_i2s_rx_sd),
mode=I2S.RX,
ibuf=16000,
rate=16000,
bits=16,
format=i2s_format)
total_data_bytes = 0
buffer = bytearray(2048)
mono_buf = bytearray(1024)
rec_start_time = time.ticks_ms()
max_rec_duration_ms = 10000
try:
# Record loop - as long as touch/button is held
while is_talk_trigger_active():
elapsed = time.ticks_diff(time.ticks_ms(), rec_start_time)
if elapsed >= max_rec_duration_ms:
print("Recording stopped: maximum duration reached")
break
bytes_read = i2s_rx.readinto(buffer)
if bytes_read > 0:
if is_stereo:
# Stereo-to-mono: extract left channel (every other 16-bit sample)
mono_len = bytes_read // 2
j = 0
for i in range(0, bytes_read, 4):
mono_buf[j] = buffer[i]
mono_buf[j + 1] = buffer[i + 1]
j += 2
ws.send_binary(mono_buf[:mono_len])
total_data_bytes += mono_len
else:
ws.send_binary(buffer[:bytes_read])
total_data_bytes += bytes_read
print(f"Touch/Button released! Sent {total_data_bytes} bytes.")
except Exception as e:
print("Error recording/streaming:", e)
finally:
i2s_rx.deinit()
if board_config.audio_mic_codec == "ES8311":
try:
codec._write(0x16, 0x00) # Reset mic gain
except:
pass
if total_data_bytes < 3200:
print("Recording too short, cancelling session.")
try:
ws.send_text(json.dumps({"event": "cancel"}))
ws.close()
except:
pass
if mclk_pwm:
mclk_pwm.deinit()
continue
# Send stop event
ws.send_text(json.dumps({"event": "stop"}))
if display:
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.line(10, 22, display.width - 10, 22, 1)
display.text("Processing...", 50, 80, 1)
display.text("Status: Thinking", 10, display.height - 20, 1)
display.show()
# Playback/Events loop
i2s_tx = None
received_audio_bytes = 0
speaker_write_failed = False
while True:
opcode, payload = ws.recv_frame()
if opcode is None:
break
if opcode == 0x1: # Text frame (JSON event)
try:
event_data = json.loads(payload.decode('utf-8'))
evt = event_data.get("event")
if evt == "transcript":
txt = event_data.get("text", "")
print(f"Heard: {txt}")
if display:
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.line(10, 22, display.width - 10, 22, 1)
display.text("Heard:", 10, 40, 1)
lines = [txt[i:i+30] for i in range(0, min(len(txt), 120), 30)]
y_offset = 60
for line in lines:
display.text(line, 10, y_offset, 1)
y_offset += 20
display.show()
elif evt == "thinking":
pass
elif evt == "response_text":
txt = event_data.get("text", "")
print(f"Response: {txt}")
if display:
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.line(10, 22, display.width - 10, 22, 1)
display.text("Response:", 10, 40, 1)
lines = [txt[i:i+30] for i in range(0, min(len(txt), 120), 30)]
y_offset = 60
for line in lines:
display.text(line, 10, y_offset, 1)
y_offset += 20
display.show()
elif evt == "audio_start":
print("Audio response started.")
if amp_pin:
amp_pin.value(on_val) # Enable amp
# Initialize ES8311 Speaker DAC
try:
from audio_util import ES8311
dac = ES8311(i2c)
dac.init(sample_rate=16000)
dac.set_volume(85)
except Exception as dace:
print("Failed to initialize ES8311 DAC for playback:", dace)
# Open I2S TX
i2s_tx = I2S(1,
sck=Pin(board_config.audio_i2s_sck),
ws=Pin(board_config.audio_i2s_ws),
sd=Pin(board_config.audio_i2s_tx_sd),
mode=I2S.TX,
ibuf=4096,
rate=16000,
bits=16,
format=I2S.MONO)
elif evt == "audio_end":
print("Audio response ended.")
if i2s_tx:
time.sleep_ms(150)
if amp_pin:
amp_pin.value(off_val) # Disable amp
i2s_tx.deinit()
i2s_tx = None
if display:
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.line(10, 22, display.width - 10, 22, 1)
if speaker_write_failed:
display.text("Speaker write failed", 30, 80, 1)
else:
display.text(f"Recv: {received_audio_bytes} bytes", 30, 80, 1)
display.text("Status: Idle (WS)", 10, display.height - 20, 1)
display.show()
elif evt == "done":
break
elif evt == "error":
msg = event_data.get("message", "Unknown error")
print(f"Error from server: {msg}")
if display:
display.clear(0)
display.text("Error", 10, 10, 1)
display.line(10, 22, display.width - 10, 22, 1)
display.text(msg[:100], 10, 80, 1)
display.show()
time.sleep(3)
break
except Exception as e:
print("Error parsing event text:", e)
elif opcode == 0x2: # Binary frame (Audio WAV chunk)
if i2s_tx:
chunk = payload
if chunk.startswith(b'RIFF') and len(chunk) > 44:
chunk = chunk[44:] # Skip WAV header for direct play
try:
i2s_tx.write(chunk)
received_audio_bytes += len(chunk)
except Exception as e:
speaker_write_failed = True
print("Error writing to speaker:", e)
ws.close()
if mclk_pwm:
mclk_pwm.deinit()
except Exception as e:
print("Failed to stream to Hermes server:", e)
if display:
display.clear(0)
display.text("Server Error", 10, 10, 1)
display.line(10, 22, display.width - 10, 22, 1)
display.text("Could not connect", 50, 80, 1)
display.text("to WebSocket server.", 50, 100, 1)
display.show()
time.sleep(3)
try:
ws.close()
except Exception:
pass
if mclk_pwm:
try:
mclk_pwm.deinit()
except:
pass
# Debounce touch release
while is_talk_trigger_active():
time.sleep_ms(30)
time.sleep_ms(300)
if __name__ == "__main__":
main()
+32
View File
@@ -0,0 +1,32 @@
# Waveshare ESP32-S3-RLCD-4.2 & ES7210 Microphone Diagnostics
This document outlines the root causes of the audio recording failures and hardware crashes we encountered with the Waveshare ESP32-S3 board and its onboard ES7210 microphone array, along with their solutions.
## 1. I2S Bit-Depth Mismatch (The "Static Noise" Issue)
**Problem:** The audio captured by the board was entirely flat or unrecognizable static noise. The audio signal sent to the Whisper pipeline had extremely low RMS levels (25-120), leading to transcription timeouts.
**Root Cause:** The ES7210 microphone ADC was configured via its internal registers to stream **24-bit** audio (Register `0x11` was set to `0x00`). However, the ESP32's I2S hardware peripheral was configured to receive **16-bit** audio. The ESP32 sliced the 24-bit audio frames into misaligned 16-bit chunks, completely destroying the waveform.
**Solution:** Modified `audio_util.py` to write `0x60` to Register `0x11`. This locks the ES7210 into native 16-bit Standard I2S output, perfectly aligning it with the ESP32's buffer.
## 2. I2C Bus Deadlocks (The "Bootloop / Hang" Issue)
**Problem:** The board would frequently hang during the boot sequence or when attempting to re-initialize the audio components. This occurred primarily after soft-reboots or abrupt script terminations.
**Root Cause:** The ES7210 chip does not gracefully release the I2C SDA (data) line if communication is interrupted midway. When the ESP32 soft-reboots, the SDA line remains held low by the ES7210, which permanently hangs the ESP32's internal I2C driver on the next boot attempt.
**Solution:** Added a manual 9-clock I2C hardware recovery sequence to `board_config.py`. Before the `SoftI2C` interface is initialized, the ESP32 manually toggles the SCL pin 9 times as an output to force the ES7210 to release the SDA line, followed by generating a standard I2C STOP condition.
## 3. Incorrect I2C Pin Assignments (The "ENODEV" Issue)
**Problem:** The audio configuration would occasionally fail with `OSError: [Errno 19] ENODEV`, indicating the I2C bus could not find the microphone at address `0x40`.
**Root Cause:** The dynamic board-detection logic in `board_config.py` was originally configured to scan for I2C devices on pins 15 and 16 (the default for the Hosyond board). The Waveshare RLCD board uses pins 13 and 14 for the audio I2C bus.
**Solution:** Hardcoded the correct I2C pins (SDA=13, SCL=14) for the Waveshare board profile and ensured the I2C scan and initialization processes execute on the correct pins.
## 4. Invalid OSR & Clock Division (The "Popping Sound" Issue)
**Problem:** Even when I2S and I2C connected successfully, the recorded audio consisted only of loud, constant popping and clipping at max/min bounds (amplitude 32768), with no recognizable voice signal.
**Root Cause:** The ES7210 microphone ADC was initialized with an incorrect clock and oversampling configuration:
1. Register `0x07` was written with `0x40` to select an oversampling ratio (OSR) of 64. However, the register allocation for `ADC_OSR` is only 6 bits (`bits 5:0`), meaning `0x40` overflowed and set the OSR value to `0`, causing the internal modulator state machines to malfunction.
2. Register `0x02` was configured as a flat division of 12 (`0x0C`), which failed to route and clock the delta-sigma modulators properly.
**Solution:** We analyzed the official C++ implementation of the ES7210 driver in the ESPHome repository (`es7210.cpp` and `es7210_const.h`). By cross-referencing its clock coefficient lookup table for a 12.288MHz Master Clock and 16kHz sample rate, we retrieved the correct register values. We modified `audio_util.py` to match this C++ clock configuration:
* Set OSR configuration register `0x07` to `0x20` (OSR = 32).
* Set main clock control register `0x02` to `0xC3` (enables clock doubler, sets multiply by 2 via bits 7:6 = `11`, and sets division to 3 via bits 4:0 = `0x03`).
* Configured LRCK divider registers `0x04`/`0x05` to `0x03` and `0x00` (division factor of 768).
* Gated unused clocks by writing `0x34` to Register `0x01` (keeps only active ADC12 channels and master MCLK active).
* Corrected the power sequence by initially clearing all MIC bias and PGA settings (`0xFF` to `0x4B`/`0x4C`) before enabling MIC1 and MIC2.
+90
View File
@@ -0,0 +1,90 @@
# Hermes ESP32 Voice Gateway
This folder captures the Hermes API-server implementation used by the ESP32 screen voice demo.
It is intentionally kept next to the screen firmware because the ESP32 client in `demo_hermes_voice.py` talks to this gateway route:
`POST /api/esp32/voice`
## Flow
1. ESP32 records push-to-talk audio as 16 kHz mono 16-bit PCM WAV.
2. ESP32 posts the raw WAV bytes to Hermes with an Authorization header containing the configured API server token.
3. Hermes saves the upload to its audio cache and transcribes it with the configured Hermes STT provider.
4. Hermes sends the transcript through the normal agent session using a persistent conversation key: `esp32:<device_id>`.
5. Hermes turns the final answer into speech with the configured Hermes TTS provider.
6. Hermes normalizes the TTS output into an ESP32-friendly WAV: mono, 16 kHz, signed 16-bit little-endian PCM.
7. Hermes returns the WAV bytes to the ESP32 for playback.
## Files
- `api_server_endpoint.py` — the route/mixin implementation for Hermes' `gateway/platforms/api_server.py`.
- `smoke_test.py` — local helper tests for MIME mapping, safe headers, WAV normalization, and fallback ACK audio generation.
## Client request contract
Required:
- Method: `POST`
- Path: `/api/esp32/voice`
- Header: Authorization containing the configured API server token
- Header: `Content-Type: audio/wav`
- Header: `X-Device-ID: kitchen-button` or another stable device id
- Body: raw audio bytes, preferably 16 kHz mono 16-bit PCM WAV
Optional headers:
- `X-Hermes-Instructions` — extra system instructions for this turn.
- `X-Hermes-Reply-Mode: ack` — return a quick ACK WAV immediately, then finish the agent turn in the background.
- `X-Hermes-Screen-Url` — JSON-RPC MCP endpoint for the initiating screen, e.g. `http://192.168.68.123/api/mcp`.
- `X-Hermes-Screen-Device` — local device registry alias to resolve to a screen URL.
Sync-mode response:
- `Content-Type: audio/wav`
- WAV body playable by the ESP32
- Diagnostic headers:
- `X-Hermes-Transcript`
- `X-Hermes-Text-Response`
- `X-Hermes-Response-Id`
- `X-Hermes-Conversation`
- `X-Hermes-TTS-Source-Type`
ACK-mode response:
- `Content-Type: audio/wav`
- short acknowledgement WAV immediately
- the full answer is displayed/spoken on the initiating MCP screen when a screen URL can be resolved
## Hermes integration notes
In Hermes Agent, wire this into `gateway/platforms/api_server.py` by adding:
```python
self._app.router.add_post("/api/esp32/voice", self._handle_esp32_voice)
```
after the API server app is created.
The mixin expects the API server class to already provide these Hermes internals:
- `_check_auth(request)`
- `_run_agent(...)`
- `_build_response_conversation_history(...)`
- `_response_messages_turn_start_index(...)`
- `_extract_output_items(...)`
- `_response_store`
- `_model_name`
- `_background_tasks`
Do not commit API keys or device tokens here. The ESP32 should use the live `API_SERVER_KEY` configured on the Hermes host.
## Smoke test
From this repo:
```bash
python3 hermes_voice_gateway/smoke_test.py
```
If `ffmpeg` is installed, the smoke test also verifies WAV normalization through the same decode/rewrite path used for TTS output.
+911
View File
@@ -0,0 +1,911 @@
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false, reportArgumentType=false
"""Hermes API-server ESP32 voice gateway implementation.
This module is a self-contained reference implementation for the Hermes
`POST /api/esp32/voice` route used by the ESP32 screen voice demo.
It is written as a mixin so the implementation can live beside this screen
repo while still documenting the exact methods that belong on Hermes'
`APIServerPlatform` class. The host class must provide the normal Hermes API
server internals listed in README.md.
"""
from __future__ import annotations
import asyncio
import json
import logging
import math
import re
import subprocess
import time
import uuid
import wave
from pathlib import Path
from typing import Any, Dict, List, Optional, Tuple
from urllib.parse import urlparse
try: # aiohttp is present in Hermes gateway runtime.
from aiohttp import web
except Exception: # pragma: no cover - allows helper smoke tests without aiohttp.
web = None # type: ignore[assignment]
logger = logging.getLogger(__name__)
ESP32_AUDIO_MAX_BYTES = 8_000_000 # Enough for push-to-talk WAV tests.
ESP32_AUDIO_EXTENSIONS = {
"audio/wav": ".wav",
"audio/wave": ".wav",
"audio/x-wav": ".wav",
"audio/mpeg": ".mp3",
"audio/mp3": ".mp3",
"audio/ogg": ".ogg",
"audio/opus": ".ogg",
"audio/webm": ".webm",
"audio/mp4": ".m4a",
"audio/x-m4a": ".m4a",
"application/octet-stream": ".wav",
}
AUDIO_RESPONSE_MIME_BY_SUFFIX = {
".mp3": "audio/mpeg",
".wav": "audio/wav",
".ogg": "audio/ogg",
".opus": "audio/ogg",
".m4a": "audio/mp4",
".mp4": "audio/mp4",
".flac": "audio/flac",
}
def openai_error(message: str, *, code: str = "esp32_voice_error") -> Dict[str, Any]:
"""Small OpenAI-shaped error body for this endpoint."""
return {"error": {"message": message, "type": "invalid_request_error", "code": code}}
def audio_extension_for_content_type(content_type: str) -> str:
"""Map inbound audio Content-Type to a safe cache-file suffix."""
media_type = (content_type or "").split(";", 1)[0].strip().lower()
return ESP32_AUDIO_EXTENSIONS.get(media_type, ".wav")
def audio_response_mime(path: str) -> str:
"""Return an HTTP Content-Type for a generated audio file path."""
suffix = Path(path).suffix.lower()
return AUDIO_RESPONSE_MIME_BY_SUFFIX.get(suffix, "application/octet-stream")
def safe_audio_header(value: Any, max_length: int) -> str:
"""Make diagnostic text safe for HTTP response headers."""
text = str(value or "")[:max_length]
return text.replace("\r", " ").replace("\n", " ").replace("\x00", " ")
def convert_audio_to_esp32_wav(input_path: str, *, output_dir: Path, device_id: str) -> Path:
"""Normalize generated TTS audio to ESP32-friendly WAV.
Hermes TTS providers commonly emit MP3 or OGG. The ESP32 client expects a
simple RIFF/WAVE file, so decode with ffmpeg to raw PCM and rewrite the WAV
container with Python's `wave` module. The result is mono, 16 kHz,
signed 16-bit little-endian PCM with a minimal `fmt` + `data` layout.
"""
safe_device = re.sub(r"[^A-Za-z0-9_.-]+", "_", device_id)[:80] or "default"
output_dir.mkdir(parents=True, exist_ok=True)
raw_path = output_dir / f"{safe_device}_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.s16le"
wav_path = raw_path.with_suffix(".wav")
cmd = [
"ffmpeg",
"-y",
"-hide_banner",
"-loglevel",
"error",
"-i",
input_path,
"-ac",
"1",
"-ar",
"16000",
"-f",
"s16le",
str(raw_path),
]
try:
subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)
raw = raw_path.read_bytes()
with wave.open(str(wav_path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16000)
wav.writeframes(raw)
return wav_path
finally:
raw_path.unlink(missing_ok=True)
def make_ack_wav(path: Path, *, frequency_hz: int = 880, duration_s: float = 0.18) -> Path:
"""Create a tiny valid mono 16 kHz WAV tone used as last-resort ACK audio."""
sample_rate = 16000
frames = bytearray()
for i in range(int(sample_rate * duration_s)):
value = int(9000 * math.sin(2 * math.pi * frequency_hz * (i / sample_rate)))
frames.extend(value.to_bytes(2, "little", signed=True))
path.parent.mkdir(parents=True, exist_ok=True)
with wave.open(str(path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(sample_rate)
wav.writeframes(bytes(frames))
return path
class HermesESP32VoiceGatewayMixin:
"""Mixin for Hermes' API-server adapter.
The consuming class is expected to provide the Hermes-specific internals:
auth, agent execution, Responses API storage, and background task tracking.
"""
@staticmethod
def _audio_extension_for_content_type(content_type: str) -> str:
return audio_extension_for_content_type(content_type)
@staticmethod
def _audio_response_mime(path: str) -> str:
return audio_response_mime(path)
@staticmethod
def _safe_audio_header(value: Any, max_length: int) -> str:
return safe_audio_header(value, max_length)
@staticmethod
def _convert_audio_to_esp32_wav(input_path: str, *, output_dir: Path, device_id: str) -> Path:
return convert_audio_to_esp32_wav(input_path, output_dir=output_dir, device_id=device_id)
@staticmethod
def _esp32_reply_mode(request: Any) -> str:
value = (
request.headers.get("X-Hermes-Reply-Mode")
or request.query.get("reply_mode")
or request.query.get("mode")
or "sync"
)
value = str(value).strip().lower()
if value in {"ack", "async", "background", "quick_ack", "quick-ack"}:
return "ack"
return "sync"
@staticmethod
def _looks_like_http_url(value: str) -> bool:
try:
parsed = urlparse(value)
return parsed.scheme in {"http", "https"} and bool(parsed.netloc)
except Exception:
return False
@staticmethod
def _load_local_device_url(device_name: str) -> Optional[str]:
"""Best-effort local MCP screen lookup from ~/.hermes/local_devices.yaml."""
if not device_name:
return None
try:
import yaml
registry = Path.home() / ".hermes" / "local_devices.yaml"
data = yaml.safe_load(registry.read_text()) if registry.exists() else {}
aliases = data.get("aliases") if isinstance(data, dict) else None
devices = data.get("devices") if isinstance(data, dict) else None
names = [str(device_name).strip(), str(device_name).strip().replace("-", "_")]
seen: set[str] = set()
while names:
name = names.pop(0)
if not name or name in seen:
continue
seen.add(name)
if isinstance(aliases, dict):
alias = aliases.get(name)
if isinstance(alias, str) and alias:
if alias.startswith("http"):
return alias
names.append(alias)
info = devices.get(name) if isinstance(devices, dict) else None
if isinstance(info, dict):
return str(info.get("fallback_url") or info.get("url") or "") or None
except Exception:
return None
return None
def _resolve_esp32_screen_url(self, request: Any, device_id: str) -> Optional[str]:
explicit = (
request.headers.get("X-Hermes-Screen-Url")
or request.headers.get("X-Screen-MCP-Url")
or request.query.get("screen_url")
)
if explicit and self._looks_like_http_url(explicit.strip()):
return explicit.strip()
screen_device = (
request.headers.get("X-Hermes-Screen-Device")
or request.query.get("screen_device")
or device_id
)
for candidate in [screen_device, "esp32_screen"]:
url = self._load_local_device_url(str(candidate).strip())
if url and self._looks_like_http_url(url):
return url
return None
async def _call_screen_mcp(self, screen_url: Optional[str], tool_name: str, arguments: Dict[str, Any]) -> bool:
"""Call a simple HTTP JSON-RPC MCP screen tool directly."""
if not screen_url:
return False
try:
from aiohttp import ClientSession
payload = {
"jsonrpc": "2.0",
"id": uuid.uuid4().hex[:12],
"method": "tools/call",
"params": {"name": tool_name, "arguments": arguments},
}
async with ClientSession() as session:
async with session.post(screen_url, json=payload, timeout=8) as resp:
if resp.status >= 400:
logger.warning("screen MCP %s failed: HTTP %s", tool_name, resp.status)
return False
body = await resp.text()
try:
parsed = json.loads(body)
if isinstance(parsed, dict) and parsed.get("error"):
logger.warning("screen MCP %s JSON-RPC error: %s", tool_name, parsed["error"])
return False
except Exception:
pass
return True
except Exception as exc:
logger.warning("screen MCP %s failed: %s", tool_name, exc)
return False
async def _send_screen_text(self, screen_url: Optional[str], text: str, *, clear: bool = False) -> None:
if not screen_url:
return
if clear:
await self._call_screen_mcp(screen_url, "clear_screen", {"color": 0})
await self._call_screen_mcp(screen_url, "draw_text", {"x": 8, "y": 8, "text": text[:900]})
async def _send_screen_audio_response(
self,
screen_url: Optional[str],
text: str,
*,
audio_dir: Path,
device_id: str,
volume: int = 80,
) -> bool:
"""Best-effort spoken follow-up for ACK-mode ESP32 voice turns."""
if not screen_url or not text.strip():
return False
try:
import base64
from tools.tts_tool import text_to_speech_tool
tts_raw = await asyncio.to_thread(text_to_speech_tool, text)
tts_result = json.loads(tts_raw)
src = str(tts_result.get("file_path") or "")
if not tts_result.get("success") or not src or not Path(src).exists():
logger.warning("screen audio TTS failed: %s", tts_result.get("error") or "missing TTS output")
return False
wav_path = await asyncio.to_thread(
self._convert_audio_to_esp32_wav,
src,
output_dir=audio_dir,
device_id=f"{device_id}_response",
)
wav_base64 = base64.b64encode(wav_path.read_bytes()).decode("ascii")
return await self._call_screen_mcp(
screen_url,
"play_audio_base64",
{"wav_base64": wav_base64, "volume": volume},
)
except Exception:
logger.exception("failed to play ESP32 screen audio response")
return False
@staticmethod
def _short_voice_summary(text: str, limit: int = 220) -> str:
clean = " ".join(str(text or "").split())
if len(clean) <= limit:
return clean
return clean[: max(0, limit - 1)].rstrip() + ""
async def _esp32_ack_wav(self, *, audio_dir: Path, device_id: str) -> Path:
"""Return a reusable quick acknowledgement WAV for low-latency voice UX."""
ack_path = audio_dir / "quick_ack.wav"
if ack_path.exists():
return ack_path
try:
from tools.tts_tool import text_to_speech_tool
tts_raw = await asyncio.to_thread(text_to_speech_tool, "Got it. Im working on that.")
tts_result = json.loads(tts_raw)
src = str(tts_result.get("file_path") or "")
if tts_result.get("success") and src and Path(src).exists():
converted = self._convert_audio_to_esp32_wav(src, output_dir=audio_dir, device_id=f"{device_id}_ack")
converted.replace(ack_path)
return ack_path
except Exception:
logger.exception("failed to generate spoken ESP32 ack; falling back to tone")
return make_ack_wav(ack_path)
def _append_esp32_voice_audit(self, audio_dir: Path, event: Dict[str, Any]) -> None:
try:
log_path = audio_dir / "voice_requests.jsonl"
with log_path.open("a", encoding="utf-8") as handle:
handle.write(json.dumps(event, default=str) + "\n")
except Exception:
logger.debug("failed to append ESP32 voice audit log", exc_info=True)
async def _run_esp32_voice_background(
self,
*,
transcript: str,
device_id: str,
instructions: Optional[str],
audio_dir: Path,
screen_url: Optional[str],
) -> None:
"""Run the slow ESP32 voice turn after the device already heard an ACK."""
try:
await self._send_screen_text(
screen_url,
f"Heard: {self._short_voice_summary(transcript, 180)}\n\nWorking…",
clear=True,
)
screen_hint = (
"\n\nThis request came from an ESP32 voice/screen device. "
"Keep the spoken follow-up short. The gateway will display the full answer on the initiating screen when possible."
)
merged_instructions = (instructions or "") + screen_hint
response_data, final_text = await self._run_esp32_voice_turn(
transcript=transcript,
device_id=device_id,
instructions=merged_instructions,
)
if not final_text.strip():
final_text = self._extract_response_text(response_data) or "I finished, but I do not have a text answer."
display_text = final_text
if len(display_text) > 1200:
display_text = self._short_voice_summary(display_text, 900) + "\n\nFull answer is long; WhatsApp delivery is the next wiring step."
await self._send_screen_text(screen_url, display_text, clear=True)
await self._send_screen_audio_response(screen_url, final_text, audio_dir=audio_dir, device_id=device_id)
except Exception:
logger.exception("ESP32 background voice turn failed")
await self._send_screen_text(screen_url, "Hermes hit an error while preparing the full answer.", clear=True)
@staticmethod
def _extract_response_text(response_data: Dict[str, Any]) -> str:
chunks: List[str] = []
for item in response_data.get("output") or []:
if not isinstance(item, dict) or item.get("type") != "message":
continue
for part in item.get("content") or []:
if isinstance(part, dict) and part.get("type") in {"output_text", "text"}:
text = part.get("text")
if isinstance(text, str) and text:
chunks.append(text)
return "\n".join(chunks).strip()
async def _run_esp32_voice_turn(
self,
*,
transcript: str,
device_id: str,
instructions: Optional[str] = None,
) -> Tuple[Dict[str, Any], str]:
"""Run one persistent Responses-style turn for an ESP32 voice device."""
conversation = f"esp32:{device_id}"
previous_response_id = self._response_store.get_conversation(conversation)
conversation_history: List[Dict[str, Any]] = []
stored_session_id = None
stored_instructions = None
if previous_response_id:
stored = self._response_store.get(previous_response_id)
if stored is not None:
conversation_history = list(stored.get("conversation_history", []))
stored_session_id = stored.get("session_id")
stored_instructions = stored.get("instructions")
if instructions is None:
instructions = stored_instructions
session_id = stored_session_id or str(uuid.uuid4())
user_message = f'[Voice input from ESP32 device "{device_id}"]\n{transcript}'
result, usage = await self._run_agent(
user_message=user_message,
conversation_history=conversation_history,
ephemeral_system_prompt=instructions,
session_id=session_id,
gateway_session_key=conversation,
)
final_response = result.get("final_response", "") or result.get("error", "") or "(No response generated)"
response_id = f"resp_{uuid.uuid4().hex[:28]}"
created_at = int(time.time())
full_history = self._build_response_conversation_history(conversation_history, user_message, result, final_response)
output_start_index = self._response_messages_turn_start_index(conversation_history, user_message, result)
output_items = self._extract_output_items(result, start_index=output_start_index)
response_data = {
"id": response_id,
"object": "response",
"status": "completed",
"created_at": created_at,
"model": self._model_name,
"output": output_items,
"usage": {
"input_tokens": usage.get("input_tokens", 0),
"output_tokens": usage.get("output_tokens", 0),
"total_tokens": usage.get("total_tokens", 0),
},
}
self._response_store.put(response_id, {
"response": response_data,
"conversation_history": full_history,
"instructions": instructions,
"session_id": session_id,
})
self._response_store.set_conversation(conversation, response_id)
return response_data, final_response
async def _handle_esp32_voice(self, request: Any) -> Any:
"""POST /api/esp32/voice — receive audio, run Hermes, return TTS audio."""
if web is None:
raise RuntimeError("aiohttp is required for the ESP32 voice endpoint")
auth_err = self._check_auth(request)
if auth_err:
return auth_err
device_id = (request.headers.get("X-Device-ID") or request.query.get("device_id") or "default").strip()[:80] or "default"
try:
audio_bytes = await request.read()
except Exception as exc:
return web.json_response(openai_error(f"Failed to read audio body: {exc}"), status=400)
if not audio_bytes:
return web.json_response(openai_error("Empty audio body", code="empty_audio"), status=400)
if len(audio_bytes) > ESP32_AUDIO_MAX_BYTES:
return web.json_response(
openai_error(f"Audio body is too large ({len(audio_bytes)} bytes); max is {ESP32_AUDIO_MAX_BYTES}", code="audio_too_large"),
status=413,
)
suffix = self._audio_extension_for_content_type(request.headers.get("Content-Type", ""))
try:
from hermes_constants import get_hermes_dir
audio_dir = get_hermes_dir("cache/audio", "audio_cache") / "esp32"
except Exception:
audio_dir = Path.home() / ".hermes" / "cache" / "audio" / "esp32"
audio_dir.mkdir(parents=True, exist_ok=True)
safe_device = re.sub(r"[^A-Za-z0-9_.-]+", "_", device_id)[:80] or "default"
audio_path = audio_dir / f"{safe_device}_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}{suffix}"
audio_path.write_bytes(audio_bytes)
audit_event: Dict[str, Any] = {
"timestamp": time.time(),
"device_id": device_id,
"source_ip": getattr(request, "remote", "") or "",
"forwarded_for": request.headers.get("X-Forwarded-For", ""),
"real_ip": request.headers.get("X-Real-IP", ""),
"user_agent": request.headers.get("User-Agent", ""),
"reply_mode": self._esp32_reply_mode(request),
"screen_device": request.headers.get("X-Hermes-Screen-Device") or request.query.get("screen_device") or "",
"screen_url": request.headers.get("X-Hermes-Screen-Url") or request.headers.get("X-Screen-MCP-Url") or request.query.get("screen_url") or "",
"content_type": request.headers.get("Content-Type", ""),
"audio_bytes": len(audio_bytes),
"audio_path": str(audio_path),
"status": "received",
}
self._append_esp32_voice_audit(audio_dir, audit_event)
if self._esp32_reply_mode(request) == "ack":
screen_url = self._resolve_esp32_screen_url(request, device_id)
async def _ack_background() -> None:
try:
from tools.transcription_tools import transcribe_audio
stt_result_bg = await asyncio.wait_for(asyncio.to_thread(transcribe_audio, str(audio_path)), timeout=45)
if not stt_result_bg.get("success"):
failed_event = dict(audit_event)
failed_event.update({"status": "transcription_failed", "error": stt_result_bg.get("error") or "Transcription failed", "timestamp": time.time()})
self._append_esp32_voice_audit(audio_dir, failed_event)
await self._send_screen_text(screen_url, "I heard audio, but transcription failed. Try again.", clear=True)
return
transcript_bg = str(stt_result_bg.get("transcript") or "").strip()
if not transcript_bg:
empty_event = dict(audit_event)
empty_event.update({"status": "empty_transcript", "transcript": "", "timestamp": time.time()})
self._append_esp32_voice_audit(audio_dir, empty_event)
await self._send_screen_text(screen_url, "I heard audio, but got no words. Try again.", clear=True)
return
transcript_event = dict(audit_event)
transcript_event.update({"status": "transcribed", "transcript": transcript_bg, "timestamp": time.time()})
self._append_esp32_voice_audit(audio_dir, transcript_event)
await self._run_esp32_voice_background(
transcript=transcript_bg,
device_id=device_id,
instructions=request.headers.get("X-Hermes-Instructions") or None,
audio_dir=audio_dir,
screen_url=screen_url,
)
except asyncio.TimeoutError:
timeout_event = dict(audit_event)
timeout_event.update({"status": "transcription_timeout", "error": "Transcription exceeded 45s", "timestamp": time.time()})
self._append_esp32_voice_audit(audio_dir, timeout_event)
except Exception as exc:
logger.exception("ESP32 ack-mode background processing failed")
failed_event = dict(audit_event)
failed_event.update({"status": "background_exception", "error": str(exc), "timestamp": time.time()})
self._append_esp32_voice_audit(audio_dir, failed_event)
task = asyncio.create_task(_ack_background())
if hasattr(self, "_background_tasks"):
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.discard)
ack_path = await self._esp32_ack_wav(audio_dir=audio_dir, device_id=device_id)
return web.FileResponse(
ack_path,
headers={
"X-Hermes-Conversation": f"esp32:{device_id}",
"X-Hermes-Reply-Mode": "ack",
"X-Hermes-Screen-Url": screen_url or "",
"Content-Type": "audio/wav",
},
)
try:
from tools.transcription_tools import transcribe_audio
stt_result = await asyncio.to_thread(transcribe_audio, str(audio_path))
except Exception as exc:
logger.exception("ESP32 transcription failed")
failed_event = dict(audit_event)
failed_event.update({"status": "transcription_exception", "error": str(exc), "timestamp": time.time()})
self._append_esp32_voice_audit(audio_dir, failed_event)
return web.json_response(openai_error(f"Transcription failed: {exc}"), status=500)
if not stt_result.get("success"):
failed_event = dict(audit_event)
failed_event.update({"status": "transcription_failed", "error": stt_result.get("error") or "Transcription failed", "timestamp": time.time()})
self._append_esp32_voice_audit(audio_dir, failed_event)
return web.json_response(openai_error(stt_result.get("error") or "Transcription failed", code="transcription_failed"), status=502)
transcript = str(stt_result.get("transcript") or "").strip()
if not transcript:
empty_event = dict(audit_event)
empty_event.update({"status": "empty_transcript", "transcript": "", "timestamp": time.time()})
self._append_esp32_voice_audit(audio_dir, empty_event)
return web.json_response(openai_error("Transcription was empty", code="empty_transcript"), status=422)
transcript_event = dict(audit_event)
transcript_event.update({"status": "transcribed", "transcript": transcript, "timestamp": time.time()})
self._append_esp32_voice_audit(audio_dir, transcript_event)
try:
response_data, final_text = await self._run_esp32_voice_turn(
transcript=transcript,
device_id=device_id,
instructions=request.headers.get("X-Hermes-Instructions") or None,
)
except Exception as exc:
logger.exception("ESP32 agent turn failed")
return web.json_response(openai_error(f"Agent turn failed: {exc}"), status=500)
if not final_text.strip():
final_text = self._extract_response_text(response_data) or "I heard you, but I do not have a spoken answer."
try:
from tools.tts_tool import text_to_speech_tool
tts_raw = await asyncio.to_thread(text_to_speech_tool, final_text)
tts_result = json.loads(tts_raw)
except Exception as exc:
logger.exception("ESP32 TTS failed")
return web.json_response(
openai_error(f"TTS failed after response was generated: {exc}", code="tts_failed"),
status=500,
headers={"X-Hermes-Transcript": self._safe_audio_header(transcript, 1000), "X-Hermes-Text-Response": self._safe_audio_header(final_text, 2000)},
)
if not tts_result.get("success"):
return web.json_response(
openai_error(tts_result.get("error") or "TTS failed", code="tts_failed"),
status=502,
headers={"X-Hermes-Transcript": self._safe_audio_header(transcript, 1000), "X-Hermes-Text-Response": self._safe_audio_header(final_text, 2000)},
)
tts_path = str(tts_result.get("file_path") or "")
if not tts_path or not Path(tts_path).exists():
return web.json_response(openai_error("TTS succeeded but no audio file was produced", code="tts_file_missing"), status=500)
try:
response_audio_path = self._convert_audio_to_esp32_wav(tts_path, output_dir=audio_dir, device_id=device_id)
except Exception as exc:
logger.exception("ESP32 WAV normalization failed")
return web.json_response(
openai_error(f"Could not convert TTS audio to ESP32 WAV: {exc}", code="wav_conversion_failed"),
status=500,
headers={"X-Hermes-Transcript": self._safe_audio_header(transcript, 1000), "X-Hermes-Text-Response": self._safe_audio_header(final_text, 2000)},
)
return web.FileResponse(
response_audio_path,
headers={
"X-Hermes-Transcript": self._safe_audio_header(transcript, 1000),
"X-Hermes-Text-Response": self._safe_audio_header(final_text, 2000),
"X-Hermes-Response-Id": str(response_data.get("id") or ""),
"X-Hermes-Conversation": f"esp32:{device_id}",
"X-Hermes-TTS-Source-Type": self._audio_response_mime(tts_path),
"Content-Type": "audio/wav",
},
)
async def _handle_esp32_voice_websocket(self, request: Any) -> Any:
"""GET /api/esp32/voice/ws — real-time WebSocket audio streaming and status route."""
if web is None:
raise RuntimeError("aiohttp is required for the ESP32 voice websocket endpoint")
# 1. Check authentication first (HTTP headers)
auth_err = self._check_auth(request)
if auth_err:
return auth_err
# Prepare websocket response
ws = web.WebSocketResponse(heartbeat=None)
await ws.prepare(request)
device_id = (request.headers.get("X-Device-ID") or request.query.get("device_id") or "default").strip()[:80] or "default"
# 2. Receive JSON start frame
try:
msg = await asyncio.wait_for(ws.receive(), timeout=10)
if msg.type != web.WSMsgType.TEXT:
await ws.send_json({"event": "error", "message": "Expected text start frame", "code": "protocol_error"})
await ws.close()
return ws
data = json.loads(msg.data)
if data.get("event") != "start":
await ws.send_json({"event": "error", "message": "Expected start event", "code": "protocol_error"})
await ws.close()
return ws
if "device_id" in data:
device_id = str(data["device_id"]).strip()[:80] or device_id
except asyncio.TimeoutError:
await ws.close()
return ws
except Exception as exc:
try:
await ws.send_json({"event": "error", "message": f"Invalid start payload: {exc}", "code": "protocol_error"})
except Exception:
pass
await ws.close()
return ws
# Send ready status
await ws.send_json({"event": "ready"})
await ws.send_json({"event": "listening"})
try:
from hermes_constants import get_hermes_dir
audio_dir = get_hermes_dir("cache/audio", "audio_cache") / "esp32"
except Exception:
audio_dir = Path.home() / ".hermes" / "cache" / "audio" / "esp32"
audio_dir.mkdir(parents=True, exist_ok=True)
safe_device = re.sub(r"[^A-Za-z0-9_.-]+", "_", device_id)[:80] or "default"
raw_path = audio_dir / f"ws_{safe_device}_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}.s16le"
total_bytes = 0
start_time = time.time()
last_frame_time = time.time()
cancelled = False
timings = {"ws_accepted": start_time}
try:
with open(raw_path, "wb") as f:
while True:
now = time.time()
elapsed = now - last_frame_time
if elapsed > 10.0:
await ws.send_json({"event": "error", "message": "Idle timeout exceeded", "code": "timeout"})
break
if now - start_time > 60.0:
await ws.send_json({"event": "error", "message": "Max session duration exceeded", "code": "duration_limit"})
break
timeout_left = min(10.0 - elapsed, 60.0 - (now - start_time))
if timeout_left <= 0:
break
try:
msg = await asyncio.wait_for(ws.receive(), timeout=timeout_left)
except asyncio.TimeoutError:
await ws.send_json({"event": "error", "message": "Idle timeout exceeded", "code": "timeout"})
break
last_frame_time = time.time()
if msg.type == web.WSMsgType.BINARY:
if total_bytes + len(msg.data) > 8_000_000:
await ws.send_json({"event": "error", "message": "Max audio size exceeded", "code": "size_limit"})
break
f.write(msg.data)
total_bytes += len(msg.data)
if "first_audio" not in timings:
timings["first_audio"] = last_frame_time
elif msg.type == web.WSMsgType.TEXT:
try:
data = json.loads(msg.data)
event = data.get("event")
if event == "stop":
timings["stop_received"] = last_frame_time
break
elif event == "cancel":
cancelled = True
break
except Exception:
pass
elif msg.type in (web.WSMsgType.CLOSE, web.WSMsgType.CLOSING):
break
elif msg.type == web.WSMsgType.ERROR:
break
except Exception as exc:
logger.exception("Error in websocket audio loop")
try:
await ws.send_json({"event": "error", "message": f"Internal server error: {exc}", "code": "server_error"})
except Exception:
pass
raw_path.unlink(missing_ok=True)
await ws.close()
return ws
if cancelled or ws.closed or total_bytes < 3200 or "stop_received" not in timings:
raw_path.unlink(missing_ok=True)
if not ws.closed:
if total_bytes < 3200:
await ws.send_json({"event": "error", "message": "Audio recording too short", "code": "audio_too_short"})
else:
await ws.send_json({"event": "error", "message": "Session cancelled", "code": "cancelled"})
await ws.close()
return ws
wav_path = raw_path.with_suffix(".wav")
try:
with wave.open(str(wav_path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16000)
wav.writeframes(raw_path.read_bytes())
finally:
raw_path.unlink(missing_ok=True)
timings["wav_finalized"] = time.time()
try:
from tools.transcription_tools import transcribe_audio
stt_result = await asyncio.wait_for(
asyncio.to_thread(transcribe_audio, str(wav_path)),
timeout=45
)
timings["stt_done"] = time.time()
if not stt_result.get("success"):
await ws.send_json({"event": "error", "message": stt_result.get("error") or "Transcription failed", "code": "stt_failed"})
await ws.close()
return ws
transcript = str(stt_result.get("transcript") or "").strip()
if not transcript:
await ws.send_json({"event": "error", "message": "Transcription empty", "code": "empty_transcript"})
await ws.close()
return ws
await ws.send_json({"event": "transcript", "text": transcript})
await ws.send_json({"event": "thinking"})
response_data, final_text = await self._run_esp32_voice_turn(
transcript=transcript,
device_id=device_id,
instructions=request.headers.get("X-Hermes-Instructions") or None,
)
timings["agent_done"] = time.time()
await ws.send_json({"event": "response_text", "text": final_text})
from tools.tts_tool import text_to_speech_tool
tts_raw = await asyncio.to_thread(text_to_speech_tool, final_text)
tts_result = json.loads(tts_raw)
timings["tts_done"] = time.time()
if not tts_result.get("success"):
await ws.send_json({"event": "error", "message": tts_result.get("error") or "TTS generation failed", "code": "tts_failed"})
await ws.close()
return ws
tts_path = str(tts_result.get("file_path") or "")
if not tts_path or not Path(tts_path).exists():
await ws.send_json({"event": "error", "message": "TTS file missing", "code": "tts_file_missing"})
await ws.close()
return ws
response_audio_path = self._convert_audio_to_esp32_wav(
tts_path,
output_dir=audio_dir,
device_id=device_id
)
wav_bytes = response_audio_path.read_bytes()
await ws.send_json({
"event": "audio_start",
"content_type": "audio/wav",
"bytes": len(wav_bytes)
})
pos = 0
chunk_size = 4096
while pos < len(wav_bytes):
chunk = wav_bytes[pos : pos + chunk_size]
await ws.send_bytes(chunk)
pos += chunk_size
await ws.send_json({"event": "audio_end"})
await ws.send_json({"event": "done"})
timings["audio_sent"] = time.time()
except Exception as exc:
logger.exception("Error processing voice session")
try:
await ws.send_json({"event": "error", "message": f"Processing error: {exc}", "code": "processing_failed"})
except Exception:
pass
finally:
wav_path.unlink(missing_ok=True)
try:
await ws.close()
except Exception:
pass
if "audio_sent" in timings:
duration = timings["audio_sent"] - timings["ws_accepted"]
status = "completed"
else:
duration = time.time() - timings["ws_accepted"]
status = "failed"
logger.info("ESP32 Voice WebSocket Session Completed. Timings: %s", timings)
audit_event = {
"timestamp": time.time(),
"device_id": device_id,
"source_ip": getattr(request, "remote", "") or "",
"duration": duration,
"byte_count": total_bytes,
"timings": timings,
"status": status
}
self._append_esp32_voice_audit(audio_dir, audit_event)
return ws
def register_esp32_voice_route(app: Any, api_server: HermesESP32VoiceGatewayMixin) -> None:
"""Register the endpoint on an aiohttp app."""
app.router.add_post("/api/esp32/voice", api_server._handle_esp32_voice)
app.router.add_get("/api/esp32/voice/ws", api_server._handle_esp32_voice_websocket)
+63
View File
@@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""Smoke tests for hermes_voice_gateway helper code.
These tests avoid live Hermes/STT/TTS calls. They verify the pieces that are
safe to exercise locally: content-type mapping, response MIME mapping, header
sanitization, fallback ACK WAV generation, and ffmpeg WAV normalization when
ffmpeg is available.
"""
from __future__ import annotations
import shutil
import tempfile
import wave
from pathlib import Path
from api_server_endpoint import (
audio_extension_for_content_type,
audio_response_mime,
convert_audio_to_esp32_wav,
make_ack_wav,
safe_audio_header,
)
def _assert_wav_16k_mono_s16(path: Path) -> None:
with wave.open(str(path), "rb") as wav:
assert wav.getnchannels() == 1, wav.getnchannels()
assert wav.getsampwidth() == 2, wav.getsampwidth()
assert wav.getframerate() == 16000, wav.getframerate()
assert wav.getnframes() > 0, wav.getnframes()
def main() -> None:
assert audio_extension_for_content_type("audio/wav") == ".wav"
assert audio_extension_for_content_type("audio/wav; charset=binary") == ".wav"
assert audio_extension_for_content_type("audio/webm") == ".webm"
assert audio_extension_for_content_type("application/unknown") == ".wav"
assert audio_response_mime("reply.wav") == "audio/wav"
assert audio_response_mime("reply.mp3") == "audio/mpeg"
assert audio_response_mime("reply.bin") == "application/octet-stream"
assert safe_audio_header("hello\nworld\r\x00!", 100) == "hello world !"
assert safe_audio_header("abcdef", 3) == "abc"
with tempfile.TemporaryDirectory() as tmp:
tmpdir = Path(tmp)
ack = make_ack_wav(tmpdir / "ack.wav")
_assert_wav_16k_mono_s16(ack)
if shutil.which("ffmpeg"):
converted = convert_audio_to_esp32_wav(str(ack), output_dir=tmpdir, device_id="kitchen/button")
_assert_wav_16k_mono_s16(converted)
assert "kitchen_button" in converted.name
else:
print("ffmpeg not found; skipped conversion path")
print("hermes_voice_gateway smoke tests passed")
if __name__ == "__main__":
main()
+230
View File
@@ -0,0 +1,230 @@
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
"""Integration and unit tests for the ESP32 Voice WebSocket gateway endpoint."""
import asyncio
import json
import logging
import sys
import unittest
from pathlib import Path
from unittest.mock import MagicMock
# 1. Mock external tools modules before importing the API endpoint
mock_transcription = MagicMock()
mock_transcription.transcribe_audio.return_value = {"success": True, "transcript": "Hello world"}
sys.modules["tools.transcription_tools"] = mock_transcription
mock_tts = MagicMock()
mock_tts.text_to_speech_tool.return_value = json.dumps({"success": True, "file_path": __file__})
sys.modules["tools.tts_tool"] = mock_tts
# Mock aiohttp
from aiohttp import web, ClientSession
# Import gateway mixin and register function
sys.path.append(str(Path(__file__).parent))
from api_server_endpoint import HermesESP32VoiceGatewayMixin, register_esp32_voice_route
logging.basicConfig(level=logging.INFO)
logger = logging.getLogger(__name__)
class MockHermesServer(HermesESP32VoiceGatewayMixin):
def __init__(self):
self.auth_token = "valid_secret_key"
self._background_tasks = set()
def _check_auth(self, request):
auth_header = request.headers.get("Authorization")
if not auth_header or auth_header != f"Bearer {self.auth_token}":
return web.json_response({"error": "Unauthorized"}, status=401)
return None
async def _run_esp32_voice_turn(self, transcript, device_id, instructions=None):
return {"id": "resp_test123"}, f"Echo: {transcript}"
@staticmethod
def _convert_audio_to_esp32_wav(input_path, *, output_dir, device_id):
out_path = Path(output_dir) / "mock_response.wav"
import wave
with wave.open(str(out_path), "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16000)
wav.writeframes(b"\x00" * 8000) # Dummy PCM data
return out_path
class TestWebSocketGateway(unittest.IsolatedAsyncioTestCase):
async def asyncSetUp(self):
self.server = MockHermesServer()
self.app = web.Application()
register_esp32_voice_route(self.app, self.server)
self.runner = web.AppRunner(self.app)
await self.runner.setup()
self.site = web.TCPSite(self.runner, "127.0.0.1", 0)
await self.site.start()
self.port = self.runner.addresses[0][1]
self.base_url = f"http://127.0.0.1:{self.port}"
self.ws_url = f"ws://127.0.0.1:{self.port}/api/esp32/voice/ws"
async def asyncTearDown(self):
await self.runner.cleanup()
async def test_auth_failure_missing_token(self):
"""Verify authentication failure (missing token) returns 401."""
async with ClientSession() as session:
try:
async with session.ws_connect(self.ws_url) as ws:
pass
self.fail("Connection should have been rejected with 401")
except Exception as e:
# HTTP status code should be checked
pass
# Test HTTP request directly to assert 401
async with session.get(self.ws_url) as resp:
self.assertEqual(resp.status, 401)
body = await resp.json()
self.assertEqual(body["error"], "Unauthorized")
async def test_auth_failure_bad_token(self):
"""Verify authentication failure (bad token) returns 401."""
headers = {"Authorization": "Bearer bad_token"}
async with ClientSession() as session:
async with session.get(self.ws_url, headers=headers) as resp:
self.assertEqual(resp.status, 401)
async def test_successful_streaming_flow(self):
"""Verify a normal WebSocket start, stream, stop, and response flow."""
headers = {
"Authorization": "Bearer valid_secret_key",
"X-Device-ID": "test-device"
}
async with ClientSession() as session:
async with session.ws_connect(self.ws_url, headers=headers) as ws:
# 1. Send start event
await ws.send_str(json.dumps({
"event": "start",
"device_id": "test-device",
"sample_rate": 16000
}))
# Read events
ready_msg = await ws.receive_json()
self.assertEqual(ready_msg["event"], "ready")
listening_msg = await ws.receive_json()
self.assertEqual(listening_msg["event"], "listening")
# 2. Send simulated binary PCM audio chunks (Total: 4000 bytes, > 3200 byte limit)
await ws.send_bytes(b"\x00" * 2000)
await ws.send_bytes(b"\x00" * 2000)
# 3. Send stop event
await ws.send_str(json.dumps({"event": "stop"}))
# 4. Check transcription event
transcript_msg = await ws.receive_json()
self.assertEqual(transcript_msg["event"], "transcript")
self.assertEqual(transcript_msg["text"], "Hello world")
# 5. Check thinking event
thinking_msg = await ws.receive_json()
self.assertEqual(thinking_msg["event"], "thinking")
# 6. Check response_text event
resp_text_msg = await ws.receive_json()
self.assertEqual(resp_text_msg["event"], "response_text")
self.assertEqual(resp_text_msg["text"], "Echo: Hello world")
# 7. Check audio_start event
audio_start_msg = await ws.receive_json()
self.assertEqual(audio_start_msg["event"], "audio_start")
self.assertEqual(audio_start_msg["content_type"], "audio/wav")
audio_bytes_len = audio_start_msg["bytes"]
# 8. Check binary audio chunks
received_audio = b""
while True:
msg = await ws.receive()
if msg.type == web.WSMsgType.BINARY:
received_audio += msg.data
elif msg.type == web.WSMsgType.TEXT:
data = json.loads(msg.data)
if data["event"] == "audio_end":
break
else:
self.fail(f"Unexpected msg type: {msg.type}")
self.assertEqual(len(received_audio), audio_bytes_len)
# 9. Check done event
done_msg = await ws.receive_json()
self.assertEqual(done_msg["event"], "done")
async def test_disconnect_cleanup(self):
"""Verify that raw files are cleaned up if client disconnects abruptly."""
headers = {
"Authorization": "Bearer valid_secret_key",
"X-Device-ID": "disconnect-device"
}
# We need to find the file created during this session.
# Let's verify files in ~/.hermes/cache/audio/esp32 or /tmp/esp32
audio_dir = Path.home() / ".hermes" / "cache" / "audio" / "esp32"
existing_files_before = set(audio_dir.glob("ws_disconnect_device_*.s16le")) if audio_dir.exists() else set()
async with ClientSession() as session:
async with session.ws_connect(self.ws_url, headers=headers) as ws:
await ws.send_str(json.dumps({
"event": "start",
"device_id": "disconnect-device"
}))
await ws.receive_json() # ready
await ws.receive_json() # listening
# Send some bytes
await ws.send_bytes(b"\x00" * 1000)
# Abrupt close/exit without 'stop'
await ws.close()
# Allow loop to process close
await asyncio.sleep(0.5)
existing_files_after = set(audio_dir.glob("ws_disconnect_device_*.s16le")) if audio_dir.exists() else set()
new_files = existing_files_after - existing_files_before
# Verify no orphaned raw files remaining
self.assertEqual(len(new_files), 0, f"Found orphaned temp files: {new_files}")
async def test_too_short_audio(self):
"""Verify session is rejected with audio_too_short error if total PCM is below 3200 bytes."""
headers = {
"Authorization": "Bearer valid_secret_key",
"X-Device-ID": "short-device"
}
async with ClientSession() as session:
async with session.ws_connect(self.ws_url, headers=headers) as ws:
await ws.send_str(json.dumps({
"event": "start",
"device_id": "short-device"
}))
await ws.receive_json() # ready
await ws.receive_json() # listening
# Send only 1000 bytes (less than 3200 bytes limit)
await ws.send_bytes(b"\x00" * 1000)
await ws.send_str(json.dumps({"event": "stop"}))
error_msg = await ws.receive_json()
self.assertEqual(error_msg["event"], "error")
self.assertEqual(error_msg["code"], "audio_too_short")
if __name__ == "__main__":
unittest.main()
+5
View File
@@ -0,0 +1,5 @@
#import <UIKit/UIKit.h>
@interface IMCAppDelegate : UIResponder <UIApplicationDelegate>
@property (nonatomic, strong) UIWindow *window;
@end
+23
View File
@@ -0,0 +1,23 @@
#import "IMCAppDelegate.h"
#import "IMCViewController.h"
@implementation IMCAppDelegate
- (BOOL)application:(UIApplication *)application
didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
application.idleTimerDisabled = YES;
self.window = [[UIWindow alloc] initWithFrame:[UIScreen mainScreen].bounds];
self.window.rootViewController = [[IMCViewController alloc] init];
[self.window makeKeyAndVisible];
return YES;
}
- (void)applicationDidBecomeActive:(UIApplication *)application {
application.idleTimerDisabled = YES;
}
- (void)applicationWillTerminate:(UIApplication *)application {
application.idleTimerDisabled = NO;
}
@end
+10
View File
@@ -0,0 +1,10 @@
#import <UIKit/UIKit.h>
@interface IMCCanvasView : UIView
- (void)clearWithColor:(NSInteger)color;
- (void)drawText:(NSString *)text x:(CGFloat)x y:(CGFloat)y size:(NSInteger)size;
- (void)drawImageData:(NSData *)data x:(CGFloat)x y:(CGFloat)y;
- (void)displayMonochromeFrameBuffer:(NSData *)frameData;
- (BOOL)displayRGB565FrameBuffer:(NSData *)frameData width:(NSUInteger)width height:(NSUInteger)height;
- (NSString *)snapshotPNGBase64;
@end
+218
View File
@@ -0,0 +1,218 @@
#import "IMCCanvasView.h"
@interface IMCCanvasView ()
@property (nonatomic, assign) NSInteger clearColor;
@property (nonatomic, strong) NSMutableArray *commands;
@property (nonatomic, strong) UIImage *streamImage;
@end
@implementation IMCCanvasView
- (instancetype)initWithFrame:(CGRect)frame {
self = [super initWithFrame:frame];
if (self) {
_clearColor = 0;
_commands = [NSMutableArray array];
self.opaque = YES;
}
return self;
}
- (void)clearWithColor:(NSInteger)color {
self.clearColor = color == 1 ? 1 : 0;
self.streamImage = nil;
[self.commands removeAllObjects];
[self setNeedsDisplay];
}
- (void)drawText:(NSString *)text x:(CGFloat)x y:(CGFloat)y size:(NSInteger)size {
if (!text) {
text = @"";
}
self.streamImage = nil;
[self.commands addObject:@{
@"type": @"text",
@"text": text,
@"x": @(x),
@"y": @(y),
@"size": @(size == 2 ? 2 : 1)
}];
[self setNeedsDisplay];
}
- (void)drawImageData:(NSData *)data x:(CGFloat)x y:(CGFloat)y {
UIImage *image = [UIImage imageWithData:data];
if (!image) {
return;
}
self.streamImage = nil;
[self.commands addObject:@{
@"type": @"image",
@"image": image,
@"x": @(x),
@"y": @(y)
}];
[self setNeedsDisplay];
}
- (void)displayMonochromeFrameBuffer:(NSData *)frameData {
if (frameData.length < 15000) {
return;
}
const NSUInteger width = 400;
const NSUInteger height = 300;
NSMutableData *rgba = [NSMutableData dataWithLength:width * height * 4];
const uint8_t *source = frameData.bytes;
uint8_t *pixels = rgba.mutableBytes;
for (NSUInteger y = 0; y < height; y++) {
NSUInteger invY = height - 1 - y;
for (NSUInteger x = 0; x < width; x++) {
NSUInteger bx = x / 2;
NSUInteger by = invY / 4;
NSUInteger index = bx * 75 + by;
NSUInteger bit = 7 - ((invY % 4) * 2 + (x % 2));
BOOL on = (source[index] & (1 << bit)) != 0;
uint8_t value = on ? 255 : 0;
NSUInteger pixelIndex = (y * width + x) * 4;
pixels[pixelIndex + 0] = value;
pixels[pixelIndex + 1] = value;
pixels[pixelIndex + 2] = value;
pixels[pixelIndex + 3] = 255;
}
}
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)rgba);
CGImageRef cgImage = CGImageCreate(width,
height,
8,
32,
width * 4,
colorSpace,
kCGImageAlphaLast | kCGBitmapByteOrderDefault,
provider,
NULL,
false,
kCGRenderingIntentDefault);
if (cgImage) {
self.streamImage = [UIImage imageWithCGImage:cgImage];
[self.commands removeAllObjects];
[self setNeedsDisplay];
CGImageRelease(cgImage);
}
CGDataProviderRelease(provider);
CGColorSpaceRelease(colorSpace);
}
- (BOOL)displayRGB565FrameBuffer:(NSData *)frameData width:(NSUInteger)width height:(NSUInteger)height {
if (width == 0 || height == 0 || width > 2048 || height > 2048 ||
frameData.length != width * height * 2) {
return NO;
}
const uint8_t *source = frameData.bytes;
NSMutableData *rgba = [NSMutableData dataWithLength:width * height * 4];
uint8_t *pixels = rgba.mutableBytes;
for (NSUInteger i = 0; i < width * height; i++) {
// Network color frames use big-endian RGB565.
uint16_t value = ((uint16_t)source[i * 2] << 8) | source[i * 2 + 1];
uint8_t red5 = (value >> 11) & 0x1f;
uint8_t green6 = (value >> 5) & 0x3f;
uint8_t blue5 = value & 0x1f;
pixels[i * 4 + 0] = (red5 << 3) | (red5 >> 2);
pixels[i * 4 + 1] = (green6 << 2) | (green6 >> 4);
pixels[i * 4 + 2] = (blue5 << 3) | (blue5 >> 2);
pixels[i * 4 + 3] = 255;
}
CGColorSpaceRef colorSpace = CGColorSpaceCreateDeviceRGB();
CGDataProviderRef provider = CGDataProviderCreateWithCFData((__bridge CFDataRef)rgba);
CGImageRef cgImage = CGImageCreate(width,
height,
8,
32,
width * 4,
colorSpace,
kCGImageAlphaLast | kCGBitmapByteOrderDefault,
provider,
NULL,
false,
kCGRenderingIntentDefault);
if (cgImage) {
self.streamImage = [UIImage imageWithCGImage:cgImage];
[self.commands removeAllObjects];
[self setNeedsDisplay];
CGImageRelease(cgImage);
}
CGDataProviderRelease(provider);
CGColorSpaceRelease(colorSpace);
return cgImage != NULL;
}
- (NSString *)snapshotPNGBase64 {
UIGraphicsBeginImageContextWithOptions(self.bounds.size, YES, 0.0);
[self drawViewHierarchyInRect:self.bounds afterScreenUpdates:YES];
UIImage *image = UIGraphicsGetImageFromCurrentImageContext();
UIGraphicsEndImageContext();
NSData *pngData = UIImagePNGRepresentation(image);
return [pngData base64EncodedStringWithOptions:0];
}
- (CGPoint)pointForDeviceX:(CGFloat)x y:(CGFloat)y {
CGFloat scaleX = self.bounds.size.width / 400.0;
CGFloat scaleY = self.bounds.size.height / 300.0;
return CGPointMake(x * scaleX, y * scaleY);
}
- (void)drawRect:(CGRect)rect {
UIColor *background = self.clearColor == 1 ? [UIColor blackColor] : [UIColor whiteColor];
UIColor *foreground = self.clearColor == 1 ? [UIColor whiteColor] : [UIColor blackColor];
[background setFill];
UIRectFill(self.bounds);
if (self.streamImage) {
[self.streamImage drawInRect:self.bounds];
return;
}
CGContextRef context = UIGraphicsGetCurrentContext();
CGContextSetStrokeColorWithColor(context, foreground.CGColor);
CGContextSetLineWidth(context, 2.0);
CGContextStrokeRect(context, CGRectInset(self.bounds, 1.0, 1.0));
for (NSDictionary *command in self.commands) {
NSString *type = command[@"type"];
CGFloat x = [command[@"x"] doubleValue];
CGFloat y = [command[@"y"] doubleValue];
CGPoint point = [self pointForDeviceX:x y:y];
if ([type isEqualToString:@"text"]) {
NSInteger size = [command[@"size"] integerValue];
UIFont *font = [UIFont boldSystemFontOfSize:size == 2 ? 26.0 : 15.0];
NSDictionary *attrs = @{
NSFontAttributeName: font,
NSForegroundColorAttributeName: foreground
};
[command[@"text"] drawAtPoint:point withAttributes:attrs];
} else if ([type isEqualToString:@"image"]) {
UIImage *image = command[@"image"];
CGFloat maxWidth = self.bounds.size.width - point.x;
CGFloat maxHeight = self.bounds.size.height - point.y;
CGSize imageSize = image.size;
CGFloat scale = MIN(maxWidth / MAX(imageSize.width, 1.0),
maxHeight / MAX(imageSize.height, 1.0));
scale = MIN(scale, 1.0);
CGRect imageRect = CGRectMake(point.x,
point.y,
imageSize.width * scale,
imageSize.height * scale);
[image drawInRect:imageRect];
}
}
}
@end
+8
View File
@@ -0,0 +1,8 @@
#import <Foundation/Foundation.h>
@class IMCViewController;
@interface IMCMCPServer : NSObject
- (instancetype)initWithViewController:(IMCViewController *)viewController port:(NSInteger)port;
- (void)start;
@end
+163
View File
@@ -0,0 +1,163 @@
#import "IMCMCPServer.h"
#import "IMCViewController.h"
#import <arpa/inet.h>
#import <netinet/in.h>
#import <sys/socket.h>
#import <unistd.h>
@interface IMCMCPServer ()
@property (nonatomic, weak) IMCViewController *viewController;
@property (nonatomic, assign) NSInteger port;
@property (nonatomic, assign) BOOL running;
@end
@implementation IMCMCPServer
- (instancetype)initWithViewController:(IMCViewController *)viewController port:(NSInteger)port {
self = [super init];
if (self) {
_viewController = viewController;
_port = port;
}
return self;
}
- (void)start {
if (self.running) {
return;
}
self.running = YES;
[NSThread detachNewThreadSelector:@selector(serverLoop) toTarget:self withObject:nil];
}
- (void)serverLoop {
@autoreleasepool {
int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
if (serverSocket < 0) {
return;
}
int yes = 1;
setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons((uint16_t)self.port);
if (bind(serverSocket, (struct sockaddr *)&address, sizeof(address)) < 0) {
close(serverSocket);
return;
}
if (listen(serverSocket, 8) < 0) {
close(serverSocket);
return;
}
while (self.running) {
@autoreleasepool {
struct sockaddr_in clientAddress;
socklen_t clientLength = sizeof(clientAddress);
int clientSocket = accept(serverSocket,
(struct sockaddr *)&clientAddress,
&clientLength);
if (clientSocket >= 0) {
[self handleClientSocket:clientSocket];
close(clientSocket);
}
}
}
close(serverSocket);
}
}
- (void)handleClientSocket:(int)clientSocket {
NSMutableData *requestData = [NSMutableData data];
char buffer[2048];
while (requestData.length < 1024 * 1024) {
ssize_t count = recv(clientSocket, buffer, sizeof(buffer), 0);
if (count <= 0) {
break;
}
[requestData appendBytes:buffer length:(NSUInteger)count];
NSString *partial = [[NSString alloc] initWithData:requestData encoding:NSUTF8StringEncoding];
NSRange separator = [partial rangeOfString:@"\r\n\r\n"];
if (separator.location == NSNotFound) {
continue;
}
NSInteger contentLength = [self contentLengthFromHeader:partial];
NSUInteger bodyStart = separator.location + separator.length;
if (requestData.length >= bodyStart + MAX(contentLength, 0)) {
break;
}
}
NSString *request = [[NSString alloc] initWithData:requestData encoding:NSUTF8StringEncoding];
NSDictionary *responseObject = [self responseObjectForHTTPRequest:request];
NSData *responseJSON = [NSJSONSerialization dataWithJSONObject:responseObject options:0 error:nil];
if (!responseJSON) {
responseJSON = [@"{}" dataUsingEncoding:NSUTF8StringEncoding];
}
NSString *status = responseObject[@"httpStatus"] ?: @"200 OK";
NSMutableDictionary *bodyObject = [responseObject mutableCopy];
[bodyObject removeObjectForKey:@"httpStatus"];
responseJSON = [NSJSONSerialization dataWithJSONObject:bodyObject options:0 error:nil];
NSMutableData *responseData = [NSMutableData data];
NSString *header = [NSString stringWithFormat:
@"HTTP/1.1 %@\r\nContent-Type: application/json\r\nContent-Length: %lu\r\nConnection: close\r\n\r\n",
status,
(unsigned long)responseJSON.length];
[responseData appendData:[header dataUsingEncoding:NSUTF8StringEncoding]];
[responseData appendData:responseJSON];
send(clientSocket, responseData.bytes, responseData.length, 0);
}
- (NSDictionary *)responseObjectForHTTPRequest:(NSString *)request {
if (![request hasPrefix:@"POST /api/mcp "]) {
return @{@"httpStatus": @"404 Not Found",
@"jsonrpc": @"2.0",
@"error": @{@"code": @-32601, @"message": @"POST /api/mcp required"},
@"id": [NSNull null]};
}
NSRange separator = [request rangeOfString:@"\r\n\r\n"];
if (separator.location == NSNotFound) {
return [self parseError:@"Missing HTTP body."];
}
NSString *body = [request substringFromIndex:separator.location + separator.length];
NSData *bodyData = [body dataUsingEncoding:NSUTF8StringEncoding];
NSError *jsonError = nil;
NSDictionary *rpcRequest = [NSJSONSerialization JSONObjectWithData:bodyData options:0 error:&jsonError];
if (jsonError || ![rpcRequest isKindOfClass:NSDictionary.class]) {
return [self parseError:@"Invalid JSON-RPC object."];
}
return [self.viewController handleRPCRequest:rpcRequest];
}
- (NSDictionary *)parseError:(NSString *)message {
return @{@"jsonrpc": @"2.0",
@"error": @{@"code": @-32700, @"message": message ?: @"Parse error"},
@"id": [NSNull null]};
}
- (NSInteger)contentLengthFromHeader:(NSString *)request {
NSArray *lines = [request componentsSeparatedByString:@"\r\n"];
for (NSString *line in lines) {
if ([[line lowercaseString] hasPrefix:@"content-length:"]) {
return [[line substringFromIndex:15] integerValue];
}
}
return 0;
}
@end
+11
View File
@@ -0,0 +1,11 @@
#import <Foundation/Foundation.h>
@class IMCCanvasView;
@interface IMCVideoStreamServer : NSObject
- (instancetype)initWithCanvasView:(IMCCanvasView *)canvasView;
- (void)start;
- (NSDictionary *)streamStats;
- (void)resetStreamStats;
- (void)setDebugEnabled:(BOOL)enabled;
@end
+447
View File
@@ -0,0 +1,447 @@
#import "IMCVideoStreamServer.h"
#import "IMCCanvasView.h"
#import <arpa/inet.h>
#import <netinet/in.h>
#import <sys/socket.h>
#import <string.h>
#import <unistd.h>
static const NSInteger IMCFrameSize = 15000;
static const NSInteger IMCUDPChunkSize = 1000;
static const NSInteger IMCUDPChunkCount = 15;
static const NSInteger IMCColorStreamPort = 8083;
static const NSUInteger IMCColorHeaderSize = 16;
@interface IMCVideoStreamServer ()
@property (nonatomic, weak) IMCCanvasView *canvasView;
@property (nonatomic, assign) BOOL running;
@property (nonatomic, assign) BOOL debugEnabled;
@property (nonatomic, strong) NSDate *startedAt;
@property (nonatomic, strong) NSDate *lastFrameAt;
@property (nonatomic, copy) NSString *lastProtocol;
@property (nonatomic, assign) uint64_t tcpConnections;
@property (nonatomic, assign) uint64_t tcpFrames;
@property (nonatomic, assign) uint64_t tcpBytes;
@property (nonatomic, assign) uint64_t udpFrames;
@property (nonatomic, assign) uint64_t udpPackets;
@property (nonatomic, assign) uint64_t udpBytes;
@property (nonatomic, assign) uint64_t invalidUDPPackets;
@property (nonatomic, assign) uint64_t discoveryRequests;
@property (nonatomic, assign) uint64_t colorConnections;
@property (nonatomic, assign) uint64_t colorFrames;
@property (nonatomic, assign) uint64_t colorBytes;
@property (nonatomic, assign) NSUInteger lastColorWidth;
@property (nonatomic, assign) NSUInteger lastColorHeight;
@end
@implementation IMCVideoStreamServer
- (instancetype)initWithCanvasView:(IMCCanvasView *)canvasView {
self = [super init];
if (self) {
_canvasView = canvasView;
_startedAt = [NSDate date];
_lastProtocol = @"none";
}
return self;
}
- (void)start {
if (self.running) {
return;
}
self.running = YES;
[NSThread detachNewThreadSelector:@selector(tcpLoop) toTarget:self withObject:nil];
[NSThread detachNewThreadSelector:@selector(udpLoop) toTarget:self withObject:nil];
[NSThread detachNewThreadSelector:@selector(discoveryLoop) toTarget:self withObject:nil];
[NSThread detachNewThreadSelector:@selector(colorTCPLoop) toTarget:self withObject:nil];
}
- (BOOL)readExactly:(NSUInteger)length fromSocket:(int)socketFD intoData:(NSMutableData *)data {
[data setLength:length];
NSUInteger received = 0;
while (self.running && received < length) {
ssize_t count = recv(socketFD,
(uint8_t *)data.mutableBytes + received,
length - received,
0);
if (count <= 0) {
return NO;
}
received += (NSUInteger)count;
}
return received == length;
}
- (void)colorTCPLoop {
@autoreleasepool {
int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
if (serverSocket < 0) {
return;
}
int yes = 1;
setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons((uint16_t)IMCColorStreamPort);
if (bind(serverSocket, (struct sockaddr *)&address, sizeof(address)) < 0 ||
listen(serverSocket, 1) < 0) {
close(serverSocket);
return;
}
while (self.running) {
@autoreleasepool {
int clientSocket = accept(serverSocket, NULL, NULL);
if (clientSocket < 0) {
continue;
}
@synchronized (self) {
self.colorConnections += 1;
}
NSMutableData *header = [NSMutableData data];
while ([self readExactly:IMCColorHeaderSize
fromSocket:clientSocket
intoData:header]) {
const uint8_t *bytes = header.bytes;
if (memcmp(bytes, "IMCR", 4) != 0 || bytes[4] != 1 || bytes[5] != 1) {
break;
}
NSUInteger width = ((NSUInteger)bytes[6] << 8) | bytes[7];
NSUInteger height = ((NSUInteger)bytes[8] << 8) | bytes[9];
uint32_t payloadLength = ((uint32_t)bytes[10] << 24) |
((uint32_t)bytes[11] << 16) |
((uint32_t)bytes[12] << 8) |
bytes[13];
if (width == 0 || height == 0 || width > 2048 || height > 2048 ||
payloadLength != width * height * 2 || payloadLength > 8 * 1024 * 1024) {
break;
}
NSMutableData *payload = [NSMutableData data];
if (![self readExactly:payloadLength
fromSocket:clientSocket
intoData:payload]) {
break;
}
NSData *snapshot = [payload copy];
@synchronized (self) {
self.colorFrames += 1;
self.colorBytes += payloadLength;
self.lastColorWidth = width;
self.lastColorHeight = height;
self.lastProtocol = @"color_tcp";
self.lastFrameAt = [NSDate date];
}
dispatch_async(dispatch_get_main_queue(), ^{
[self.canvasView displayRGB565FrameBuffer:snapshot width:width height:height];
});
}
close(clientSocket);
}
}
close(serverSocket);
}
}
- (void)tcpLoop {
@autoreleasepool {
int serverSocket = socket(AF_INET, SOCK_STREAM, 0);
if (serverSocket < 0) {
return;
}
int yes = 1;
setsockopt(serverSocket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8081);
if (bind(serverSocket, (struct sockaddr *)&address, sizeof(address)) < 0) {
close(serverSocket);
return;
}
if (listen(serverSocket, 1) < 0) {
close(serverSocket);
return;
}
while (self.running) {
@autoreleasepool {
int clientSocket = accept(serverSocket, NULL, NULL);
if (clientSocket < 0) {
continue;
}
[self noteTCPConnection];
NSMutableData *frame = [NSMutableData dataWithLength:IMCFrameSize];
NSUInteger received = 0;
while (self.running) {
ssize_t count = recv(clientSocket,
(uint8_t *)frame.mutableBytes + received,
IMCFrameSize - received,
0);
if (count <= 0) {
break;
}
received += (NSUInteger)count;
[self noteTCPBytes:(NSUInteger)count];
if (received == IMCFrameSize) {
[self displayFrame:frame protocol:@"tcp"];
received = 0;
}
}
close(clientSocket);
}
}
close(serverSocket);
}
}
- (void)udpLoop {
@autoreleasepool {
int udpSocket = socket(AF_INET, SOCK_DGRAM, 0);
if (udpSocket < 0) {
return;
}
int yes = 1;
setsockopt(udpSocket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(8082);
if (bind(udpSocket, (struct sockaddr *)&address, sizeof(address)) < 0) {
close(udpSocket);
return;
}
NSMutableData *frame = [NSMutableData dataWithLength:IMCFrameSize];
uint8_t currentFrameID = 0;
uint16_t chunksReceived = 0;
BOOL hasFrame = NO;
uint8_t packet[1002];
while (self.running) {
ssize_t count = recvfrom(udpSocket, packet, sizeof(packet), 0, NULL, NULL);
if (count < 2) {
[self noteInvalidUDPPacket];
continue;
}
uint8_t frameID = packet[0];
uint8_t chunkIndex = packet[1];
if (chunkIndex >= IMCUDPChunkCount || count < 2 + IMCUDPChunkSize) {
[self noteInvalidUDPPacket];
continue;
}
[self noteUDPPacketBytes:(NSUInteger)count];
if (!hasFrame || frameID != currentFrameID) {
currentFrameID = frameID;
chunksReceived = 0;
hasFrame = YES;
}
memcpy((uint8_t *)frame.mutableBytes + chunkIndex * IMCUDPChunkSize,
packet + 2,
IMCUDPChunkSize);
chunksReceived |= (uint16_t)(1 << chunkIndex);
if (chunksReceived == 0x7fff) {
[self displayFrame:frame protocol:@"udp"];
chunksReceived = 0;
}
}
close(udpSocket);
}
}
- (void)discoveryLoop {
@autoreleasepool {
int udpSocket = socket(AF_INET, SOCK_DGRAM, 0);
if (udpSocket < 0) {
return;
}
int yes = 1;
setsockopt(udpSocket, SOL_SOCKET, SO_REUSEADDR, &yes, sizeof(yes));
setsockopt(udpSocket, SOL_SOCKET, SO_BROADCAST, &yes, sizeof(yes));
struct sockaddr_in address;
memset(&address, 0, sizeof(address));
address.sin_family = AF_INET;
address.sin_addr.s_addr = INADDR_ANY;
address.sin_port = htons(5000);
if (bind(udpSocket, (struct sockaddr *)&address, sizeof(address)) < 0) {
close(udpSocket);
return;
}
char buffer[64];
while (self.running) {
struct sockaddr_in clientAddress;
socklen_t clientLength = sizeof(clientAddress);
ssize_t count = recvfrom(udpSocket,
buffer,
sizeof(buffer),
0,
(struct sockaddr *)&clientAddress,
&clientLength);
if (count == 15 && memcmp(buffer, "DISCOVER_SCREEN", 15) == 0) {
[self noteDiscoveryRequest];
const char *reply = "SCREEN_IP_8080";
sendto(udpSocket,
reply,
strlen(reply),
0,
(struct sockaddr *)&clientAddress,
clientLength);
}
}
close(udpSocket);
}
}
- (void)displayFrame:(NSData *)frame protocol:(NSString *)protocol {
[self noteFrameForProtocol:protocol];
NSData *snapshot = [frame copy];
dispatch_async(dispatch_get_main_queue(), ^{
[self.canvasView displayMonochromeFrameBuffer:snapshot];
});
}
- (NSDictionary *)streamStats {
@synchronized (self) {
NSTimeInterval uptime = [[NSDate date] timeIntervalSinceDate:self.startedAt ?: [NSDate date]];
NSTimeInterval secondsSinceLastFrame = self.lastFrameAt ? [[NSDate date] timeIntervalSinceDate:self.lastFrameAt] : -1.0;
return @{
@"debug_enabled": @(self.debugEnabled),
@"running": @(self.running),
@"tcp_port": @8081,
@"udp_port": @8082,
@"color_tcp_port": @(IMCColorStreamPort),
@"discovery_port": @5000,
@"frame_size_bytes": @(IMCFrameSize),
@"udp_chunk_size_bytes": @(IMCUDPChunkSize),
@"udp_chunks_per_frame": @(IMCUDPChunkCount),
@"uptime_sec": @((NSInteger)uptime),
@"tcp_connections": @(self.tcpConnections),
@"tcp_frames": @(self.tcpFrames),
@"tcp_bytes": @(self.tcpBytes),
@"udp_frames": @(self.udpFrames),
@"udp_packets": @(self.udpPackets),
@"udp_bytes": @(self.udpBytes),
@"invalid_udp_packets": @(self.invalidUDPPackets),
@"discovery_requests": @(self.discoveryRequests),
@"color_connections": @(self.colorConnections),
@"color_frames": @(self.colorFrames),
@"color_bytes": @(self.colorBytes),
@"last_color_width": @(self.lastColorWidth),
@"last_color_height": @(self.lastColorHeight),
@"last_protocol": self.lastProtocol ?: @"none",
@"seconds_since_last_frame": @(secondsSinceLastFrame)
};
}
}
- (void)resetStreamStats {
@synchronized (self) {
self.startedAt = [NSDate date];
self.lastFrameAt = nil;
self.lastProtocol = @"none";
self.tcpConnections = 0;
self.tcpFrames = 0;
self.tcpBytes = 0;
self.udpFrames = 0;
self.udpPackets = 0;
self.udpBytes = 0;
self.invalidUDPPackets = 0;
self.discoveryRequests = 0;
self.colorConnections = 0;
self.colorFrames = 0;
self.colorBytes = 0;
self.lastColorWidth = 0;
self.lastColorHeight = 0;
}
}
- (void)setDebugEnabled:(BOOL)enabled {
@synchronized (self) {
_debugEnabled = enabled;
}
}
- (void)noteTCPConnection {
@synchronized (self) {
self.tcpConnections += 1;
if (self.debugEnabled) {
NSLog(@"IPhoneMCP stream: TCP client connected");
}
}
}
- (void)noteTCPBytes:(NSUInteger)count {
@synchronized (self) {
self.tcpBytes += count;
}
}
- (void)noteUDPPacketBytes:(NSUInteger)count {
@synchronized (self) {
self.udpPackets += 1;
self.udpBytes += count;
}
}
- (void)noteInvalidUDPPacket {
@synchronized (self) {
self.invalidUDPPackets += 1;
if (self.debugEnabled) {
NSLog(@"IPhoneMCP stream: invalid UDP packet");
}
}
}
- (void)noteDiscoveryRequest {
@synchronized (self) {
self.discoveryRequests += 1;
if (self.debugEnabled) {
NSLog(@"IPhoneMCP stream: discovery request");
}
}
}
- (void)noteFrameForProtocol:(NSString *)protocol {
@synchronized (self) {
if ([protocol isEqualToString:@"tcp"]) {
self.tcpFrames += 1;
} else if ([protocol isEqualToString:@"udp"]) {
self.udpFrames += 1;
}
self.lastProtocol = protocol ?: @"none";
self.lastFrameAt = [NSDate date];
if (self.debugEnabled) {
NSLog(@"IPhoneMCP stream: %@ frame displayed", self.lastProtocol);
}
}
}
@end
+5
View File
@@ -0,0 +1,5 @@
#import <UIKit/UIKit.h>
@interface IMCViewController : UIViewController
- (NSDictionary *)handleRPCRequest:(NSDictionary *)request;
@end
+846
View File
@@ -0,0 +1,846 @@
#import "IMCViewController.h"
#import "IMCCanvasView.h"
#import "IMCMCPServer.h"
#import "IMCVideoStreamServer.h"
#import <AVFoundation/AVFoundation.h>
#import <math.h>
@interface IMCPhotoCaptureDelegate : NSObject <AVCapturePhotoCaptureDelegate>
@property (nonatomic, copy) void (^completion)(NSData *jpegData, NSError *error);
@end
@implementation IMCPhotoCaptureDelegate
- (void)captureOutput:(AVCapturePhotoOutput *)output
didFinishProcessingPhoto:(AVCapturePhoto *)photo
error:(NSError *)error {
NSData *data = error ? nil : [photo fileDataRepresentation];
if (self.completion) {
self.completion(data, error);
}
}
@end
@interface IMCViewController ()
@property (nonatomic, strong) IMCCanvasView *canvasView;
@property (nonatomic, strong) IMCMCPServer *server;
@property (nonatomic, strong) IMCVideoStreamServer *videoStreamServer;
@property (nonatomic, strong) AVAudioPlayer *audioPlayer;
@property (nonatomic, strong) AVAudioRecorder *audioRecorder;
@end
@implementation IMCViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.view.backgroundColor = [UIColor colorWithRed:0.03 green:0.06 blue:0.12 alpha:1.0];
self.canvasView = [[IMCCanvasView alloc] initWithFrame:CGRectZero];
self.canvasView.translatesAutoresizingMaskIntoConstraints = NO;
[self.view addSubview:self.canvasView];
[NSLayoutConstraint activateConstraints:@[
[self.canvasView.topAnchor constraintEqualToAnchor:self.view.topAnchor],
[self.canvasView.bottomAnchor constraintEqualToAnchor:self.view.bottomAnchor],
[self.canvasView.leadingAnchor constraintEqualToAnchor:self.view.leadingAnchor],
[self.canvasView.trailingAnchor constraintEqualToAnchor:self.view.trailingAnchor]
]];
[self.canvasView clearWithColor:0];
[self.canvasView drawText:@"iPhone MCP landscape server ready" x:18 y:18 size:2];
[self.canvasView drawText:@"POST /api/mcp on port 8080" x:18 y:58 size:1];
self.server = [[IMCMCPServer alloc] initWithViewController:self port:8080];
[self.server start];
self.videoStreamServer = [[IMCVideoStreamServer alloc] initWithCanvasView:self.canvasView];
[self.videoStreamServer start];
}
- (NSDictionary *)handleRPCRequest:(NSDictionary *)request {
__block NSDictionary *response = nil;
dispatch_sync(dispatch_get_main_queue(), ^{
response = [self handleRPCRequestOnMainThread:request];
});
return response;
}
- (NSDictionary *)handleRPCRequestOnMainThread:(NSDictionary *)request {
id rpcID = request[@"id"] ?: [NSNull null];
NSString *method = request[@"method"];
if ([method isEqualToString:@"initialize"]) {
return [self result:@{
@"protocolVersion": @"2024-11-05",
@"capabilities": @{@"tools": @{}},
@"serverInfo": @{@"name": @"iphone6-mcp", @"version": @"0.0.4"}
} rpcID:rpcID];
}
if ([method isEqualToString:@"notifications/initialized"]) {
return [self result:@{} rpcID:rpcID];
}
if ([method isEqualToString:@"tools/list"]) {
return [self result:@{@"tools": [self tools]} rpcID:rpcID];
}
if ([method isEqualToString:@"tools/call"]) {
NSDictionary *params = request[@"params"] ?: @{};
NSString *name = params[@"name"];
NSDictionary *arguments = params[@"arguments"] ?: @{};
return [self callTool:name arguments:arguments rpcID:rpcID];
}
return [self errorWithCode:-32601 message:[NSString stringWithFormat:@"Method %@ not found", method] rpcID:rpcID];
}
- (NSDictionary *)callTool:(NSString *)name arguments:(NSDictionary *)arguments rpcID:(id)rpcID {
@try {
if ([name isEqualToString:@"clear_screen"]) {
NSInteger color = [arguments[@"color"] integerValue];
[self.canvasView clearWithColor:color];
return [self textResult:@"Screen cleared." rpcID:rpcID];
}
if ([name isEqualToString:@"draw_text"]) {
NSString *text = [NSString stringWithFormat:@"%@", arguments[@"text"] ?: @""];
CGFloat x = [arguments[@"x"] doubleValue];
CGFloat y = [arguments[@"y"] doubleValue];
NSInteger size = arguments[@"size"] ? [arguments[@"size"] integerValue] : 1;
[self.canvasView drawText:text x:x y:y size:size];
return [self textResult:[NSString stringWithFormat:@"Drew text '%@' at (%.0f, %.0f).", text, x, y] rpcID:rpcID];
}
if ([name isEqualToString:@"draw_image"]) {
NSString *base64 = [NSString stringWithFormat:@"%@", arguments[@"image_base64"] ?: @""];
NSRange marker = [base64 rangeOfString:@";base64,"];
if (marker.location != NSNotFound) {
base64 = [base64 substringFromIndex:marker.location + marker.length];
}
NSData *data = [[NSData alloc] initWithBase64EncodedString:base64 options:NSDataBase64DecodingIgnoreUnknownCharacters];
if (!data) {
return [self errorWithCode:-32602 message:@"Invalid image_base64." rpcID:rpcID];
}
[self.canvasView drawImageData:data
x:[arguments[@"x"] doubleValue]
y:[arguments[@"y"] doubleValue]];
return [self textResult:@"Image displayed." rpcID:rpcID];
}
if ([name isEqualToString:@"get_screenshot"]) {
NSString *png = [self.canvasView snapshotPNGBase64];
return [self result:@{@"content": @[@{@"type": @"image", @"data": png, @"mimeType": @"image/png"}]} rpcID:rpcID];
}
if ([name isEqualToString:@"get_battery"]) {
UIDevice *device = [UIDevice currentDevice];
device.batteryMonitoringEnabled = YES;
NSDictionary *battery = @{
@"level_pct": @(MAX(device.batteryLevel, 0.0) * 100.0),
@"state": [self batteryStateName:device.batteryState]
};
return [self textResult:[self jsonString:battery] rpcID:rpcID];
}
if ([name isEqualToString:@"get_sensors"] || [name isEqualToString:@"scan_ble"] || [name isEqualToString:@"set_led"]) {
return [self textResult:[NSString stringWithFormat:@"%@ is not implemented in this first iPhone build.", name] rpcID:rpcID];
}
if ([name isEqualToString:@"play_tone"]) {
NSInteger frequency = arguments[@"frequency"] ? [arguments[@"frequency"] integerValue] : 440;
NSInteger duration = arguments[@"duration_ms"] ? [arguments[@"duration_ms"] integerValue] : 1000;
NSInteger volume = arguments[@"volume"] ? [arguments[@"volume"] integerValue] : 50;
frequency = MAX(50, MIN(10000, frequency));
duration = MAX(50, MIN(5000, duration));
volume = MAX(0, MIN(100, volume));
NSData *toneData = [self wavDataForToneFrequency:frequency
durationMS:duration
volume:volume];
NSDictionary *error = [self playAudioData:toneData volume:volume];
if (error) {
return [self errorWithCode:[error[@"code"] integerValue]
message:error[@"message"]
rpcID:rpcID];
}
return [self textResult:[NSString stringWithFormat:@"Played tone of %ldHz for %ldms at volume %ld.",
(long)frequency,
(long)duration,
(long)volume]
rpcID:rpcID];
}
if ([name isEqualToString:@"play_audio_base64"]) {
NSString *base64 = [self base64PayloadFromString:[NSString stringWithFormat:@"%@", arguments[@"wav_base64"] ?: @""]];
NSData *audioData = [[NSData alloc] initWithBase64EncodedString:base64
options:NSDataBase64DecodingIgnoreUnknownCharacters];
if (!audioData) {
return [self errorWithCode:-32602 message:@"Invalid wav_base64." rpcID:rpcID];
}
NSInteger volumeArg = arguments[@"volume"] ? [arguments[@"volume"] integerValue] : 50;
volumeArg = MAX(0, MIN(100, volumeArg));
NSDictionary *error = [self playAudioData:audioData volume:volumeArg];
if (error) {
return [self errorWithCode:[error[@"code"] integerValue]
message:error[@"message"]
rpcID:rpcID];
}
return [self textResult:[NSString stringWithFormat:@"Playing base64 WAV audio (%lu bytes) at volume %ld.",
(unsigned long)audioData.length,
(long)volumeArg]
rpcID:rpcID];
}
if ([name isEqualToString:@"play_audio"]) {
NSString *filename = [NSString stringWithFormat:@"%@", arguments[@"filename"] ?: @""];
NSURL *url = [self safeFileURLForPath:filename];
if (!url) {
return [self errorWithCode:-32602
message:@"filename must be relative and cannot contain '..'."
rpcID:rpcID];
}
NSData *audioData = [NSData dataWithContentsOfURL:url];
if (!audioData) {
return [self errorWithCode:-32000
message:[NSString stringWithFormat:@"Could not read audio file '%@' from Documents/MCPFiles.", filename]
rpcID:rpcID];
}
NSInteger volumeArg = arguments[@"volume"] ? [arguments[@"volume"] integerValue] : 50;
volumeArg = MAX(0, MIN(100, volumeArg));
NSDictionary *error = [self playAudioData:audioData volume:volumeArg];
if (error) {
return [self errorWithCode:[error[@"code"] integerValue]
message:error[@"message"]
rpcID:rpcID];
}
return [self textResult:[NSString stringWithFormat:@"Playing WAV file '%@' (%lu bytes) at volume %ld.",
filename,
(unsigned long)audioData.length,
(long)volumeArg]
rpcID:rpcID];
}
if ([name isEqualToString:@"hermes_voice_turn"]) {
NSDictionary *voiceResult = [self performHermesVoiceTurn:arguments];
if (voiceResult[@"error"]) {
return [self errorWithCode:-32000 message:voiceResult[@"error"] rpcID:rpcID];
}
return [self textResult:[self jsonString:voiceResult] rpcID:rpcID];
}
if ([name isEqualToString:@"record_voice"]) {
if (![self ensureMicrophonePermission]) {
return [self errorWithCode:-32000
message:@"Microphone permission is not granted for iPhone MCP."
rpcID:rpcID];
}
NSInteger duration = arguments[@"duration_sec"] ? [arguments[@"duration_sec"] integerValue] : 4;
duration = MAX(1, MIN(15, duration));
NSString *filename = [NSString stringWithFormat:@"%@", arguments[@"filename"] ?: @"recording.wav"];
NSURL *url = [self safeFileURLForPath:filename];
if (!url) {
return [self errorWithCode:-32602
message:@"filename must be relative and cannot contain '..'."
rpcID:rpcID];
}
NSDictionary *result = [self recordVoiceToURL:url duration:duration];
if (result[@"error"]) {
return [self errorWithCode:-32000 message:result[@"error"] rpcID:rpcID];
}
return [self textResult:[self jsonString:result] rpcID:rpcID];
}
if ([name isEqualToString:@"capture_selfie"]) {
if (![self ensureCameraPermission]) {
return [self errorWithCode:-32000
message:@"Camera permission is not granted for iPhone MCP."
rpcID:rpcID];
}
NSDictionary *result = [self captureSelfiePNGBase64];
if (result[@"error"]) {
return [self errorWithCode:-32000 message:result[@"error"] rpcID:rpcID];
}
return [self result:@{@"content": @[@{@"type": @"image",
@"data": result[@"png_base64"],
@"mimeType": @"image/png"}]} rpcID:rpcID];
}
if ([name isEqualToString:@"write_file"]) {
NSString *path = [NSString stringWithFormat:@"%@", arguments[@"path"] ?: @""];
NSString *content = [NSString stringWithFormat:@"%@", arguments[@"content"] ?: @""];
NSURL *url = [self safeFileURLForPath:path];
if (!url) {
return [self errorWithCode:-32602 message:@"Path must be relative and cannot contain '..'." rpcID:rpcID];
}
[[NSFileManager defaultManager] createDirectoryAtURL:[url URLByDeletingLastPathComponent]
withIntermediateDirectories:YES
attributes:nil
error:nil];
NSError *writeError = nil;
[content writeToURL:url atomically:YES encoding:NSUTF8StringEncoding error:&writeError];
if (writeError) {
return [self errorWithCode:-32000 message:writeError.localizedDescription rpcID:rpcID];
}
return [self textResult:[NSString stringWithFormat:@"Wrote %lu characters to %@.", (unsigned long)content.length, path] rpcID:rpcID];
}
if ([name isEqualToString:@"read_file"]) {
NSString *path = [NSString stringWithFormat:@"%@", arguments[@"path"] ?: @""];
NSURL *url = [self safeFileURLForPath:path];
if (!url) {
return [self errorWithCode:-32602 message:@"Path must be relative and cannot contain '..'." rpcID:rpcID];
}
NSError *readError = nil;
NSString *content = [NSString stringWithContentsOfURL:url encoding:NSUTF8StringEncoding error:&readError];
if (readError) {
return [self errorWithCode:-32000 message:readError.localizedDescription rpcID:rpcID];
}
return [self textResult:content rpcID:rpcID];
}
if ([name isEqualToString:@"download_file"]) {
NSString *urlString = [NSString stringWithFormat:@"%@", arguments[@"url"] ?: @""];
NSString *filename = [NSString stringWithFormat:@"%@", arguments[@"filename"] ?: @""];
BOOL useSD = arguments[@"use_sd"] ? [arguments[@"use_sd"] boolValue] : NO;
NSURL *sourceURL = [NSURL URLWithString:urlString];
NSURL *destinationURL = [self safeFileURLForPath:filename];
if (!sourceURL || !destinationURL) {
return [self errorWithCode:-32602 message:@"Invalid url or filename." rpcID:rpcID];
}
NSData *data = [NSData dataWithContentsOfURL:sourceURL];
if (!data) {
return [self errorWithCode:-32000 message:@"Download failed." rpcID:rpcID];
}
[[NSFileManager defaultManager] createDirectoryAtURL:[destinationURL URLByDeletingLastPathComponent]
withIntermediateDirectories:YES
attributes:nil
error:nil];
[data writeToURL:destinationURL atomically:YES];
NSString *storageNote = useSD ? @" The iPhone build has no SD card, so use_sd was accepted and stored in app Documents instead." : @"";
return [self textResult:[NSString stringWithFormat:@"Downloaded %lu bytes to %@.%@", (unsigned long)data.length, filename, storageNote] rpcID:rpcID];
}
if ([name isEqualToString:@"get_video_streaming_instructions"]) {
NSString *protocol = [[NSString stringWithFormat:@"%@", arguments[@"protocol"] ?: @"both"] lowercaseString];
return [self textResult:[self videoStreamingInstructionsForProtocol:protocol] rpcID:rpcID];
}
if ([name isEqualToString:@"get_stream_stats"]) {
return [self textResult:[self jsonString:[self.videoStreamServer streamStats]] rpcID:rpcID];
}
if ([name isEqualToString:@"reset_stream_stats"]) {
[self.videoStreamServer resetStreamStats];
return [self textResult:[self jsonString:[self.videoStreamServer streamStats]] rpcID:rpcID];
}
if ([name isEqualToString:@"set_stream_debug"]) {
BOOL enabled = arguments[@"enabled"] ? [arguments[@"enabled"] boolValue] : NO;
[self.videoStreamServer setDebugEnabled:enabled];
return [self textResult:[self jsonString:@{@"debug_enabled": @(enabled)}] rpcID:rpcID];
}
if ([name isEqualToString:@"execute_python"]) {
return [self textResult:@"execute_python is available on the MicroPython ESP32 target, but the native iPhone app does not execute arbitrary Python code. Use write_file/read_file/download_file for files and the dedicated drawing/audio/camera tools for app actions." rpcID:rpcID];
}
return [self errorWithCode:-32602 message:[NSString stringWithFormat:@"Unknown tool: %@", name] rpcID:rpcID];
} @catch (NSException *exception) {
return [self errorWithCode:-32000 message:exception.reason ?: @"Tool failed." rpcID:rpcID];
}
}
- (NSArray *)tools {
return @[
[self tool:@"clear_screen" description:@"Clear the iPhone canvas to white (0) or black (1)." schema:@{@"type": @"object", @"properties": @{@"color": @{@"type": @"integer", @"enum": @[@0, @1]}}, @"required": @[@"color"]}],
[self tool:@"draw_text" description:@"Draw text on the iPhone canvas using 400x300 logical coordinates." schema:@{@"type": @"object", @"properties": @{@"text": @{@"type": @"string"}, @"x": @{@"type": @"integer"}, @"y": @{@"type": @"integer"}, @"size": @{@"type": @"integer", @"enum": @[@1, @2]}}, @"required": @[@"text", @"x", @"y"]}],
[self tool:@"draw_image" description:@"Draw a base64 PNG/JPEG/GIF image on the iPhone canvas." schema:@{@"type": @"object", @"properties": @{@"image_base64": @{@"type": @"string"}, @"x": @{@"type": @"integer", @"default": @0}, @"y": @{@"type": @"integer", @"default": @0}}, @"required": @[@"image_base64"]}],
[self tool:@"get_screenshot" description:@"Return the current iPhone canvas as a PNG image." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"get_battery" description:@"Read the iPhone battery percentage and charge state." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"play_tone" description:@"Generate and play a sine-wave tone with the requested frequency, duration, and volume." schema:@{@"type": @"object", @"properties": @{@"frequency": @{@"type": @"integer", @"default": @440}, @"duration_ms": @{@"type": @"integer", @"default": @1000}, @"volume": @{@"type": @"integer", @"default": @50}}}],
[self tool:@"play_audio_base64" description:@"Decode a base64 WAV payload and play it through the iPhone speaker." schema:@{@"type": @"object", @"properties": @{@"wav_base64": @{@"type": @"string"}, @"volume": @{@"type": @"integer", @"default": @50}}, @"required": @[@"wav_base64"]}],
[self tool:@"play_audio" description:@"Play a WAV file from the app's Documents/MCPFiles directory." schema:@{@"type": @"object", @"properties": @{@"filename": @{@"type": @"string"}, @"volume": @{@"type": @"integer", @"default": @50}}, @"required": @[@"filename"]}],
[self tool:@"hermes_voice_turn" description:@"Record a voice command, send it to the Hermes voice gateway, play the returned WAV, and return transcript/response diagnostics." schema:@{@"type": @"object", @"properties": @{@"url": @{@"type": @"string", @"default": @"http://192.168.68.126:8642/api/esp32/voice"}, @"api_token": @{@"type": @"string"}, @"device_id": @{@"type": @"string", @"default": @"iphone6"}, @"duration_sec": @{@"type": @"integer", @"default": @4}, @"volume": @{@"type": @"integer", @"default": @80}, @"instructions": @{@"type": @"string"}, @"reply_mode": @{@"type": @"string", @"enum": @[@"sync", @"ack"], @"default": @"sync"}, @"screen_url": @{@"type": @"string"}}}],
[self tool:@"record_voice" description:@"Record microphone audio to a WAV file inside Documents/MCPFiles." schema:@{@"type": @"object", @"properties": @{@"duration_sec": @{@"type": @"integer", @"default": @4}, @"filename": @{@"type": @"string", @"default": @"recording.wav"}}}],
[self tool:@"capture_selfie" description:@"Capture a still image from the front camera and return it as PNG image content." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"write_file" description:@"Write a UTF-8 file inside the app's Documents/MCPFiles directory." schema:@{@"type": @"object", @"properties": @{@"path": @{@"type": @"string"}, @"content": @{@"type": @"string"}}, @"required": @[@"path", @"content"]}],
[self tool:@"read_file" description:@"Read a UTF-8 file from the app's Documents/MCPFiles directory." schema:@{@"type": @"object", @"properties": @{@"path": @{@"type": @"string"}}, @"required": @[@"path"]}],
[self tool:@"download_file" description:@"Download a URL into the app's Documents/MCPFiles directory. Accepts ESP32-compatible use_sd, but stores locally on iPhone." schema:@{@"type": @"object", @"properties": @{@"url": @{@"type": @"string"}, @"filename": @{@"type": @"string"}, @"use_sd": @{@"type": @"boolean", @"default": @NO}}, @"required": @[@"url", @"filename"]}],
[self tool:@"get_video_streaming_instructions" description:@"Get details for monochrome TCP/UDP streaming and RGB565 color TCP streaming." schema:@{@"type": @"object", @"properties": @{@"protocol": @{@"type": @"string", @"enum": @[@"tcp", @"udp", @"color", @"both", @"all"], @"default": @"all"}}}],
[self tool:@"get_stream_stats" description:@"Return TCP/UDP streaming counters, ports, uptime, last protocol, and debug state." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"reset_stream_stats" description:@"Reset TCP/UDP streaming counters and last-frame state." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"set_stream_debug" description:@"Enable or disable stream debug logging on the iPhone." schema:@{@"type": @"object", @"properties": @{@"enabled": @{@"type": @"boolean", @"default": @NO}}}],
[self tool:@"execute_python" description:@"ESP32 compatibility stub; arbitrary Python execution is intentionally not implemented in the native iPhone app." schema:@{@"type": @"object", @"properties": @{@"code": @{@"type": @"string"}}, @"required": @[@"code"]}],
[self tool:@"set_led" description:@"ESP32 compatibility stub; the iPhone 6 has no NeoPixel LED." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"get_sensors" description:@"ESP32 compatibility stub; SHTC3 temperature/humidity is not present." schema:@{@"type": @"object", @"properties": @{}}],
[self tool:@"scan_ble" description:@"ESP32 compatibility stub; BLE scanning is not implemented in this first build." schema:@{@"type": @"object", @"properties": @{}}]
];
}
- (NSDictionary *)tool:(NSString *)name description:(NSString *)description schema:(NSDictionary *)schema {
return @{@"name": name, @"description": description, @"inputSchema": schema};
}
- (NSDictionary *)textResult:(NSString *)text rpcID:(id)rpcID {
return [self result:@{@"content": @[@{@"type": @"text", @"text": text ?: @""}]} rpcID:rpcID];
}
- (NSDictionary *)result:(NSDictionary *)result rpcID:(id)rpcID {
return @{@"jsonrpc": @"2.0", @"result": result ?: @{}, @"id": rpcID ?: [NSNull null]};
}
- (NSDictionary *)errorWithCode:(NSInteger)code message:(NSString *)message rpcID:(id)rpcID {
return @{@"jsonrpc": @"2.0",
@"error": @{@"code": @(code), @"message": message ?: @"Error"},
@"id": rpcID ?: [NSNull null]};
}
- (NSString *)jsonString:(id)object {
NSData *data = [NSJSONSerialization dataWithJSONObject:object options:0 error:nil];
return [[NSString alloc] initWithData:data encoding:NSUTF8StringEncoding] ?: @"{}";
}
- (NSString *)base64PayloadFromString:(NSString *)string {
NSRange marker = [string rangeOfString:@";base64,"];
if (marker.location != NSNotFound) {
return [string substringFromIndex:marker.location + marker.length];
}
return string ?: @"";
}
- (NSDictionary *)playAudioData:(NSData *)audioData volume:(NSInteger)volume {
NSError *sessionError = nil;
[[AVAudioSession sharedInstance] setCategory:AVAudioSessionCategoryPlayback error:&sessionError];
[[AVAudioSession sharedInstance] setActive:YES error:&sessionError];
NSError *playerError = nil;
self.audioPlayer = [[AVAudioPlayer alloc] initWithData:audioData error:&playerError];
if (!self.audioPlayer || playerError) {
return @{@"code": @-32000,
@"message": playerError.localizedDescription ?: @"Failed to create audio player."};
}
self.audioPlayer.volume = MAX(0.0, MIN(1.0, volume / 100.0));
[self.audioPlayer prepareToPlay];
[self.audioPlayer play];
return nil;
}
- (NSString *)headerValue:(NSString *)name fromResponse:(NSHTTPURLResponse *)response {
for (id key in response.allHeaderFields) {
if ([[NSString stringWithFormat:@"%@", key] caseInsensitiveCompare:name] == NSOrderedSame) {
return [NSString stringWithFormat:@"%@", response.allHeaderFields[key]];
}
}
return @"";
}
- (NSDictionary *)performHermesVoiceTurn:(NSDictionary *)arguments {
if (![self ensureMicrophonePermission]) {
return @{@"error": @"Microphone permission is not granted for iPhone MCP."};
}
NSInteger duration = arguments[@"duration_sec"] ? [arguments[@"duration_sec"] integerValue] : 4;
duration = MAX(1, MIN(20, duration));
NSInteger volume = arguments[@"volume"] ? [arguments[@"volume"] integerValue] : 80;
volume = MAX(0, MIN(100, volume));
NSString *urlString = [NSString stringWithFormat:@"%@", arguments[@"url"] ?: @"http://192.168.68.126:8642/api/esp32/voice"];
NSURL *gatewayURL = [NSURL URLWithString:urlString];
if (!gatewayURL || ![@[@"http", @"https"] containsObject:gatewayURL.scheme.lowercaseString]) {
return @{@"error": @"Hermes url must be an http or https URL."};
}
NSURL *recordingURL = [self safeFileURLForPath:@"hermes/request.wav"];
NSDictionary *recording = [self recordVoiceToURL:recordingURL duration:duration];
if (recording[@"error"]) {
return recording;
}
NSData *wavData = [NSData dataWithContentsOfURL:recordingURL];
if (!wavData.length) {
return @{@"error": @"Hermes recording produced no audio data."};
}
NSMutableURLRequest *request = [NSMutableURLRequest requestWithURL:gatewayURL
cachePolicy:NSURLRequestReloadIgnoringLocalCacheData
timeoutInterval:90.0];
request.HTTPMethod = @"POST";
request.HTTPBody = wavData;
[request setValue:@"audio/wav" forHTTPHeaderField:@"Content-Type"];
[request setValue:[NSString stringWithFormat:@"%@", arguments[@"device_id"] ?: @"iphone6"]
forHTTPHeaderField:@"X-Device-ID"];
[request setValue:[NSString stringWithFormat:@"%@", arguments[@"reply_mode"] ?: @"sync"]
forHTTPHeaderField:@"X-Hermes-Reply-Mode"];
NSString *instructions = [NSString stringWithFormat:@"%@", arguments[@"instructions"] ?: @""];
if (instructions.length) {
[request setValue:instructions forHTTPHeaderField:@"X-Hermes-Instructions"];
}
NSString *screenURL = [NSString stringWithFormat:@"%@", arguments[@"screen_url"] ?: @""];
if (screenURL.length) {
[request setValue:screenURL forHTTPHeaderField:@"X-Hermes-Screen-Url"];
}
NSString *token = [NSString stringWithFormat:@"%@", arguments[@"api_token"] ?: @""];
if (token.length) {
if (![token.lowercaseString hasPrefix:@"bearer "]) {
token = [@"Bearer " stringByAppendingString:token];
}
[request setValue:token forHTTPHeaderField:@"Authorization"];
}
NSURLResponse *rawResponse = nil;
NSError *requestError = nil;
NSData *responseData = [NSURLConnection sendSynchronousRequest:request
returningResponse:&rawResponse
error:&requestError];
if (requestError || !responseData) {
if ([requestError.domain isEqualToString:NSURLErrorDomain] &&
(requestError.code == NSURLErrorUserCancelledAuthentication ||
requestError.code == NSURLErrorUserAuthenticationRequired)) {
return @{@"error": @"Hermes authorization failed. Provide a valid api_token to hermes_voice_turn."};
}
return @{@"error": requestError.localizedDescription ?: @"Hermes request failed."};
}
NSHTTPURLResponse *response = (NSHTTPURLResponse *)rawResponse;
if (response.statusCode < 200 || response.statusCode >= 300) {
NSString *body = [[NSString alloc] initWithData:responseData encoding:NSUTF8StringEncoding] ?: @"";
return @{@"error": [NSString stringWithFormat:@"Hermes returned HTTP %ld: %@",
(long)response.statusCode,
[body substringToIndex:MIN(body.length, 500)]]};
}
NSDictionary *playError = [self playAudioData:responseData volume:volume];
if (playError) {
return @{@"error": playError[@"message"] ?: @"Hermes audio playback failed."};
}
NSURL *responseURL = [self safeFileURLForPath:@"hermes/response.wav"];
[responseData writeToURL:responseURL atomically:YES];
return @{
@"status": @"playing",
@"http_status": @(response.statusCode),
@"request_bytes": @(wavData.length),
@"response_bytes": @(responseData.length),
@"transcript": [self headerValue:@"X-Hermes-Transcript" fromResponse:response],
@"response_text": [self headerValue:@"X-Hermes-Text-Response" fromResponse:response],
@"response_id": [self headerValue:@"X-Hermes-Response-Id" fromResponse:response],
@"conversation": [self headerValue:@"X-Hermes-Conversation" fromResponse:response],
@"reply_mode": [NSString stringWithFormat:@"%@", arguments[@"reply_mode"] ?: @"sync"],
@"saved_response": @"hermes/response.wav"
};
}
- (NSDictionary *)recordVoiceToURL:(NSURL *)url duration:(NSInteger)duration {
NSError *directoryError = nil;
[[NSFileManager defaultManager] createDirectoryAtURL:[url URLByDeletingLastPathComponent]
withIntermediateDirectories:YES
attributes:nil
error:&directoryError];
if (directoryError) {
return @{@"error": directoryError.localizedDescription};
}
NSError *sessionError = nil;
AVAudioSession *session = [AVAudioSession sharedInstance];
[session setCategory:AVAudioSessionCategoryPlayAndRecord error:&sessionError];
[session setActive:YES error:&sessionError];
if (sessionError) {
return @{@"error": sessionError.localizedDescription};
}
NSDictionary *settings = @{
AVFormatIDKey: @(kAudioFormatLinearPCM),
AVSampleRateKey: @16000,
AVNumberOfChannelsKey: @1,
AVLinearPCMBitDepthKey: @16,
AVLinearPCMIsFloatKey: @NO,
AVLinearPCMIsBigEndianKey: @NO
};
NSError *recordError = nil;
self.audioRecorder = [[AVAudioRecorder alloc] initWithURL:url
settings:settings
error:&recordError];
if (!self.audioRecorder || recordError) {
return @{@"error": recordError.localizedDescription ?: @"Failed to create audio recorder."};
}
[self.audioRecorder prepareToRecord];
[self.audioRecorder recordForDuration:duration];
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:duration + 0.25];
while (self.audioRecorder.recording && [deadline timeIntervalSinceNow] > 0) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
}
[self.audioRecorder stop];
NSDictionary *attributes = [[NSFileManager defaultManager] attributesOfItemAtPath:url.path error:nil];
NSNumber *bytes = attributes[NSFileSize] ?: @0;
return @{@"filename": url.lastPathComponent ?: @"recording.wav",
@"bytes": bytes,
@"duration_sec": @(duration)};
}
- (NSDictionary *)captureSelfiePNGBase64 {
AVCaptureDeviceDiscoverySession *discovery = [AVCaptureDeviceDiscoverySession discoverySessionWithDeviceTypes:@[
AVCaptureDeviceTypeBuiltInWideAngleCamera
]
mediaType:AVMediaTypeVideo
position:AVCaptureDevicePositionFront];
AVCaptureDevice *camera = discovery.devices.firstObject;
if (!camera) {
return @{@"error": @"Front camera not found."};
}
NSError *inputError = nil;
AVCaptureDeviceInput *input = [AVCaptureDeviceInput deviceInputWithDevice:camera error:&inputError];
if (!input || inputError) {
return @{@"error": inputError.localizedDescription ?: @"Failed to open front camera."};
}
AVCaptureSession *session = [[AVCaptureSession alloc] init];
session.sessionPreset = AVCaptureSessionPresetPhoto;
if (![session canAddInput:input]) {
return @{@"error": @"Could not add front camera input."};
}
[session addInput:input];
AVCaptureStillImageOutput *output = [[AVCaptureStillImageOutput alloc] init];
output.outputSettings = @{AVVideoCodecKey: AVVideoCodecJPEG};
if (![session canAddOutput:output]) {
return @{@"error": @"Could not add still image output."};
}
[session addOutput:output];
[session startRunning];
AVCaptureConnection *connection = [output connectionWithMediaType:AVMediaTypeVideo];
if (connection.isVideoOrientationSupported) {
connection.videoOrientation = AVCaptureVideoOrientationLandscapeRight;
}
__block NSData *jpegData = nil;
__block NSError *captureError = nil;
__block BOOL finished = NO;
[output captureStillImageAsynchronouslyFromConnection:connection
completionHandler:^(CMSampleBufferRef imageDataSampleBuffer, NSError *error) {
if (imageDataSampleBuffer) {
jpegData = [AVCaptureStillImageOutput jpegStillImageNSDataRepresentation:imageDataSampleBuffer];
}
captureError = error;
finished = YES;
}];
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:5.0];
while (!finished && [deadline timeIntervalSinceNow] > 0) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
}
[session stopRunning];
if (captureError || !jpegData) {
return @{@"error": captureError.localizedDescription ?: @"Selfie capture timed out."};
}
UIImage *image = [UIImage imageWithData:jpegData];
NSData *pngData = UIImagePNGRepresentation(image);
if (!pngData) {
return @{@"error": @"Failed to convert selfie to PNG."};
}
return @{@"png_base64": [pngData base64EncodedStringWithOptions:0]};
}
- (BOOL)ensureMicrophonePermission {
AVAudioSession *session = [AVAudioSession sharedInstance];
if (session.recordPermission == AVAudioSessionRecordPermissionGranted) {
return YES;
}
if (session.recordPermission == AVAudioSessionRecordPermissionDenied) {
return NO;
}
__block BOOL granted = NO;
__block BOOL finished = NO;
[session requestRecordPermission:^(BOOL didGrant) {
granted = didGrant;
finished = YES;
}];
return [self waitForPermissionResult:&finished granted:&granted];
}
- (BOOL)ensureCameraPermission {
AVAuthorizationStatus status = [AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo];
if (status == AVAuthorizationStatusAuthorized) {
return YES;
}
if (status == AVAuthorizationStatusDenied || status == AVAuthorizationStatusRestricted) {
return NO;
}
__block BOOL granted = NO;
__block BOOL finished = NO;
[AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo
completionHandler:^(BOOL didGrant) {
granted = didGrant;
finished = YES;
}];
return [self waitForPermissionResult:&finished granted:&granted];
}
- (BOOL)waitForPermissionResult:(BOOL *)finished granted:(BOOL *)granted {
NSDate *deadline = [NSDate dateWithTimeIntervalSinceNow:30.0];
while (!*finished && [deadline timeIntervalSinceNow] > 0) {
[[NSRunLoop currentRunLoop] runMode:NSDefaultRunLoopMode
beforeDate:[NSDate dateWithTimeIntervalSinceNow:0.05]];
}
return *finished && *granted;
}
- (NSData *)wavDataForToneFrequency:(NSInteger)frequency durationMS:(NSInteger)durationMS volume:(NSInteger)volume {
const uint32_t sampleRate = 22050;
const uint16_t channels = 1;
const uint16_t bitsPerSample = 16;
const uint16_t blockAlign = channels * bitsPerSample / 8;
const uint32_t byteRate = sampleRate * blockAlign;
uint32_t sampleCount = (uint32_t)((sampleRate * durationMS) / 1000);
uint32_t dataSize = sampleCount * blockAlign;
uint32_t riffSize = 36 + dataSize;
NSMutableData *data = [NSMutableData dataWithCapacity:44 + dataSize];
[self appendASCII:"RIFF" toData:data];
[self appendUInt32LE:riffSize toData:data];
[self appendASCII:"WAVE" toData:data];
[self appendASCII:"fmt " toData:data];
[self appendUInt32LE:16 toData:data];
[self appendUInt16LE:1 toData:data];
[self appendUInt16LE:channels toData:data];
[self appendUInt32LE:sampleRate toData:data];
[self appendUInt32LE:byteRate toData:data];
[self appendUInt16LE:blockAlign toData:data];
[self appendUInt16LE:bitsPerSample toData:data];
[self appendASCII:"data" toData:data];
[self appendUInt32LE:dataSize toData:data];
double amplitude = 32767.0 * MAX(0.0, MIN(1.0, volume / 100.0));
double phaseStep = 2.0 * M_PI * frequency / sampleRate;
for (uint32_t i = 0; i < sampleCount; i++) {
double fadeIn = MIN(1.0, i / (sampleRate * 0.01));
double fadeOut = MIN(1.0, (sampleCount - i) / (sampleRate * 0.01));
double envelope = MIN(fadeIn, fadeOut);
int16_t sample = (int16_t)(sin(i * phaseStep) * amplitude * envelope);
[self appendUInt16LE:(uint16_t)sample toData:data];
}
return data;
}
- (void)appendASCII:(const char *)string toData:(NSMutableData *)data {
[data appendBytes:string length:4];
}
- (void)appendUInt16LE:(uint16_t)value toData:(NSMutableData *)data {
uint8_t bytes[] = {value & 0xff, (value >> 8) & 0xff};
[data appendBytes:bytes length:sizeof(bytes)];
}
- (void)appendUInt32LE:(uint32_t)value toData:(NSMutableData *)data {
uint8_t bytes[] = {
value & 0xff,
(value >> 8) & 0xff,
(value >> 16) & 0xff,
(value >> 24) & 0xff
};
[data appendBytes:bytes length:sizeof(bytes)];
}
- (NSString *)videoStreamingInstructionsForProtocol:(NSString *)protocol {
NSMutableArray *lines = [NSMutableArray arrayWithArray:@[
@"### iPhone MCP Video Streaming Instructions",
@"Dimensions: 400x300, 1-bit monochrome, using the same 15,000-byte RLCD frame layout as the ESP32 server.",
@"The iPhone stretches each received frame over the full landscape screen.",
@"UDP discovery: send DISCOVER_SCREEN to port 5000; the iPhone replies SCREEN_IP_8080 because its JSON-RPC endpoint is on port 8080.",
@"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 isEqualToString:@"tcp"] || [protocol isEqualToString:@"both"] || [protocol isEqualToString:@"all"]) {
[lines addObject:@"\n**TCP Streaming (Port 8081):**"];
[lines addObject:@"Open a TCP connection to the iPhone IP on port 8081 and send consecutive 15,000-byte frame blocks."];
}
if ([protocol isEqualToString:@"udp"] || [protocol isEqualToString:@"both"] || [protocol isEqualToString:@"all"]) {
[lines addObject:@"\n**UDP Streaming / Broadcast (Port 8082):**"];
[lines addObject:@"Split each 15,000-byte frame into 15 chunks of 1,000 bytes. 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."];
}
if ([protocol isEqualToString:@"color"] || [protocol isEqualToString:@"all"]) {
[lines addObject:@"\n**RGB565 Color TCP Streaming (Port 8083):**"];
[lines addObject:@"Send each frame as a 16-byte header followed by width*height*2 bytes of big-endian RGB565 pixels. Header: bytes 0..3='IMCR', byte 4=1 (version), byte 5=1 (RGB565BE), bytes 6..7=width big-endian, bytes 8..9=height big-endian, bytes 10..13=payload length big-endian, bytes 14..15=flags (zero). Frames may use any dimensions up to 2048x2048 and are scaled to the full iPhone canvas."];
}
return [lines componentsJoinedByString:@"\n"];
}
- (NSString *)batteryStateName:(UIDeviceBatteryState)state {
switch (state) {
case UIDeviceBatteryStateUnplugged:
return @"unplugged";
case UIDeviceBatteryStateCharging:
return @"charging";
case UIDeviceBatteryStateFull:
return @"full";
default:
return @"unknown";
}
}
- (NSURL *)safeFileURLForPath:(NSString *)path {
if (path.length == 0 || [path hasPrefix:@"/"]) {
return nil;
}
if ([[path pathComponents] containsObject:@".."]) {
return nil;
}
NSArray *directories = NSSearchPathForDirectoriesInDomains(NSDocumentDirectory, NSUserDomainMask, YES);
NSString *documents = directories.firstObject;
NSString *root = [documents stringByAppendingPathComponent:@"MCPFiles"];
return [NSURL fileURLWithPath:[root stringByAppendingPathComponent:path]];
}
- (BOOL)prefersStatusBarHidden {
return YES;
}
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
return UIInterfaceOrientationMaskLandscape;
}
- (UIInterfaceOrientation)preferredInterfaceOrientationForPresentation {
return UIInterfaceOrientationLandscapeRight;
}
@end
+13
View File
@@ -0,0 +1,13 @@
TARGET := iphone:clang:12.4:12.0
ARCHS = arm64
INSTALL_TARGET_PROCESSES = IPhoneMCP
include $(THEOS)/makefiles/common.mk
APPLICATION_NAME = IPhoneMCP
IPhoneMCP_FILES = main.m IMCAppDelegate.m IMCViewController.m IMCCanvasView.m IMCMCPServer.m IMCVideoStreamServer.m
IPhoneMCP_FRAMEWORKS = UIKit Foundation AVFoundation CoreGraphics
IPhoneMCP_CFLAGS = -fobjc-arc -Wno-deprecated-declarations
include $(THEOS_MAKE_PATH)/application.mk
+188
View File
@@ -0,0 +1,188 @@
# iPhone MCP
Native UIKit MCP screen server for a jailbroken arm64 iPhone running iOS 12. It shares the ESP32 screen's MCP tool shape while using the iPhone display, speaker, microphone, battery, and front camera.
## Features
- HTTP JSON-RPC MCP endpoint: `POST http://<iphone-ip>:8080/api/mcp`
- Full-screen landscape canvas with idle sleep disabled while the app is active
- ESP32-compatible 400x300 monochrome streaming: TCP `8081`, UDP `8082`
- Framed RGB565 color streaming: TCP `8083`
- UDP discovery on `5000`: `DISCOVER_SCREEN` -> `SCREEN_IP_8080`
- True pitched tones, WAV playback, microphone recording, and selfie capture
- Hermes voice gateway turn: record -> transcribe/agent/TTS -> play response
- Stream statistics and MCP screenshots
## Requirements
- Jailbroken arm64 iPhone (tested on iPhone 6 / iOS 12.5.8)
- OpenSSH and `ldid` installed on the phone
- Theos at `/var/mobile/theos` with `iPhoneOS12.4.sdk`
- The phone and build computer on the same LAN
## Copy To The Phone
From the repository root:
```sh
rsync -av iphone_app/ root@<iphone-ip>:/var/mobile/IPhoneMCP/
```
Keep the phone unlocked while launching the app. On iOS 12, `uiopen` may refuse to launch a locked device.
## Build
The included direct build script is the most reliable path on the iPhone 6:
```sh
ssh root@<iphone-ip>
su - mobile -c 'chmod +x /var/mobile/IPhoneMCP/manual_build_iphone.sh && /var/mobile/IPhoneMCP/manual_build_iphone.sh /var/mobile/IPhoneMCP'
```
This creates and signs:
```text
/var/mobile/IPhoneMCP/.manual_build/IPhoneMCP.app
```
The regular Theos build is also supported:
```sh
su - mobile -c 'env THEOS=/var/mobile/theos PATH=/usr/bin:/bin:/usr/sbin:/sbin make -C /var/mobile/IPhoneMCP clean package SDKVERSION=12.4 INCLUDE_SDKVERSION=12.4 FAKEROOT="bash /var/mobile/theos/bin/fakeroot.sh -p /var/mobile/IPhoneMCP/.theos/fakeroot" _THEOS_PLATFORM_DPKG_DEB=dpkg-deb THEOS_PLATFORM_DEB_COMPRESSION_TYPE=gzip'
```
## Package And Install
For a direct-build bundle:
```sh
PKGROOT=/var/mobile/IPhoneMCP/.manual_pkg
DEB=/var/mobile/IPhoneMCP/packages/com.reynafamily.iphonemcp_manual_iphoneos-arm.deb
rm -rf "$PKGROOT"
mkdir -p "$PKGROOT/DEBIAN" "$PKGROOT/Applications/IPhoneMCP.app" /var/mobile/IPhoneMCP/packages
cp /var/mobile/IPhoneMCP/control "$PKGROOT/DEBIAN/control"
cp /var/mobile/IPhoneMCP/.manual_build/IPhoneMCP.app/IPhoneMCP "$PKGROOT/Applications/IPhoneMCP.app/IPhoneMCP"
cp /var/mobile/IPhoneMCP/.manual_build/IPhoneMCP.app/Info.plist "$PKGROOT/Applications/IPhoneMCP.app/Info.plist"
chmod 755 "$PKGROOT/Applications/IPhoneMCP.app/IPhoneMCP"
chown -R root:wheel "$PKGROOT"
dpkg-deb -b "$PKGROOT" "$DEB"
killall IPhoneMCP 2>/dev/null || true
dpkg -i "$DEB"
```
For an update, launch **iPhone MCP** again from its Home Screen icon. Do not run
`uiopen com.reynafamily.iphonemcp`: this jailbreak's `uiopen` command expects a
URL, not an application bundle identifier.
Only run the following after the first installation if the icon does not appear:
```sh
uicache -p /Applications/IPhoneMCP.app
```
Stop the app before running it and let `uicache` exit normally. Interrupting a
refresh can leave the application database busy and make app launches appear to
crash. If that happens, recover it over SSH with `killall -9 uicache`, then tap
the app icon again.
Verify listeners:
```sh
netstat -an | grep -E '\.8080|\.8081|\.8082|\.8083|\.5000'
```
## MCP Bridge
```sh
python3 iphone_app/iphone_mcp_bridge.py --ip <iphone-ip>
```
Example client configuration:
```json
{
"mcpServers": {
"iphone6-mcp": {
"command": "python3",
"args": ["/absolute/path/to/mcp_screen/iphone_app/iphone_mcp_bridge.py", "--ip", "192.168.68.150"]
}
}
}
```
## Monochrome Streaming
- TCP `8081`: consecutive 15,000-byte ESP32 RLCD frame buffers
- UDP `8082`: fifteen 1002-byte packets per frame
- byte 0: frame id
- byte 1: chunk index 0-14
- bytes 2-1001: 1000-byte payload
The mapping is identical to `test_stream.py` in the repository.
## RGB565 Color Streaming
Color frames use TCP port `8083`. Every frame has a 16-byte big-endian header followed by RGB565 data:
| Offset | Size | Meaning |
|---|---:|---|
| 0 | 4 | ASCII `IMCR` |
| 4 | 1 | Version `1` |
| 5 | 1 | Format `1` = RGB565 big-endian |
| 6 | 2 | Width |
| 8 | 2 | Height |
| 10 | 4 | Payload length (`width * height * 2`) |
| 14 | 2 | Flags, currently `0` |
Run the included example:
```sh
python3 -m pip install Pillow
python3 iphone_app/tools/stream_color.py --ip <iphone-ip>
```
## Hermes Voice
The `hermes_voice_turn` MCP tool records a mono 16 kHz, 16-bit WAV, posts it to Hermes, plays the returned WAV, and returns the transcript and answer headers.
Default gateway:
```text
http://192.168.68.126:8642/api/esp32/voice
```
Example JSON-RPC call:
```json
{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "hermes_voice_turn",
"arguments": {
"url": "http://192.168.68.126:8642/api/esp32/voice",
"api_token": "YOUR_HERMES_API_TOKEN",
"device_id": "iphone6",
"duration_sec": 4,
"volume": 80,
"reply_mode": "sync",
"screen_url": "http://192.168.68.150:8080/api/mcp"
}
}
}
```
Do not commit the Hermes API token. It is accepted per call and is not persisted by the app.
## Implemented Tools
`clear_screen`, `draw_text`, `draw_image`, `get_screenshot`, `get_battery`, `play_tone`, `play_audio`, `play_audio_base64`, `record_voice`, `hermes_voice_turn`, `capture_selfie`, `write_file`, `read_file`, `download_file`, `get_video_streaming_instructions`, `get_stream_stats`, `reset_stream_stats`, and `set_stream_debug`.
Compatibility stubs remain for `set_led`, `get_sensors`, `scan_ble`, and `execute_python`.
## Operational Notes
- Keep the app foregrounded for reliable network service on iOS 12.
- The idle timer is disabled only while the app is active.
- Give each device a unique static DHCP reservation; duplicate IPs can make traffic reach the wrong host.
+57
View File
@@ -0,0 +1,57 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN"
"http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleDisplayName</key>
<string>iPhone MCP</string>
<key>CFBundleExecutable</key>
<string>IPhoneMCP</string>
<key>CFBundleIdentifier</key>
<string>com.reynafamily.iphonemcp</string>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>CFBundleName</key>
<string>IPhoneMCP</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleSupportedPlatforms</key>
<array>
<string>iPhoneOS</string>
</array>
<key>CFBundleShortVersionString</key>
<string>0.0.4</string>
<key>CFBundleVersion</key>
<string>4</string>
<key>MinimumOSVersion</key>
<string>12.0</string>
<key>LSRequiresIPhoneOS</key>
<true/>
<key>NSAppTransportSecurity</key>
<dict>
<key>NSAllowsArbitraryLoads</key>
<true/>
</dict>
<key>NSCameraUsageDescription</key>
<string>iPhone MCP uses the camera when an MCP client calls the selfie capture tool.</string>
<key>NSMicrophoneUsageDescription</key>
<string>iPhone MCP uses the microphone when an MCP client calls the voice recording tool.</string>
<key>UIDeviceFamily</key>
<array>
<integer>1</integer>
</array>
<key>UIRequiredDeviceCapabilities</key>
<array>
<string>arm64</string>
</array>
<key>UIStatusBarHidden</key>
<true/>
<key>UIViewControllerBasedStatusBarAppearance</key>
<true/>
<key>UISupportedInterfaceOrientations</key>
<array>
<string>UIInterfaceOrientationLandscapeLeft</string>
<string>UIInterfaceOrientationLandscapeRight</string>
</array>
</dict>
</plist>
+8
View File
@@ -0,0 +1,8 @@
Package: com.reynafamily.iphonemcp
Name: iPhone MCP
Version: 0.0.4
Architecture: iphoneos-arm
Description: A native UIKit MCP-style HTTP server for a jailbroken iPhone 6.
Maintainer: Adolfo Reyna
Author: Adolfo Reyna
Section: Utilities
+47
View File
@@ -0,0 +1,47 @@
#!/usr/bin/env python3
import argparse
import json
import sys
import urllib.request
import urllib.error
def main():
parser = argparse.ArgumentParser(description="MCP stdio bridge for the jailbroken iPhone MCP app")
parser.add_argument("--ip", required=True, help="iPhone IP address")
parser.add_argument("--port", type=int, default=8080, help="iPhone MCP HTTP port")
args = parser.parse_args()
url = f"http://{args.ip}:{args.port}/api/mcp"
sys.stderr.write(f"iPhone MCP bridge routing stdio to {url}\n")
sys.stderr.flush()
for line in sys.stdin:
try:
request = json.loads(line)
http_request = urllib.request.Request(
url,
data=json.dumps(request).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST",
)
with urllib.request.urlopen(http_request, timeout=15.0) as response:
sys.stdout.write(response.read().decode("utf-8") + "\n")
sys.stdout.flush()
except urllib.error.URLError as exc:
write_error(-32000, f"Bridge failed to reach iPhone MCP app: {exc}", request if "request" in locals() else {})
except Exception as exc:
write_error(-32603, f"Bridge error: {exc}", request if "request" in locals() else {})
def write_error(code, message, request):
sys.stdout.write(json.dumps({
"jsonrpc": "2.0",
"error": {"code": code, "message": message},
"id": request.get("id"),
}) + "\n")
sys.stdout.flush()
if __name__ == "__main__":
main()
+9
View File
@@ -0,0 +1,9 @@
#import <UIKit/UIKit.h>
#import "IMCAppDelegate.h"
int main(int argc, char *argv[]) {
@autoreleasepool {
return UIApplicationMain(argc, argv, nil,
NSStringFromClass(IMCAppDelegate.class));
}
}
+38
View File
@@ -0,0 +1,38 @@
#!/bin/sh
set -eu
PROJECT_DIR="${1:-/var/mobile/IPhoneMCP}"
SDK="${SDK:-/var/mobile/theos/sdks/iPhoneOS12.4.sdk}"
BUILD_DIR="$PROJECT_DIR/.manual_build"
OBJ_DIR="$BUILD_DIR/obj"
APP_DIR="$BUILD_DIR/IPhoneMCP.app"
mkdir -p "$OBJ_DIR" "$APP_DIR"
cp "$PROJECT_DIR/Resources/Info.plist" "$APP_DIR/Info.plist"
CFLAGS="-arch arm64 -isysroot $SDK -miphoneos-version-min=12.0 -fobjc-arc -Wno-deprecated-declarations -I$PROJECT_DIR"
SOURCES="main.m IMCAppDelegate.m IMCViewController.m IMCCanvasView.m IMCMCPServer.m IMCVideoStreamServer.m"
OBJECTS=""
for src in $SOURCES; do
obj="$OBJ_DIR/${src%.m}.o"
echo "Compiling $src"
clang $CFLAGS -c "$PROJECT_DIR/$src" -o "$obj"
OBJECTS="$OBJECTS $obj"
done
echo "Linking IPhoneMCP"
clang -arch arm64 \
-isysroot "$SDK" \
-miphoneos-version-min=12.0 \
-o "$APP_DIR/IPhoneMCP" \
$OBJECTS \
-framework UIKit \
-framework Foundation \
-framework AVFoundation \
-framework CoreGraphics
echo "Signing IPhoneMCP"
ldid -S "$APP_DIR/IPhoneMCP"
echo "$APP_DIR"
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Minimal Hermes-compatible voice endpoint for iPhone integration testing."""
import argparse
import io
import math
import struct
import wave
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
def response_wav() -> bytes:
output = io.BytesIO()
with wave.open(output, "wb") as wav:
wav.setnchannels(1)
wav.setsampwidth(2)
wav.setframerate(16000)
frames = bytearray()
for index in range(int(16000 * 0.3)):
sample = int(7000 * math.sin(2 * math.pi * 660 * index / 16000))
frames.extend(struct.pack("<h", sample))
wav.writeframes(frames)
return output.getvalue()
class Handler(BaseHTTPRequestHandler):
def do_POST(self):
if self.path != "/api/esp32/voice":
self.send_error(404)
return
length = int(self.headers.get("Content-Length", "0"))
received = self.rfile.read(length)
print(f"received {len(received)} bytes from {self.headers.get('X-Device-ID', 'unknown')}", flush=True)
payload = response_wav()
self.send_response(200)
self.send_header("Content-Type", "audio/wav")
self.send_header("Content-Length", str(len(payload)))
self.send_header("X-Hermes-Transcript", "Mock voice request received")
self.send_header("X-Hermes-Text-Response", "The iPhone Hermes integration is working")
self.send_header("X-Hermes-Response-Id", "mock-response")
self.send_header("X-Hermes-Conversation", "iphone-test")
self.end_headers()
self.wfile.write(payload)
def log_message(self, message, *args):
print(message % args, flush=True)
def main():
parser = argparse.ArgumentParser()
parser.add_argument("--host", default="0.0.0.0")
parser.add_argument("--port", type=int, default=8765)
args = parser.parse_args()
server = ThreadingHTTPServer((args.host, args.port), Handler)
print(f"mock Hermes gateway listening on {args.host}:{args.port}", flush=True)
server.serve_forever()
if __name__ == "__main__":
main()
+95
View File
@@ -0,0 +1,95 @@
#!/usr/bin/env python3
"""Stream a generated RGB565 color animation to iPhone MCP TCP port 8083."""
import argparse
import socket
import struct
import time
from PIL import Image, ImageDraw
def rgb565be(image: Image.Image) -> bytes:
image = image.convert("RGB")
output = bytearray(image.width * image.height * 2)
for index, (red, green, blue) in enumerate(image.getdata()):
value = ((red >> 3) << 11) | ((green >> 2) << 5) | (blue >> 3)
output[index * 2] = value >> 8
output[index * 2 + 1] = value & 0xFF
return bytes(output)
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("--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()
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
image = Image.new("RGB", (args.width, args.height), (12, 18, 35))
draw = ImageDraw.Draw(image)
for x in range(args.width):
red = (x * 255 // max(1, args.width - 1) + hue) % 256
blue = 255 - (x * 255 // max(1, args.width - 1))
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), f"iPhone MCP RGB565 ({args.protocol.upper()})", fill=(255, 255, 255))
draw.text((40, 72), f"Frame {frame}", fill=(255, 240, 80))
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 / 15)
finally:
sock.close()
if __name__ == "__main__":
main()
+88 -37
View File
@@ -13,6 +13,12 @@ class ES7210:
def __init__(self, i2c):
self.i2c = i2c
def _write(self, reg, val):
self.i2c.writeto_mem(self.ADDR, reg, bytes([val]))
def _read(self, reg):
return self.i2c.readfrom_mem(self.ADDR, reg, 1)[0]
def init(self, sample_rate=16000, bit_width=16):
"""Initializes the ES7210 registers for dual-microphone recording.
@@ -23,45 +29,85 @@ class ES7210:
Returns:
bool: True if initialization was successful, False otherwise.
"""
print("Initializing ES7210 Microphone ADC...")
print("Initializing ES7210 Microphone ADC (ESPHome sequence)...")
try:
# 1. Reset the chip
self._write(0x00, 0xFF) # Write all 1s to reset register
time.sleep_ms(10)
self._write(0x00, 0x00) # Release reset
# 1. Software reset
self._write(0x00, 0xFF)
time.sleep_ms(20)
self._write(0x00, 0x32)
time.sleep_ms(20)
self._write(0x01, 0x3F) # Clock off during config
# 2. Power management and system configuration
self._write(0x01, 0x00) # Enable analog power, reference voltage
self._write(0x11, 0x60) # Enable master clock PLL
# 2. Timing control
self._write(0x09, 0x30)
self._write(0x0A, 0x30)
# 3. Configure Clock Dividers
if sample_rate == 16000:
self._write(0x02, 0x0C) # BCLK divider
self._write(0x03, 0x10) # LRCK divider
else: # 44100 / 48000 defaults
self._write(0x02, 0x04)
self._write(0x03, 0x08)
# 3. High-pass filter
self._write(0x23, 0x2A)
self._write(0x22, 0x0A)
self._write(0x20, 0x0A)
self._write(0x21, 0x2A)
# 4. Input Configuration (Enable Mics 1 and 2, power down Mics 3 and 4)
self._write(0x47, 0x00) # Enable MIC1 / MIC2 analog front-ends
self._write(0x48, 0xFF) # Power down MIC3 / MIC4 path
self._write(0x49, 0x0A) # Power up PGA (Programmable Gain Amplifier) 1 and 2
self._write(0x4A, 0x00) # Power down PGA 3 and 4
# 4. Mode config: clear bit 0 of Reg 0x08
val08 = self._read(0x08)
self._write(0x08, val08 & ~0x01)
# 5. Microphone Gain Settings (+24dB standard)
# Gain range: 0x00 (0dB) to 0x0F (+45dB) in 3dB steps. 0x08 = +24dB.
self._write(0x43, 0x08) # Set MIC1 Gain (+24dB)
self._write(0x44, 0x08) # Set MIC2 Gain (+24dB)
# 5. Configure analog power
self._write(0x40, 0xC3)
# 6. Set Digital Interface Format (I2S standard format)
# Bit width: 0x00 = 24-bit, 0x01 = 16-bit, 0x02 = 8-bit, 0x03 = 32-bit
fmt = 0x01 if bit_width == 16 else 0x00
self._write(0x13, fmt) # Set serial output interface format
self._write(0x14, 0x18) # Enable frame clock / bit clock output
# 6. Mic bias voltage
self._write(0x41, 0x70)
self._write(0x42, 0x70)
# 7. Unmute ADCs and enable output
self._write(0x12, 0x00) # Enable ADC digital filters (unmute)
self._write(0x15, 0x30) # Enable output data pin (SDOUT) active
# 7. Configure I2S format (16-bit, standard I2S, TDM disabled)
self._write(0x11, 0x60)
self._write(0x12, 0x00)
# 8. Configure sample rate (16kHz with 12.288MHz MCLK)
# adc_div = 0x03, dll = 0x01, doubler = 0x01, osr = 0x20, lrck_h = 0x03, lrck_l = 0x00
reg02_val = 0x03 | (1 << 6) | (1 << 7) # 0xC3
self._write(0x02, reg02_val)
self._write(0x07, 0x20)
self._write(0x04, 0x03)
self._write(0x05, 0x00)
# 9. Clear select bits for MIC gain registers
for i in range(4):
val_gain = self._read(0x43 + i)
self._write(0x43 + i, val_gain & ~0x10)
# 10. Power down all MIC bias & PGA initially
self._write(0x4B, 0xFF)
self._write(0x4C, 0xFF)
# 11. Configure MIC1 and MIC2 (gain = 30dB -> 0x0A, enable SELMIC)
gain_reg_val = 0x0A
# Enable ADC12 clocks
val01 = self._read(0x01)
self._write(0x01, val01 & ~0x0B)
# Power on MIC1/2 bias, ADC, PGA
self._write(0x4B, 0x00)
# Select MIC1 and gain
val43 = self._read(0x43)
self._write(0x43, (val43 & ~0x0F) | 0x10 | gain_reg_val)
# Select MIC2 and gain
val44 = self._read(0x44)
self._write(0x44, (val44 & ~0x0F) | 0x10 | gain_reg_val)
# 12. Power on mics low power registers
self._write(0x47, 0x08)
self._write(0x48, 0x08)
self._write(0x49, 0x08)
self._write(0x4A, 0x08)
# 13. Power down DLL
self._write(0x06, 0x04)
# 14. Enable device state machine
self._write(0x00, 0x71)
time.sleep_ms(20)
self._write(0x00, 0x41)
time.sleep_ms(100)
print("ES7210 initialization complete.")
return True
@@ -69,9 +115,6 @@ class ES7210:
print(f"Failed to initialize ES7210: {e}")
return False
def _write(self, reg, val):
self.i2c.writeto_mem(self.ADDR, reg, bytes([val]))
def record_audio(duration_seconds=10, filename='recording.pcm'):
"""Records raw stereo PCM data from the dual microphones to a file.
@@ -120,6 +163,7 @@ def record_audio(duration_seconds=10, filename='recording.pcm'):
# Create reading buffer (reads 100ms chunks: 16000 samples/sec * 2 channels * 2 bytes/sample * 0.1s = 6400 bytes)
buffer = bytearray(6400)
mono_buf = bytearray(3200) # Half size for mono extraction
start_time = time.time()
total_bytes = 0
@@ -130,8 +174,15 @@ def record_audio(duration_seconds=10, filename='recording.pcm'):
# Read raw stereo PCM data from I2S
bytes_read = i2s.readinto(buffer)
if bytes_read > 0:
f.write(buffer[:bytes_read])
total_bytes += bytes_read
# Stereo-to-mono: extract left channel (every other 16-bit sample)
mono_len = bytes_read // 2
j = 0
for i in range(0, bytes_read, 4):
mono_buf[j] = buffer[i]
mono_buf[j + 1] = buffer[i + 1]
j += 2
f.write(mono_buf[:mono_len])
total_bytes += mono_len
print(f"Recording saved successfully to '{filename}' ({total_bytes} bytes).")
return True
+23
View File
@@ -34,6 +34,26 @@ audio_amp_pin = None
audio_amp_active_level = 0 # 0 = Active Low, 1 = Active High
audio_mic_codec = "ES8311" # "ES7210" or "ES8311"
def _i2c_recovery(sda_pin, scl_pin):
import time
scl = Pin(scl_pin, Pin.OUT)
sda = Pin(sda_pin, Pin.OUT)
scl.value(1)
sda.value(1)
time.sleep_ms(1)
for _ in range(9):
scl.value(0)
time.sleep_ms(1)
scl.value(1)
time.sleep_ms(1)
scl.value(0)
sda.value(0)
time.sleep_ms(1)
scl.value(1)
time.sleep_ms(1)
sda.value(1)
time.sleep_ms(1)
def detect_board():
global BOARD_TYPE, DISPLAY_TYPE, DISPLAY_WIDTH, DISPLAY_HEIGHT
global spi_bus, i2c_bus, display_instance, touch
@@ -60,6 +80,7 @@ def detect_board():
# Setup Touch: FT6336U on I2C(1)
try:
_i2c_recovery(2, 3)
touch_i2c = I2C(1, sda=Pin(2), scl=Pin(3), freq=400000)
from ft6336u import FT6336U
touch = FT6336U(touch_i2c, rst_pin=28, int_pin=25)
@@ -73,6 +94,7 @@ def detect_board():
# Try scanning SDA=16, SCL=15 (Hosyond pins)
try:
_i2c_recovery(16, 15)
test_i2c = SoftI2C(sda=Pin(16), scl=Pin(15))
devices = test_i2c.scan()
if 0x38 in devices:
@@ -121,6 +143,7 @@ def detect_board():
# 3. Try scanning SDA=13, SCL=14 (Waveshare RLCD pins)
try:
_i2c_recovery(13, 14)
test_i2c = SoftI2C(sda=Pin(13), scl=Pin(14))
devices = test_i2c.scan()
if 0x70 in devices or 0x51 in devices:
+239
View File
@@ -2,6 +2,7 @@ import time
from machine import Pin, SPI
import framebuf
import micropython
import struct
class ILI9341:
def __init__(self, spi, cs, dc, rst=None, bl=None, width=320, height=240, invert_color=True):
@@ -89,6 +90,185 @@ class ILI9341:
except OSError:
print(f"Error: Could not open {filename}")
@micropython.native
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
@micropython.native
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):
"""Draw a 24-bit or 32-bit uncompressed color BMP image at (x, y) coordinates."""
try:
with open(filename, 'rb') as f:
header = f.read(54)
if len(header) < 54 or header[0:2] != b'BM':
print("Err: Not a valid BMP file")
return False
pixel_offset = struct.unpack('<I', header[10:14])[0]
width, height = struct.unpack('<ii', header[18:26])
planes, bpp = struct.unpack('<HH', header[26:30])
compression = struct.unpack('<I', header[30:34])[0]
if bpp not in (24, 32):
print("Err: Only 24-bit and 32-bit BMP formats supported")
return False
if compression != 0:
print("Err: Only uncompressed BMP supported")
return False
f.seek(pixel_offset)
bottom_up = True
if height < 0:
height = -height
bottom_up = False
row_bytes = (width * bpp) // 8
row_padded = ((width * bpp + 31) // 32) * 4
read_buf = bytearray(row_padded)
rgb565_buf = bytearray(width * 2)
for row_idx in range(height):
n = f.readinto(read_buf)
if n < row_padded:
break
screen_y = y + (height - 1 - row_idx) if bottom_up else y + row_idx
if screen_y < 0 or screen_y >= self.height:
continue
x_start = x
x_end = x + width - 1
if x_start >= self.width or x_end < 0:
continue
win_x0 = max(0, x_start)
win_x1 = min(self.width - 1, x_end)
if win_x1 < win_x0:
continue
src_offset_pixels = win_x0 - x_start
win_w = win_x1 - win_x0 + 1
# Convert pixel data to RGB565 row buffer
if bpp == 24:
self._convert_bgr24_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
elif bpp == 32:
self._convert_bgra32_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
# Draw directly to the screen via SPI window
self.set_window(win_x0, screen_y, win_x1, screen_y)
self.dc(1)
self.cs(0)
self.spi.write(memoryview(rgb565_buf)[:win_w * 2])
self.cs(1)
# 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
@micropython.native
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(1)
self.cs(0)
self.spi.write(data)
self.cs(1)
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(1)
self.cs(0)
self.spi.write(memoryview(data)[src_row_offset : src_row_offset + row_len_bytes])
self.cs(1)
# Sync the internal 1-bit canvas buffer
if sync_canvas:
self._update_mono_canvas_rgb565(x, y, w, h, data)
return True
# --- SCREENSHOT ---
def save_screenshot(self, filename):
print(f"Saving screenshot to {filename}...")
@@ -259,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)
+108
View File
@@ -2,6 +2,7 @@ import time
from machine import Pin, SPI
import framebuf
import micropython
import struct
class RLCD:
def __init__(self, spi, cs, dc, rst, width=400, height=300):
@@ -82,6 +83,97 @@ class RLCD:
except OSError:
print(f"Error: Could not open {filename}")
def draw_bmp(self, filename, x=0, y=0):
"""Draw a 24-bit or 32-bit uncompressed color BMP image converted to 1-bit monochrome at (x, y) coordinates."""
try:
with open(filename, 'rb') as f:
header = f.read(54)
if len(header) < 54 or header[0:2] != b'BM':
print("Err: Not a valid BMP file")
return False
pixel_offset = struct.unpack('<I', header[10:14])[0]
width, height = struct.unpack('<ii', header[18:26])
planes, bpp = struct.unpack('<HH', header[26:30])
compression = struct.unpack('<I', header[30:34])[0]
if bpp not in (24, 32):
print("Err: Only 24-bit and 32-bit BMP formats supported")
return False
if compression != 0:
print("Err: Only uncompressed BMP supported")
return False
f.seek(pixel_offset)
bottom_up = True
if height < 0:
height = -height
bottom_up = False
row_bytes = (width * bpp) // 8
row_padded = ((width * bpp + 31) // 32) * 4
read_buf = bytearray(row_padded)
for row_idx in range(height):
n = f.readinto(read_buf)
if n < row_padded:
break
screen_y = y + (height - 1 - row_idx) if bottom_up else y + row_idx
if screen_y < 0 or screen_y >= self.height:
continue
for px in range(width):
screen_x = x + px
if screen_x < 0 or screen_x >= self.width:
continue
if bpp == 24:
b = read_buf[px * 3]
g = read_buf[px * 3 + 1]
r = read_buf[px * 3 + 2]
else: # 32-bit
b = read_buf[px * 4]
g = read_buf[px * 4 + 1]
r = read_buf[px * 4 + 2]
# Convert to monochrome (0 = White, 1 = Black)
lum = (r * 299 + g * 587 + b * 114) // 1000
c = 1 if lum < 128 else 0
self.canvas.pixel(screen_x, screen_y, c)
self.show()
return True
except Exception as e:
print("Error drawing BMP on RLCD:", e)
return False
def draw_rgb565(self, x, y, w, h, data, sync_canvas=True):
"""Draw raw RGB565 pixel data converted to 1-bit monochrome on the RLCD."""
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 (0 = White, 1 = Black in RLCD)
lum = (r * 299 + g * 587 + b * 114) // 1000
c = 1 if lum < 128 else 0
self.canvas.pixel(screen_x, screen_y, c)
self.show()
return True
# --- SCREENSHOT ---
def save_screenshot(self, filename):
print(f"Saving screenshot to {filename}...")
@@ -164,3 +256,19 @@ class RLCD:
self.cs(0); self.dc(1)
self.spi.write(self.hw_buffer)
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)
+239
View File
@@ -2,6 +2,7 @@ import time
from machine import Pin, SPI
import framebuf
import micropython
import struct
class ST7796:
def __init__(self, spi, cs, dc, rst, bl=None, width=480, height=320, invert_color=True):
@@ -89,6 +90,185 @@ class ST7796:
except OSError:
print(f"Error: Could not open {filename}")
@micropython.native
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
@micropython.native
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):
"""Draw a 24-bit or 32-bit uncompressed color BMP image at (x, y) coordinates."""
try:
with open(filename, 'rb') as f:
header = f.read(54)
if len(header) < 54 or header[0:2] != b'BM':
print("Err: Not a valid BMP file")
return False
pixel_offset = struct.unpack('<I', header[10:14])[0]
width, height = struct.unpack('<ii', header[18:26])
planes, bpp = struct.unpack('<HH', header[26:30])
compression = struct.unpack('<I', header[30:34])[0]
if bpp not in (24, 32):
print("Err: Only 24-bit and 32-bit BMP formats supported")
return False
if compression != 0:
print("Err: Only uncompressed BMP supported")
return False
f.seek(pixel_offset)
bottom_up = True
if height < 0:
height = -height
bottom_up = False
row_bytes = (width * bpp) // 8
row_padded = ((width * bpp + 31) // 32) * 4
read_buf = bytearray(row_padded)
rgb565_buf = bytearray(width * 2)
for row_idx in range(height):
n = f.readinto(read_buf)
if n < row_padded:
break
screen_y = y + (height - 1 - row_idx) if bottom_up else y + row_idx
if screen_y < 0 or screen_y >= self.height:
continue
x_start = x
x_end = x + width - 1
if x_start >= self.width or x_end < 0:
continue
win_x0 = max(0, x_start)
win_x1 = min(self.width - 1, x_end)
if win_x1 < win_x0:
continue
src_offset_pixels = win_x0 - x_start
win_w = win_x1 - win_x0 + 1
# Convert pixel data to RGB565 row buffer
if bpp == 24:
self._convert_bgr24_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
elif bpp == 32:
self._convert_bgra32_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
# Draw directly to the screen via SPI window
self.set_window(win_x0, screen_y, win_x1, screen_y)
self.dc(1)
self.cs(0)
self.spi.write(memoryview(rgb565_buf)[:win_w * 2])
self.cs(1)
# 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
@micropython.native
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(1)
self.cs(0)
self.spi.write(data)
self.cs(1)
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(1)
self.cs(0)
self.spi.write(memoryview(data)[src_row_offset : src_row_offset + row_len_bytes])
self.cs(1)
# Sync the internal 1-bit canvas buffer
if sync_canvas:
self._update_mono_canvas_rgb565(x, y, w, h, data)
return True
# --- SCREENSHOT ---
def save_screenshot(self, filename):
print(f"Saving screenshot to {filename}...")
@@ -247,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)
+184
View File
@@ -0,0 +1,184 @@
# pyright: reportMissingImports=false, reportAttributeAccessIssue=false
"""Lightweight WebSocket client for MicroPython.
This module provides a minimal, robust, memory-efficient WebSocket client
supporting HTTP upgrade handshakes and client-to-server frame masking
(RFC 6455).
"""
import usocket as socket
import ustruct as struct
import urandom as random
import ubinascii as binascii
def parse_url(url):
if not url.startswith("ws://") and not url.startswith("wss://"):
raise ValueError("URL must start with ws:// or wss://")
is_ssl = url.startswith("wss://")
url_p = url.split("://", 1)[1]
parts = url_p.split("/", 1)
host_port = parts[0]
path = "/" + parts[1] if len(parts) > 1 else "/"
if ":" in host_port:
host, port = host_port.split(":", 1)
port = int(port)
else:
host = host_port
port = 443 if is_ssl else 80
return host, port, path, is_ssl
class WebSocketClient:
def __init__(self, url, headers=None, timeout=10):
self.url = url
self.headers = headers or {}
self.timeout = timeout
self.sock = None
self.host, self.port, self.path, self.is_ssl = parse_url(url)
def connect(self):
# 1. Generate standard Sec-WebSocket-Key
raw_key = bytes([random.getrandbits(8) for _ in range(16)])
sec_key = binascii.b2a_base64(raw_key).decode('utf-8').strip()
# 2. Resolve address and connect
addr = socket.getaddrinfo(self.host, self.port)[0][-1]
self.sock = socket.socket()
self.sock.settimeout(self.timeout)
self.sock.connect(addr)
if self.is_ssl:
import ussl
self.sock = ussl.wrap_socket(self.sock)
# 3. Construct HTTP GET upgrade request
req = [
f"GET {self.path} HTTP/1.1",
f"Host: {self.host}:{self.port}",
"Upgrade: websocket",
"Connection: Upgrade",
f"Sec-WebSocket-Key: {sec_key}",
"Sec-WebSocket-Version: 13",
]
for k, v in self.headers.items():
req.append(f"{k}: {v}")
req.append("\r\n")
self.sock.write("\r\n".join(req).encode('utf-8'))
# 4. Read HTTP response status and headers
status_line = self.sock.readline()
if not status_line or b"101" not in status_line:
self.close()
raise RuntimeError(f"Handshake failed status: {status_line.decode('utf-8', 'ignore').strip()}")
while True:
line = self.sock.readline()
if not line or line == b"\r\n":
break
def send_frame(self, opcode, payload, fin=True):
"""Send a masked WebSocket frame to the server (client-to-server MUST be masked)."""
if not self.sock:
raise RuntimeError("Not connected")
# Header byte 0: FIN and Opcode
b0 = 0x80 if fin else 0
b0 |= (opcode & 0x0F)
# Header byte 1: Mask bit (always 1 for client) and Payload length
payload_len = len(payload)
if payload_len <= 125:
header = struct.pack("!BB", b0, 0x80 | payload_len)
elif payload_len <= 65535:
header = struct.pack("!BBH", b0, 0x80 | 126, payload_len)
else:
header = struct.pack("!BBQ", b0, 0x80 | 127, payload_len)
# Generate 4-byte random masking key
mask = bytes([random.getrandbits(8) for _ in range(4)])
# Apply masking key (XOR payload)
masked_payload = bytearray(payload_len)
for i in range(payload_len):
masked_payload[i] = payload[i] ^ mask[i % 4]
# Send header, mask, and masked payload
self.sock.write(header)
self.sock.write(mask)
self.sock.write(masked_payload)
def send_text(self, text):
self.send_frame(0x1, text.encode('utf-8'))
def send_binary(self, data):
self.send_frame(0x2, data)
def recv_frame(self):
"""Receive an unmasked WebSocket frame from the server (server-to-client is unmasked)."""
if not self.sock:
raise RuntimeError("Not connected")
try:
header = self.sock.read(2)
except Exception:
# Handle socket timeout or disconnect
return None, None
if not header or len(header) < 2:
return None, None
b0, b1 = header
opcode = b0 & 0x0F
masked = bool(b1 & 0x80)
payload_len = b1 & 0x7F
if payload_len == 126:
len_bytes = self.sock.read(2)
if not len_bytes or len(len_bytes) < 2:
return None, None
payload_len = struct.unpack("!H", len_bytes)[0]
elif payload_len == 127:
len_bytes = self.sock.read(8)
if not len_bytes or len(len_bytes) < 8:
return None, None
payload_len = struct.unpack("!Q", len_bytes)[0]
if masked:
mask = self.sock.read(4)
if not mask or len(mask) < 4:
return None, None
# Read actual payload
payload = b""
while len(payload) < payload_len:
needed = payload_len - len(payload)
chunk = self.sock.read(needed)
if not chunk:
break
payload += chunk
if len(payload) < payload_len:
# Socket closed prematurely
return None, None
if masked:
# Unmask payload if masked
unmasked = bytearray(payload_len)
for i in range(payload_len):
unmasked[i] = payload[i] ^ mask[i % 4]
payload = bytes(unmasked)
return opcode, payload
def close(self):
if self.sock:
try:
# Send close frame
self.send_frame(0x8, b"")
except Exception:
pass
try:
self.sock.close()
except Exception:
pass
self.sock = None
+172 -97
View File
@@ -1,4 +1,5 @@
import time
import json
import sys
import machine
from machine import Pin, SPI, I2C, I2S
@@ -35,6 +36,7 @@ from video_stream import VideoStreamServer
from audio_util import ES8311
import struct
import urequests
from websocket_client import WebSocketClient
def create_wav_header(data_size):
# Generates a 44-byte WAV header for 16kHz, 16-bit mono PCM
@@ -149,6 +151,33 @@ def stream_hermes_request(url, headers, filename):
return status_code, resp_headers, s
def sync_ntp_time(rtc_chip):
if rtc_chip is None:
return False
import ntptime
import wifi_config
tz_offset = getattr(wifi_config, 'TZ_OFFSET', 0)
print(f"Syncing time from NTP server... (Timezone offset: {tz_offset} hours)")
for attempt in range(3):
try:
utc_sec = ntptime.time()
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])
rtc_chip.set_datetime(dt)
rtc_chip.sync_to_system()
t_str = f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d} {t[3]:02d}:{t[4]:02d}:{t[5]:02d}"
print(f"Successfully synced RTC with NTP. Local time: {t_str}")
return True
except Exception as e:
print(f"NTP sync attempt {attempt+1} failed: {e}")
time.sleep_ms(200)
return False
# LED mode options for manual cycling
led_modes = [
("Red (Breathing)", lambda led: led.set_color(40, 0, 0), "breath"),
@@ -240,6 +269,9 @@ def main():
from mcp_server import MCPServer
mcp = MCPServer(display, led, battery, sensor, rtc_chip, ble_uart, vstream=vstream, touch=touch)
mcp.start(port=80)
if wlan.isconnected():
sync_ntp_time(rtc_chip)
except Exception as ne:
print("Failed to start network services:", ne)
@@ -317,6 +349,7 @@ def main():
print(f"Wi-Fi Connected! IP Address: {ip_addr}")
last_action_str = f"Wi-Fi Connected: {ip_addr}"
force_dashboard_redraw = True
sync_ntp_time(rtc_chip)
# Check voice assistant trigger (touch screen or physical key button)
if is_talk_trigger_active():
@@ -366,39 +399,86 @@ def main():
except Exception as e:
print("Failed to set mic gain:", e)
# 2. Configure I2S RX for recording (Mono 16kHz)
# 2. Configure I2S RX for recording (Stereo 16kHz — ES7210 outputs stereo)
i2s_rx = I2S(1,
sck=Pin(board_config.audio_i2s_sck),
ws=Pin(board_config.audio_i2s_ws),
sd=Pin(board_config.audio_i2s_rx_sd),
mode=I2S.RX,
ibuf=8000,
ibuf=16000,
rate=16000,
bits=16,
format=I2S.MONO)
format=I2S.STEREO)
ws_connected = False
ws = None
try:
# Determine dynamic device ID based on board configuration
device_id = "esp32_screen" if board_config.BOARD_TYPE == 'WAVESHARE_RLCD' else "little32"
headers = {
"Authorization": "Bearer mcT1YA1vOr9wXSiHpCYalweEGGZKX-PIfZv2drp8BSg",
"X-Device-ID": device_id
}
ws = WebSocketClient("ws://192.168.68.126:8642/api/esp32/voice/ws", headers=headers, timeout=30)
ws.connect()
ws.send_text(json.dumps({
"event": "start",
"device_id": device_id,
"sample_rate": 16000,
"channels": 1,
"sample_width": 2,
"format": "pcm_s16le"
}))
ws.recv_frame() # ready
ws.recv_frame() # listening
ws_connected = True
except Exception as wse:
print("WebSocket connect error:", wse)
draw_status_bar("Connection Error")
time.sleep(2)
if ws_connected and ws:
draw_status_bar("Recording & streaming...")
# Open I2S RX for recording (Stereo 16kHz)
i2s_rx = I2S(1,
sck=Pin(board_config.audio_i2s_sck),
ws=Pin(board_config.audio_i2s_ws),
sd=Pin(board_config.audio_i2s_rx_sd),
mode=I2S.RX,
ibuf=16000,
rate=16000,
bits=16,
format=I2S.STEREO)
total_data_bytes = 0
buffer = bytearray(1024)
buffer = bytearray(2048)
mono_buf = bytearray(1024)
rec_start_time = time.ticks_ms()
max_rec_duration_ms = 10000 # 10 seconds max duration
try:
with open("voice_rec.pcm", "wb") as f:
while True:
# Safety: check duration
# Stream chunks while talk trigger is active
while is_talk_trigger_active():
elapsed = time.ticks_diff(time.ticks_ms(), rec_start_time)
if elapsed >= max_rec_duration_ms:
print("Recording stopped: maximum duration reached")
break
# Read I2S chunk
bytes_read = i2s_rx.readinto(buffer)
if bytes_read > 0:
f.write(buffer[:bytes_read])
total_data_bytes += bytes_read
mono_len = bytes_read // 2
j = 0
for i in range(0, bytes_read, 4):
mono_buf[j] = buffer[i]
mono_buf[j + 1] = buffer[i + 1]
j += 2
ws.send_binary(mono_buf[:mono_len])
total_data_bytes += mono_len
except Exception as e:
print("Error recording:", e)
print("Error during recording/streaming:", e)
finally:
i2s_rx.deinit()
if board_config.audio_mic_codec == "ES8311":
@@ -407,117 +487,112 @@ def main():
except:
pass
if total_data_bytes >= 1000:
draw_status_bar("Sending & processing...")
# Determine dynamic device ID based on board configuration
device_id = "esp32_screen" if board_config.BOARD_TYPE == 'WAVESHARE_RLCD' else "little32"
# Headers
headers = {
"Authorization": "Bearer mcT1YA1vOr9wXSiHpCYalweEGGZKX-PIfZv2drp8BSg",
"Content-Type": "audio/wav",
"X-Device-ID": device_id,
"X-Hermes-Screen-Device": device_id,
"X-Hermes-Reply-Mode": "ack"
}
s = None
if total_data_bytes < 3200:
print("Recording too short, cancelling.")
try:
status_code, resp_headers, s = stream_hermes_request(
"http://192.168.68.126:8642/api/esp32/voice",
headers,
"voice_rec.pcm"
)
ws.send_text(json.dumps({"event": "cancel"}))
ws.close()
except:
pass
draw_status_bar("Cancelled")
time.sleep(1)
else:
draw_status_bar("Processing...")
try:
ws.send_text(json.dumps({"event": "stop"}))
if status_code == 200:
content_type = resp_headers.get("content-type", "")
content_len = int(resp_headers.get("content-length", 0))
i2s_tx = None
received_audio_bytes = 0
speaker_write_failed = False
while True:
opcode, payload = ws.recv_frame()
if opcode is None:
break
if "audio" in content_type and content_len >= 44:
if opcode == 0x1: # Text JSON event
try:
event_data = json.loads(payload.decode('utf-8'))
evt = event_data.get("event")
if evt == "transcript":
txt = event_data.get("text", "")
print(f"Heard: {txt}")
draw_status_bar(f"Heard: {txt[:20]}...")
elif evt == "thinking":
draw_status_bar("Thinking...")
elif evt == "response_text":
txt = event_data.get("text", "")
print(f"Response: {txt}")
elif evt == "audio_start":
draw_status_bar("Playing response...")
# Read and parse WAV header (first 44 bytes)
header = socket_read_exactly(s, 44)
if len(header) == 44 and header[0:4] == b'RIFF' and header[8:12] == b'WAVE':
import struct
fmt_idx = header.find(b'fmt ')
if fmt_idx != -1:
fmt_data = header[fmt_idx+8 : fmt_idx+24]
audio_format, channels, sample_rate, byte_rate, block_align, bits = struct.unpack('<HHIIHH', fmt_data)
data_idx = header.find(b'data')
if data_idx != -1:
# Skip remaining bytes to start of audio data
bytes_to_skip = (data_idx + 8) - 44
if bytes_to_skip > 0:
socket_read_exactly(s, bytes_to_skip)
# Play using board_config parameters
on_val = 0 if board_config.audio_amp_active_level == 0 else 1
off_val = 1 if board_config.audio_amp_active_level == 0 else 0
amp_pin.value(on_val) # Enable Amp
i2s_format = I2S.MONO if channels == 1 else I2S.STEREO
# Initialize ES8311 Speaker DAC
try:
from audio_util import ES8311
dac = ES8311(i2c)
dac.init(sample_rate=16000)
dac.set_volume(85)
except Exception as dace:
print("Failed to initialize ES8311 DAC for playback:", dace)
i2s_format = I2S.MONO # WebSocket audio response is mono
i2s_tx = I2S(1,
sck=Pin(board_config.audio_i2s_sck),
ws=Pin(board_config.audio_i2s_ws),
sd=Pin(board_config.audio_i2s_tx_sd),
mode=I2S.TX,
ibuf=4096,
rate=sample_rate,
bits=bits,
rate=16000,
bits=16,
format=i2s_format)
try:
remaining_bytes = content_len - (data_idx + 8)
while remaining_bytes > 0:
chunk_to_read = min(remaining_bytes, 2048)
try:
chunk = s.recv(chunk_to_read)
except OSError:
break
if not chunk:
break
i2s_tx.write(chunk)
remaining_bytes -= len(chunk)
finally:
elif evt == "audio_end":
if i2s_tx:
time.sleep_ms(150)
off_val = 1 if board_config.audio_amp_active_level == 0 else 0
amp_pin.value(off_val) # Disable Amp
i2s_tx.deinit()
i2s_tx = None
if speaker_write_failed:
draw_status_bar("Speaker write failed")
else:
# Ack or text response
resp_text = ""
if content_len > 0:
try:
resp_text = socket_read_exactly(s, content_len).decode('utf-8', errors='ignore')
except:
pass
print("Non-WAV response received:", resp_text)
if "ack" in resp_text.lower() or "ok" in resp_text.lower() or "success" in resp_text.lower():
draw_status_bar("Success (Ack received)")
else:
draw_status_bar("Response processed.")
draw_status_bar(f"Recv {received_audio_bytes} bytes")
elif evt == "done":
break
elif evt == "error":
msg = event_data.get("message", "Unknown error")
print(f"Server error: {msg}")
draw_status_bar(f"Error: {msg[:20]}")
time.sleep(2)
else:
content_len = int(resp_headers.get("content-length", 0))
resp_text = "Unknown error"
if content_len > 0:
break
except Exception as e:
print("Error parsing event text:", e)
elif opcode == 0x2: # Binary frame (Audio WAV chunk)
if i2s_tx:
chunk = payload
if chunk.startswith(b'RIFF') and len(chunk) > 44:
chunk = chunk[44:]
try:
resp_text = socket_read_exactly(s, content_len).decode('utf-8', errors='ignore')
except:
pass
print("Error response:", resp_text)
draw_status_bar(f"Error: {status_code}")
time.sleep(2)
i2s_tx.write(chunk)
received_audio_bytes += len(chunk)
except Exception as e:
speaker_write_failed = True
print("Error writing to speaker:", e)
except Exception as he:
print("Hermes HTTP post failed:", he)
sys.print_exception(he)
print("Hermes WS query failed:", he)
draw_status_bar("Connection Error")
time.sleep(2)
finally:
if s:
try:
s.close()
ws.close()
except:
pass
+275 -21
View File
@@ -77,8 +77,11 @@ class MCPServer:
if self.sock is None:
return
t0 = 0
t1 = 0
try:
client, addr = self.sock.accept()
t0 = time.ticks_ms()
except OSError as e:
import errno
err = getattr(e, 'errno', None)
@@ -96,11 +99,22 @@ class MCPServer:
try:
# Handle incoming connection
client.settimeout(2.0)
req = client.recv(2048).decode('utf-8')
req_bytes = client.recv(2048)
if not req_bytes:
client.close()
return
# Simple HTTP parser
lines = req.split('\r\n')
if len(lines) == 0:
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')
t1 = time.ticks_ms()
if len(lines) == 0 or not lines[0]:
client.close()
return
@@ -120,17 +134,15 @@ class MCPServer:
content_length = int(line.split(':')[1].strip())
break
# Locate start of JSON body
body = ""
if '\r\n\r\n' in req:
body = req.split('\r\n\r\n', 1)[1]
body_bytes = bytearray(req_bytes[body_start_idx:])
while len(body_bytes) < content_length:
chunk = client.recv(min(1024, content_length - len(body_bytes)))
if not chunk:
break
body_bytes.extend(chunk)
# Read remaining body if not fully received
while len(body) < content_length:
body += client.recv(1024).decode('utf-8')
# Parse JSON-RPC 2.0 Request
rpc_req = json.loads(body)
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)
@@ -148,10 +160,72 @@ class MCPServer:
time.sleep_ms(10)
continue
total_sent += sent
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 = client.recv(min(1024, content_length - len(body_bytes)))
if not chunk:
break
body_bytes.extend(chunk)
t2 = time.ticks_ms()
# Parse query parameters from path (e.g. /api/screen/raw?x=0&y=0&w=320&h=240)
width = getattr(self.display, 'width', 320)
height = getattr(self.display, 'height', 240)
x, y, w, h = 0, 0, width, height
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)
t3 = time.ticks_ms()
# Draw directly using display raw RGB565 method
self.override_active = True
self.display.draw_rgb565(x, y, w, h, body_bytes, sync_canvas=False)
t4 = time.ticks_ms()
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
data_to_send = resp.encode('utf-8')
total_sent = 0
while total_sent < len(data_to_send):
sent = client.write(data_to_send[total_sent:])
if sent is None or sent == 0:
time.sleep_ms(10)
continue
total_sent += sent
t5 = time.ticks_ms()
print("[RAW API Profile] total={}ms: accept_to_header={}ms, recv_body={}ms, route_n_prep={}ms, draw={}ms, send_resp={}ms".format(
time.ticks_diff(t5, t0),
time.ticks_diff(t1, t0),
time.ticks_diff(t2, t1),
time.ticks_diff(t3, t2),
time.ticks_diff(t4, t3),
time.ticks_diff(t5, t4)
))
else:
# Return 404
resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
client.send(resp.encode('utf-8'))
client.write(resp.encode('utf-8'))
except Exception as e:
print("Error handling client:", e)
finally:
@@ -162,6 +236,10 @@ class MCPServer:
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",
@@ -169,7 +247,7 @@ class MCPServer:
"tools": [
{
"name": "clear_screen",
"description": "Clear the 480x320 screen to white (0) or black (1).",
"description": f"Clear the {width}x{height} screen to white (0) or black (1).",
"inputSchema": {
"type": "object",
"properties": {
@@ -180,13 +258,13 @@ class MCPServer:
},
{
"name": "draw_text",
"description": "Draw text on the screen at specified (x,y) coordinates.",
"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": "X coordinate (0-470)"},
"y": {"type": "integer", "description": "Y coordinate (0-310)"},
"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"]
@@ -233,12 +311,12 @@ class MCPServer:
},
{
"name": "get_screenshot",
"description": "Capture the current reflective LCD screen rendering as a PNG image.",
"description": "Capture the current screen rendering as a PNG image.",
"inputSchema": {"type": "object", "properties": {}}
},
{
"name": "draw_image",
"description": "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.",
"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": {
@@ -250,6 +328,44 @@ class MCPServer:
"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.",
@@ -358,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"]
}
}
]
},
@@ -410,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", ""))
@@ -507,6 +661,106 @@ class MCPServer:
pass
return "Image displayed successfully."
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")
import binascii
try:
bmp_bytes = binascii.a2b_base64(bmp_b64)
except Exception as e:
raise ValueError(f"Failed to decode base64 BMP: {e}")
filename = 'temp_recv.bmp'
with open(filename, 'wb') as f:
f.write(bmp_bytes)
try:
success = self.display.draw_bmp(filename, x, y)
if not success:
raise RuntimeError("Failed to parse or draw BMP image on screen.")
finally:
import os
try:
os.remove(filename)
except:
pass
return "Color BMP image displayed successfully."
elif name == "get_capabilities":
is_color = getattr(self.display, '__class__', None) is not None and self.display.__class__.__name__ != 'RLCD'
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":
import ntptime
import wifi_config
tz_offset = getattr(wifi_config, 'TZ_OFFSET', 0)
success = False
t = None
for attempt in range(3):
try:
utc_sec = ntptime.time()
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()
success = True
break
except Exception as e:
time.sleep_ms(200)
if success and t is not None:
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}"
else:
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")
import binascii
try:
rgb_bytes = binascii.a2b_base64(rgb_b64)
except Exception as e:
raise ValueError(f"Failed to decode base64 RGB565: {e}")
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)")
try:
self.display.draw_rgb565(x, y, w, h, rgb_bytes)
except Exception as e:
raise RuntimeError(f"Failed to draw RGB565: {e}")
return "Raw RGB565 data displayed successfully."
elif name == "write_file":
path = str(args.get("path"))
content = str(args.get("content"))
@@ -0,0 +1,143 @@
# Hermes ESP32 voice-state animations for MicroPython screen boards.
# Uploaded by the API server; can also be run manually with:
# exec(open('hermes_voice_animations.py').read()); run_state('ack')
import time
try:
import board_config
except Exception:
board_config = None
def _display():
return getattr(board_config, 'display_instance', None) if board_config else None
def _sleep(ms):
try:
time.sleep_ms(ms)
except AttributeError:
time.sleep(ms / 1000)
def _text(d, msg, x, y, scale=1, color=1):
if hasattr(d, 'text_large') and scale > 1:
try:
d.text_large(msg, x, y, scale=scale, color=color)
return
except TypeError:
pass
d.text(msg, x, y, color)
def _center_text(d, msg, y, scale=1, color=1):
width = getattr(d, 'width', 320)
char_w = 8 * scale
x = max(0, (width - len(msg) * char_w) // 2)
_text(d, msg, x, y, scale=scale, color=color)
def _wrap(text, width_chars=34, max_lines=4):
words = str(text or '').replace('\n', ' ').split()
lines, line = [], ''
for w in words:
candidate = (line + ' ' + w).strip()
if len(candidate) > width_chars and line:
lines.append(line)
line = w
if len(lines) >= max_lines:
break
else:
line = candidate
if line and len(lines) < max_lines:
lines.append(line)
return lines
def _spinner(d, title, subtitle='', frames=24, delay=70):
width, height = getattr(d, 'width', 320), getattr(d, 'height', 240)
cx, cy = width // 2, height // 2 - 8
spokes = [(-22,0),(-16,-16),(0,-22),(16,-16),(22,0),(16,16),(0,22),(-16,16)]
for frame in range(frames):
d.clear(0)
_center_text(d, title, 18, scale=2)
for i, (dx, dy) in enumerate(spokes):
color = 1 if (i + frame) % len(spokes) in (0, 1, 2, 3) else 0
size = 4 if i == frame % len(spokes) else 2
d.fill_rect(cx + dx - size // 2, cy + dy - size // 2, size, size, color)
d.rect(8, height - 34, width - 16, 18, 1)
d.fill_rect(10, height - 32, ((width - 20) * ((frame % 16) + 1)) // 16, 14, 1)
if subtitle:
_center_text(d, subtitle[:32], height - 56, scale=1)
d.show()
_sleep(delay)
def ack(frames=18):
d = _display()
if not d:
return
width, height = d.width, d.height
for i in range(frames):
d.clear(0)
_center_text(d, 'GOT IT', 18, scale=2)
# expanding check pulse
box = 48 + (i % 6) * 8
x, y = width // 2 - box // 2, height // 2 - box // 2
d.rect(x, y, box, box, 1)
d.line(width//2 - 24, height//2, width//2 - 6, height//2 + 18, 1)
d.line(width//2 - 6, height//2 + 18, width//2 + 30, height//2 - 22, 1)
_center_text(d, 'Listening captured', height - 36, scale=1)
d.show()
_sleep(55)
def captioning(frames=36):
d = _display()
if not d:
return
width, height = d.width, d.height
for i in range(frames):
d.clear(0)
_center_text(d, 'CAPTIONING', 16, scale=2)
for bar in range(9):
h = 8 + ((i * 5 + bar * 13) % 42)
x = width // 2 - 72 + bar * 18
d.fill_rect(x, height // 2 - h // 2, 10, h, 1)
dots = '.' * ((i // 5) % 4)
_center_text(d, 'Transcribing' + dots, height - 42, scale=1)
d.show()
_sleep(65)
def waiting(text='', frames=44):
d = _display()
if not d:
return
width, height = d.width, d.height
for i in range(frames):
d.clear(0)
_center_text(d, 'THINKING', 12, scale=2)
r = 18 + (i % 10) * 2
cx, cy = width // 2, height // 2 - 10
d.rect(cx - r, cy - r, r * 2, r * 2, 1)
d.rect(cx - r//2, cy - r//2, r, r, 1)
if text:
d.text('Heard:', 10, height - 68, 1)
for idx, line in enumerate(_wrap(text, 36, 3)):
d.text(line[:38], 10, height - 54 + idx * 12, 1)
else:
_center_text(d, 'Waiting for reply...', height - 42, scale=1)
d.show()
_sleep(75)
def run_state(state='ack', text='', frames=None):
state = str(state or 'ack').lower()
if state in ('ack', 'listening', 'got_it'):
ack(frames or 18)
elif state in ('caption', 'captioning', 'transcribing'):
captioning(frames or 36)
elif state in ('wait', 'waiting', 'thinking', 'reply'):
waiting(text, frames or 44)
else:
_spinner(_display(), 'HERMES', state[:28], frames or 20)
+97
View File
@@ -0,0 +1,97 @@
# pong.py
import time
import board_config
def run_pong(frames=200):
display = board_config.display_instance
if not display:
print("No display found")
return
width = display.width
height = display.height
# Ball state
bx, by = width // 2, height // 2
vx, vy = 6, 4
ball_size = 6
# Paddle state
pad_w, pad_h = 8, 40
p1_y = height // 2 - pad_h // 2
p2_y = height // 2 - pad_h // 2
p1_score = 0
p2_score = 0
for _ in range(frames):
# Update ball
bx += vx
by += vy
# Bounce top/bottom
if by <= 0 or by >= height - ball_size:
vy = -vy
# Paddle AI (simple tracking)
target1 = by - pad_h // 2
p1_y += int((target1 - p1_y) * 0.15)
p1_y = max(0, min(height - pad_h, p1_y))
target2 = by - pad_h // 2
p2_y += int((target2 - p2_y) * 0.15)
p2_y = max(0, min(height - pad_h, p2_y))
# Bounce left paddle
if vx < 0 and bx <= 20 and bx >= 10:
if by + ball_size >= p1_y and by <= p1_y + pad_h:
vx = -vx
vy += int((by - (p1_y + pad_h // 2)) * 0.2)
# Bounce right paddle
if vx > 0 and bx >= width - 20 - pad_w and bx <= width - 10:
if by + ball_size >= p2_y and by <= p2_y + pad_h:
vx = -vx
vy += int((by - (p2_y + pad_h // 2)) * 0.2)
# Score left
if bx < 0:
p2_score += 1
bx, by = width // 2, height // 2
vx = 6
vy = 4
# Score right
if bx > width:
p1_score += 1
bx, by = width // 2, height // 2
vx = -6
vy = -4
# Draw everything
display.clear(0)
# Center line
for y in range(0, height, 15):
display.fill_rect(width // 2 - 1, y, 2, 8, 1)
# Paddles
display.fill_rect(10, p1_y, pad_w, pad_h, 1)
display.fill_rect(width - 20, p2_y, pad_w, pad_h, 1)
# Ball
display.fill_rect(bx, by, ball_size, ball_size, 1)
# Score
display.text(str(p1_score), width // 2 - 30, 10, 1)
display.text(str(p2_score), width // 2 + 20, 10, 1)
display.show()
time.sleep_ms(30)
# Clear screen at the end
display.clear(0)
display.show()
# Run the animation
run_pong()
+25
View File
@@ -0,0 +1,25 @@
import urllib.request
import json
def call_mcp(method, params={}):
url = "http://192.168.68.118/api/mcp"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=5.0) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
return {"error": str(e)}
res = call_mcp("tools/call", {"name": "get_stream_stats", "arguments": {}})
print(json.dumps(res, indent=2))
+30
View File
@@ -0,0 +1,30 @@
import serial
import time
import sys
def main():
port = "/dev/cu.usbmodem411CF91D65091"
print(f"Connecting to {port} at 115200...")
try:
ser = serial.Serial(port, 115200, timeout=1.0)
except Exception as e:
print(f"Error opening serial port: {e}")
return
print("Reading serial output for 3 seconds...")
start_time = time.time()
try:
while time.time() - start_time < 3.0:
if ser.in_waiting > 0:
data = ser.read(ser.in_waiting)
sys.stdout.write(data.decode("utf-8", "ignore"))
sys.stdout.flush()
time.sleep(0.1)
except KeyboardInterrupt:
pass
finally:
ser.close()
print("\nSerial connection closed.")
if __name__ == "__main__":
main()
+39
View File
@@ -0,0 +1,39 @@
import serial
import time
import sys
def main():
port = "/dev/cu.usbmodem411CF91D65091"
print(f"Connecting to {port} at 115200...")
try:
ser = serial.Serial(port, 115200, timeout=1.0)
except Exception as e:
print(f"Error: {e}")
return
print("Sending Ctrl-C to interrupt...")
ser.write(b'\x03')
time.sleep(0.5)
print("--- Current buffer contents ---")
print(ser.read_all().decode("utf-8", "ignore"))
print("Sending Ctrl-D to trigger soft reboot...")
ser.write(b'\x04')
print("--- Capturing boot output (10 seconds) ---")
start_time = time.time()
try:
while time.time() - start_time < 10.0:
if ser.in_waiting > 0:
data = ser.read(ser.in_waiting)
sys.stdout.write(data.decode("utf-8", "ignore"))
sys.stdout.flush()
time.sleep(0.05)
except KeyboardInterrupt:
pass
finally:
ser.close()
print("\nConnection closed.")
if __name__ == "__main__":
main()
+68
View File
@@ -0,0 +1,68 @@
import serial
import time
import sys
def run_diagnostic(port):
print(f"Opening serial port {port}...")
try:
ser = serial.Serial(port, 115200, timeout=1)
except Exception as e:
print("Failed to open serial port:", e)
return False
print("Interrupting board execution (Ctrl+C)...")
for _ in range(30):
ser.write(b'\x03')
time.sleep(0.02)
time.sleep(0.5)
ser.read_all()
print("Entering raw REPL...")
ser.write(b'\x01')
time.sleep(0.2)
resp = ser.read_all()
if b'raw REPL' not in resp and b'OK' not in resp and b'raw' not in resp:
print("Could not enter raw REPL:", resp)
ser.close()
return False
# Python code to test playback
test_code = """
import audio_util
try:
print("Starting play_tone test...")
audio_util.play_tone(440, 1000, 50)
print("play_tone test finished!")
except Exception as e:
import sys
sys.print_exception(e)
"""
print("Running diagnostic code on the board...")
# Send code
ser.write(test_code.encode('utf-8'))
ser.write(b'\x04') # Ctrl+D to execute
time.sleep(0.2)
# Read output
print("\n--- Board Output ---")
start = time.time()
while time.time() - start < 5.0:
line = ser.readline()
if line:
print(line.decode('utf-8', 'ignore').strip())
# Reset the board to return to normal
print("Resetting board to normal...")
ser.write(b'\x02') # Ctrl+B to exit raw REPL
time.sleep(0.1)
ser.write(b'\x03')
time.sleep(0.1)
ser.write(b"import machine\nmachine.reset()\n")
ser.close()
return True
if __name__ == "__main__":
run_diagnostic('/dev/cu.usbmodem101')
+64
View File
@@ -0,0 +1,64 @@
import urllib.request
import urllib.parse
import time
from PIL import Image, ImageDraw
IP = "192.168.68.128"
def send_raw_frame(payload, x=0, y=0, w=320, h=240, fmt=None):
params = {"x": x, "y": y, "w": w, "h": h}
if fmt:
params["format"] = fmt
q = urllib.parse.urlencode(params)
url = f"http://{IP}/api/screen/raw?{q}"
req = urllib.request.Request(
url,
data=payload,
headers={"Content-Type": "application/octet-stream"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=5.0) as resp:
return resp.read().decode("utf-8")
except Exception as e:
return f"Error: {e}"
# 1. Test Color Frame (RGB565)
print("Generating raw color frame...")
img_color = Image.new("RGB", (320, 240))
draw = ImageDraw.Draw(img_color)
for x in range(320):
for y in range(240):
r = (x * 255 // 319)
g = (y * 255 // 239)
b = 128
img_color.putpixel((x, y), (r, g, b))
draw.text((20, 20), "RAW COLOR STREAM (RGB565)", fill=(255, 255, 255))
# Convert to RGB565 BE bytes
color_payload = bytearray(320 * 240 * 2)
for idx, (r, g, b) in enumerate(img_color.getdata()):
val = ((r >> 3) << 11) | ((g >> 2) << 5) | (b >> 3)
color_payload[idx * 2] = (val >> 8) & 0xFF
color_payload[idx * 2 + 1] = val & 0xFF
print("Streaming raw color frame...")
res = send_raw_frame(bytes(color_payload), fmt="rgb565")
print("Response:", res)
time.sleep(3.0)
# 2. Test Monochrome Frame (1-bit)
print("\nGenerating raw monochrome frame...")
img_mono = Image.new("1", (320, 240), 0)
draw_mono = ImageDraw.Draw(img_mono)
draw_mono.rectangle([10, 10, 310, 230], outline=1, width=3)
draw_mono.text((30, 100), "RAW MONOCHROME STREAM (1-BIT)", fill=1)
# Convert PIL 1-bit to MHMSB bytearray
mono_payload = img_mono.tobytes()
print(f"Monochrome payload size: {len(mono_payload)} bytes")
print("Streaming raw monochrome frame...")
res_mono = send_raw_frame(mono_payload, fmt="mono")
print("Response:", res_mono)
+48
View File
@@ -0,0 +1,48 @@
import urllib.request
import json
import time
def call_mcp(method, params={}):
url = "http://192.168.68.128/api/mcp"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": method,
"params": params
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode("utf-8"),
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=5.0) as resp:
return json.loads(resp.read().decode("utf-8"))
except Exception as e:
return {"error": str(e)}
# 1. Fetch start stats
print("Fetching start stats...")
res_start = call_mcp("tools/call", {"name": "get_stream_stats", "arguments": {}})
print(json.dumps(res_start, indent=2))
# 2. Wait 3 seconds
print("Waiting 3 seconds...")
time.sleep(3.0)
# 3. Fetch end stats
print("Fetching end stats...")
res_end = call_mcp("tools/call", {"name": "get_stream_stats", "arguments": {}})
print(json.dumps(res_end, indent=2))
# Calculate FPS
try:
start_stats = json.loads(res_start["result"]["content"][0]["text"])
end_stats = json.loads(res_end["result"]["content"][0]["text"])
frames_diff = end_stats["frames_drawn"] - start_stats["frames_drawn"]
packets_diff = end_stats["udp_packets_received"] - start_stats["udp_packets_received"]
print(f"\nResult: Drew {frames_diff} frames in 3.0 seconds ({frames_diff / 3.0:.1f} FPS)")
print(f"Packets received: {packets_diff} in 3.0 seconds ({packets_diff / 3.0:.1f} pps)")
except Exception as e:
print("Failed to calculate FPS:", e)
+88
View File
@@ -0,0 +1,88 @@
import serial
import time
import sys
def upload_via_serial(port, local_path, remote_path):
print(f"Opening serial port {port}...")
try:
ser = serial.Serial(port, 115200, timeout=0.5)
except Exception as e:
print("Failed to open serial port:", e)
return False
print("Interrupting board (Ctrl+C)...")
for _ in range(50):
ser.write(b'\x03')
time.sleep(0.02)
time.sleep(0.5)
ser.read_all()
print("Entering raw REPL...")
ser.write(b'\x01')
time.sleep(0.2)
resp = ser.read_all()
if b'raw REPL' not in resp and b'OK' not in resp and b'raw' not in resp:
print("Could not enter raw REPL:", resp)
ser.close()
return False
with open(local_path, 'rb') as f:
content = f.read()
print(f"Uploading {local_path} to remote {remote_path} ({len(content)} bytes)...")
# Open file
cmd = f"f = open('{remote_path}', 'wb')\n".encode('utf-8')
ser.write(cmd)
ser.write(b'\x04')
time.sleep(0.1)
ser.read_all()
# Write chunks
chunk_size = 256
for i in range(0, len(content), chunk_size):
chunk = content[i:i+chunk_size]
cmd = b"f.write(" + repr(chunk).encode('utf-8') + b")\n"
ser.write(cmd)
ser.write(b'\x04')
time.sleep(0.05)
sys.stdout.write('.')
sys.stdout.flush()
cmd = b"f.close()\n"
ser.write(cmd)
ser.write(b'\x04')
time.sleep(0.1)
ser.read_all()
print(f"\nUploaded {remote_path} successfully!")
ser.write(b'\x02')
ser.close()
return True
def main():
port = '/dev/cu.usbmodem101'
files_to_upload = [
("main.py", "main.py"),
("demo_websocket_voice.py", "demo_websocket_voice.py")
]
for local, remote in files_to_upload:
if not upload_via_serial(port, local, remote):
print(f"Failed to upload {local}. Exiting.")
sys.exit(1)
print("Rebooting board...")
try:
ser = serial.Serial(port, 115200, timeout=0.5)
ser.write(b'\x03')
time.sleep(0.1)
ser.write(b'import machine\nmachine.reset()\n')
ser.close()
print("Reset triggered successfully!")
except Exception as e:
print("Could not trigger reset:", e)
if __name__ == "__main__":
main()
+117
View File
@@ -0,0 +1,117 @@
# screensaver.py
import time
import board_config
def run_screensaver(duration_sec=120):
display = board_config.display_instance
touch = board_config.touch
if not display:
print("No display found")
return
width = display.width
height = display.height
# Calculate card dimensions based on screen size
card_w = int(width * 0.4)
card_h = int(height * 0.6)
y_offset = int((height - card_h) / 2)
x_gap = int(width * 0.08)
x1 = int((width - (card_w * 2 + x_gap)) / 2)
x2 = x1 + card_w + x_gap
# Use larger text scale on larger screens
scale = 6 if width >= 400 else 5
def draw_card(x, y, val_str, flip_state=0):
# Draw white card body
display.fill_rect(x, y, card_w, card_h, 1)
# Center coordinates for text inside card
text_w = 2 * 8 * scale
text_h = 8 * scale
tx = x + (card_w - text_w) // 2
ty = y + (card_h - text_h) // 2
# Draw text in black on the white card
display.text_large(val_str, tx, ty, scale=scale, c=0)
# Draw split line and side notches to simulate 3D split-flap cards
split_y = y + card_h // 2
if flip_state == 0:
display.line(x, split_y, x + card_w, split_y, 0)
display.fill_rect(x, split_y - 3, 3, 6, 0)
display.fill_rect(x + card_w - 3, split_y - 3, 3, 6, 0)
elif flip_state == 1:
# Widening line (flap rotating slightly down)
display.fill_rect(x, split_y - 3, card_w, 6, 0)
display.fill_rect(x, split_y - 5, 4, 10, 0)
display.fill_rect(x + card_w - 4, split_y - 5, 4, 10, 0)
elif flip_state == 2:
# Thick black bar (flap mid-rotation covering numbers)
display.fill_rect(x, split_y - 12, card_w, 24, 0)
elif flip_state == 3:
# Settling line (flap finishing rotation)
display.fill_rect(x, split_y - 4, card_w, 8, 0)
display.fill_rect(x, split_y - 5, 4, 10, 0)
display.fill_rect(x + card_w - 4, split_y - 5, 4, 10, 0)
start_time = time.time()
last_h, last_m = -1, -1
print("Screensaver running. Touch the screen to exit.")
while time.time() - start_time < duration_sec:
# Check if screen is touched to exit
if touch and touch.is_touched():
print("Touch detected. Exiting screensaver.")
break
t = time.localtime()
h, m = t[3], t[4]
h_str = f"{h:02d}"
m_str = f"{m:02d}"
# Play flip animation if minute changes
if m != last_m:
if last_m != -1:
# Play 3-frame split-flap rotation
for frame in [1, 2, 3]:
display.clear(0)
if h != last_h:
draw_card(x1, y_offset, h_str, flip_state=frame)
else:
draw_card(x1, y_offset, h_str, flip_state=0)
draw_card(x2, y_offset, m_str, flip_state=frame)
display.show()
time.sleep_ms(150)
last_h, last_m = h, m
# Draw standard static state
display.clear(0)
draw_card(x1, y_offset, h_str, flip_state=0)
draw_card(x2, y_offset, m_str, flip_state=0)
# Draw blinking seconds colon between cards
if t[5] % 2 == 0:
cx = width // 2
cy = height // 2
display.fill_rect(cx - 3, cy - 15, 6, 6, 1)
display.fill_rect(cx - 3, cy + 9, 6, 6, 1)
display.show()
# Check for touch during the 1-second pause
for _ in range(10):
if touch and touch.is_touched():
break
time.sleep_ms(100)
# Clear display on exit
display.clear(0)
display.show()
# Run the screensaver
run_screensaver()
+48
View File
@@ -0,0 +1,48 @@
# Retro Video Game Screensavers for Reyna little32 ESP32-S3
A collection of 5 standalone, retro-game-inspired MicroPython screensavers designed for high-contrast, low-resolution reflective LCD screens (specifically the Waveshare ESP32-S3-RLCD-4.2).
## Screensavers List
1. **Maze Chomper (`maze_chomper.py`)**
* **Inspired By:** *maze-chase arcade classic*
* **Description:** A wedge-shaped mouth chomps its way through a symmetric maze grid of dots. A ghost follows and chases the chomper. If the ghost catches the chomper, a collision is registered, and the board resets.
2. **Block Puzzle (`block_puzzle.py`)**
* **Inspired By:** *falling-block puzzle classic*
* **Description:** Tetromino pieces fall from the top of the board. A simple self-playing heuristic automatically shifts and rotates each piece to target a column. When a horizontal row is fully occupied, it is cleared. The game resets once blocks reach the ceiling.
3. **Alien Invaders (`alien_invaders.py`)**
* **Inspired By:** *alien-wave shooter classic*
* **Description:** Crawling leg-animated invaders march side-to-side and descend down the screen. An automated defender ship moves underneath to aim and destroy invaders. Both invaders and ship shoot bullets/bombs, causing pixel-spark explosions.
4. **Asteroid Drift (`asteroid_drift.py`)**
* **Inspired By:** *vector-rock space classic*
* **Description:** A wireframe, vector-style ship sits in the center of the screen, automatically aiming its nose towards the closest drifting space rock. The ship fires lasers to blow up and split larger rocks into medium and small fragments.
5. **Paddle Bounce (`paddle_bounce.py`)**
* **Inspired By:** *paddle-ball arcade classic*
* **Description:** Two vertical paddles play tennis against each other. The ball bounces off the top/bottom boundaries and the paddles, increasing speed with each paddle contact. Simulated reaction times and position offsets introduce realistic mistakes, allowing scores to accrue.
## How It Works
* **Standalone Execution:** Each script is designed to run completely standalone and can be executed via REPL or boot routines.
* **Hardware Fallback:** If `board_config` or `display_instance` is not available (such as running under test environments or without initialized hardware), the script gracefully prints a warning message and exits without throwing a crash exception.
* **Touch Stop Detection:** Every script contains a robust `check_touch` function that intercepts touch interrupts/coordinates across various formats (`board_config.touch_instance`, `board_config.touch`, `display.get_touch()`, or `display.touch()`). If a touch is registered, the loop terminates immediately.
* **Frame Control:** Every script exposes a `run(text="", frames=None)` function. If `frames` is set to `1` (useful for syntax checks or single-frame rendering), the screensaver renders exactly one frame and exits. If `frames` is `None` or `<=0`, it runs indefinitely.
## Example Execution Commands
Upload the desired screensaver to your board using `mpremote`:
```bash
mpremote connect /dev/cu.usbmodem101 cp screensavers/maze_chomper.py :
```
Run the screensaver directly from the MicroPython REPL:
```python
# Execute the entire file to run automatically
exec(open("maze_chomper.py").read())
```
Or import the module and invoke with custom parameters:
```python
# Import and run with frame limits (e.g. exit after 300 frames)
import maze_chomper
maze_chomper.run(text="BREAKROOM SCREEN", frames=300)
```
+322
View File
@@ -0,0 +1,322 @@
# Standalone MicroPython Screensaver: Alien Invaders
# Inspired by alien-wave shooter classic. Exposes run(text="", frames=None).
import time
import math
import random
try:
import board_config
display = getattr(board_config, 'display_instance', None)
except Exception:
board_config = None
display = None
def sleep_ms(ms):
try:
time.sleep_ms(ms)
except AttributeError:
time.sleep(ms / 1000.0)
def check_touch(d, bc):
"""Robust check for screen touch to stop the screensaver."""
if bc is not None:
touch_inst = getattr(bc, 'touch_instance', None)
if touch_inst is not None:
if hasattr(touch_inst, 'is_touched'):
try:
if touch_inst.is_touched():
return True
except Exception:
pass
for meth in ('get_touch', 'read_touch'):
if hasattr(touch_inst, meth):
try:
res = getattr(touch_inst, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
touch_val = getattr(bc, 'touch', None)
if touch_val is not None:
if callable(touch_val):
try:
res = touch_val()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
else:
if hasattr(touch_val, 'is_touched'):
try:
if touch_val.is_touched():
return True
except Exception:
pass
for meth in ('get_touch', 'read_touch'):
if hasattr(touch_val, meth):
try:
res = getattr(touch_val, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
if d is not None:
for meth in ('get_touch', 'touch'):
if hasattr(d, meth):
try:
res = getattr(d, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
return False
def draw_invader(d, ix, iy, frame_state):
# Alternating animation frames for crawling legs
d.fill_rect(ix - 7, iy - 4, 14, 3, 1)
d.fill_rect(ix - 5, iy - 1, 10, 3, 1)
if frame_state:
# Legs out
d.fill_rect(ix - 7, iy + 2, 2, 3, 1)
d.fill_rect(ix + 5, iy + 2, 2, 3, 1)
else:
# Legs in
d.fill_rect(ix - 4, iy + 2, 2, 3, 1)
d.fill_rect(ix + 2, iy + 2, 2, 3, 1)
# Eyes
d.fill_rect(ix - 3, iy - 3, 2, 2, 0)
d.fill_rect(ix + 1, iy - 3, 2, 2, 0)
def draw_ship(d, sx, sy):
d.fill_rect(sx - 9, sy - 4, 18, 5, 1)
d.fill_rect(sx - 3, sy - 8, 6, 4, 1)
d.fill_rect(sx - 1, sy - 12, 2, 4, 1)
def run(text="", frames=None):
if not display:
print("board_config.display_instance is not available. Skipping screensaver.")
return
width = getattr(display, 'width', 400)
height = getattr(display, 'height', 300)
# Game parameters
invader_w, invader_h = 14, 9
col_spacing = 28
row_spacing = 18
grid_cols = 6
grid_rows = 3
# Initialize game objects
invaders = [] # entries: [col, row, alive_bool]
def reset_invaders():
invaders.clear()
for r in range(grid_rows):
for c in range(grid_cols):
invaders.append([c, r, True])
reset_invaders()
inv_grid_x = (width - (grid_cols * col_spacing)) // 2
inv_grid_y = 40
inv_speed = 1.5
inv_dir = 1
ship_x = width // 2
ship_y = height - 15
ship_speed = 2.0
bullets = [] # player bullets: [x, y]
bombs = [] # alien bombs: [x, y]
explosions = [] # particle list: [x, y, vx, vy, life]
score = 0
frame = 0
cooldown = 0
death_timer = 0
try:
while True:
if frames is not None and frames > 0 and frame >= frames:
break
if check_touch(display, board_config):
print("Screensaver stopped by screen touch.")
break
# If player was hit, run a death screen-shake / reset sequence
if death_timer > 0:
death_timer -= 1
display.clear(0)
# Simple screen shake by offsetting lines slightly
shake = random.randint(-4, 4)
display.text("SYSTEM ERROR", width // 2 - 48 + shake, height // 2 - 4 + shake, 1)
display.show()
if death_timer == 0:
reset_invaders()
bullets.clear()
bombs.clear()
explosions.clear()
inv_grid_x = (width - (grid_cols * col_spacing)) // 2
inv_grid_y = 40
ship_x = width // 2
frame += 1
sleep_ms(30)
continue
# AI: Move player ship toward closest invader horizontally
alive_invaders = [inv for inv in invaders if inv[2]]
if not alive_invaders:
score += 500
reset_invaders()
inv_grid_y = 40
bullets.clear()
bombs.clear()
continue
# Find the lowest/closest invader to follow
target_inv = None
max_r = -1
for inv in alive_invaders:
if inv[1] > max_r:
max_r = inv[1]
target_inv = inv
if target_inv:
inv_col, inv_row = target_inv[0], target_inv[1]
t_x = inv_grid_x + inv_col * col_spacing + col_spacing // 2
if ship_x < t_x:
ship_x = min(width - 15, ship_x + ship_speed)
elif ship_x > t_x:
ship_x = max(15, ship_x - ship_speed)
# Auto shooting
if cooldown > 0:
cooldown -= 1
elif random.random() < 0.15:
bullets.append([ship_x, ship_y - 12])
cooldown = 12
# Invader movements
# Calculate grid bounds based on active invaders
left_col = min(inv[0] for inv in alive_invaders)
right_col = max(inv[0] for inv in alive_invaders)
grid_left = inv_grid_x + left_col * col_spacing
grid_right = inv_grid_x + right_col * col_spacing + col_spacing
# Check boundary collision
if inv_dir == 1 and grid_right >= width - 15:
inv_dir = -1
inv_grid_y += row_spacing
elif inv_dir == -1 and grid_left <= 15:
inv_dir = 1
inv_grid_y += row_spacing
else:
inv_grid_x += inv_dir * inv_speed
# Invader shooting (bombs)
if random.random() < 0.03:
shooter = random.choice(alive_invaders)
bx = inv_grid_x + shooter[0] * col_spacing + col_spacing // 2
by = inv_grid_y + shooter[1] * row_spacing + row_spacing
bombs.append([bx, by])
# Update bullets (going up)
next_bullets = []
for b in bullets:
b[1] -= 4
hit = False
# Collision check with invaders
for inv in invaders:
if inv[2]:
ix = inv_grid_x + inv[0] * col_spacing + col_spacing // 2
iy = inv_grid_y + inv[1] * row_spacing + row_spacing // 2
if abs(b[0] - ix) < inv_col * 0 + 8 and abs(b[1] - iy) < 6:
inv[2] = False
hit = True
score += 30
# Spawn explosion particles
for _ in range(8):
explosions.append([ix, iy, random.uniform(-2, 2), random.uniform(-2, 2), 10])
break
if not hit and b[1] > 28:
next_bullets.append(b)
bullets = next_bullets
# Update bombs (going down)
next_bombs = []
for b in bombs:
b[1] += 3
# Collision check with player ship
if abs(b[0] - ship_x) < 10 and abs(b[1] - ship_y) < 6:
death_timer = 50 # Start system error state
# Large explosion
for _ in range(20):
explosions.append([ship_x, ship_y, random.uniform(-4, 4), random.uniform(-4, 4), 20])
break
elif b[1] < height:
next_bombs.append(b)
bombs = next_bombs
# Update explosions
next_explosions = []
for p in explosions:
p[0] += p[2]
p[1] += p[3]
p[4] -= 1
if p[4] > 0:
next_explosions.append(p)
explosions = next_explosions
# Check game over by invaders reaching bottom
max_y = max(inv_grid_y + inv[1] * row_spacing for inv in alive_invaders)
if max_y >= ship_y - 8:
death_timer = 50
for _ in range(20):
explosions.append([ship_x, ship_y, random.uniform(-4, 4), random.uniform(-4, 4), 20])
continue
# Render game objects
display.clear(0)
# Draw header
display.text(text if text else "INVADER DRIFT", 10, 10, 1)
display.text(f"SCORE: {score:05d}", width - 120, 10, 1)
display.line(0, 26, width, 26, 1)
# Draw invaders
inv_anim_state = (frame // 10) % 2 == 0
for inv in invaders:
if inv[2]:
ix = inv_grid_x + inv[0] * col_spacing + col_spacing // 2
iy = inv_grid_y + inv[1] * row_spacing + row_spacing // 2
draw_invader(display, ix, iy, inv_anim_state)
# Draw player ship
draw_ship(display, ship_x, ship_y)
# Draw player bullets
for b in bullets:
display.fill_rect(b[0] - 1, b[1] - 3, 2, 6, 1)
# Draw alien bombs
for b in bombs:
display.line(b[0], b[1] - 3, b[0], b[1] + 3, 1)
display.line(b[0] - 1, b[1], b[0] + 1, b[1], 1)
# Draw explosion particles
for p in explosions:
display.fill_rect(int(p[0]), int(p[1]), 2, 2, 1)
display.show()
frame += 1
sleep_ms(30)
except KeyboardInterrupt:
print("Screensaver stopped by user interrupt.")
if __name__ in ('__main__', '__name__'):
run()
+319
View File
@@ -0,0 +1,319 @@
# Standalone MicroPython Screensaver: Asteroid Drift
# Inspired by vector-rock space classic. Exposes run(text="", frames=None).
import time
import math
import random
try:
import board_config
display = getattr(board_config, 'display_instance', None)
except Exception:
board_config = None
display = None
def sleep_ms(ms):
try:
time.sleep_ms(ms)
except AttributeError:
time.sleep(ms / 1000.0)
def check_touch(d, bc):
"""Robust check for screen touch to stop the screensaver."""
if bc is not None:
touch_inst = getattr(bc, 'touch_instance', None)
if touch_inst is not None:
if hasattr(touch_inst, 'is_touched'):
try:
if touch_inst.is_touched():
return True
except Exception:
pass
for meth in ('get_touch', 'read_touch'):
if hasattr(touch_inst, meth):
try:
res = getattr(touch_inst, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
touch_val = getattr(bc, 'touch', None)
if touch_val is not None:
if callable(touch_val):
try:
res = touch_val()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
else:
if hasattr(touch_val, 'is_touched'):
try:
if touch_val.is_touched():
return True
except Exception:
pass
for meth in ('get_touch', 'read_touch'):
if hasattr(touch_val, meth):
try:
res = getattr(touch_val, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
if d is not None:
for meth in ('get_touch', 'touch'):
if hasattr(d, meth):
try:
res = getattr(d, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
return False
def make_rock(x, y, radius):
num_verts = 8
vertices = []
for i in range(num_verts):
angle = i * (2 * math.pi / num_verts)
vert_r = radius + random.uniform(-radius / 3.0, radius / 3.0)
dx = vert_r * math.cos(angle)
dy = vert_r * math.sin(angle)
vertices.append((dx, dy))
# Velocity
speed = random.uniform(0.5, 1.5)
v_angle = random.uniform(0, 2 * math.pi)
vx = speed * math.cos(v_angle)
vy = speed * math.sin(v_angle)
return {
'x': x,
'y': y,
'vx': vx,
'vy': vy,
'radius': radius,
'vertices': vertices
}
def draw_rock(d, rock):
rx, ry = rock['x'], rock['y']
verts = rock['vertices']
n = len(verts)
for i in range(n):
x1, y1 = rx + verts[i][0], ry + verts[i][1]
x2, y2 = rx + verts[(i+1)%n][0], ry + verts[(i+1)%n][1]
d.line(int(x1), int(y1), int(x2), int(y2), 1)
def run(text="", frames=None):
if not display:
print("board_config.display_instance is not available. Skipping screensaver.")
return
width = getattr(display, 'width', 400)
height = getattr(display, 'height', 300)
px, py = width // 2, height // 2
theta = 0.0
bullets = [] # elements: {'x', 'y', 'vx', 'vy', 'life'}
rocks = [] # elements: rock dicts
explosions = [] # elements: {'x', 'y', 'vx', 'vy', 'life'}
score = 0
cooldown = 0
frame = 0
# Spawn initial rocks
def spawn_initial_rocks():
rocks.clear()
for _ in range(4):
# Spawn rocks away from center
rx = random.choice([random.uniform(20, px - 50), random.uniform(px + 50, width - 20)])
ry = random.choice([random.uniform(20, py - 50), random.uniform(py + 50, height - 20)])
rocks.append(make_rock(rx, ry, 24))
spawn_initial_rocks()
try:
while True:
if frames is not None and frames > 0 and frame >= frames:
break
if check_touch(display, board_config):
print("Screensaver stopped by screen touch.")
break
# If no rocks are left, spawn more
if not rocks:
score += 1000
spawn_initial_rocks()
bullets.clear()
continue
# AI: Target the nearest rock
nearest_rock = None
min_dist = 999999
for r in rocks:
dx = r['x'] - px
dy = r['y'] - py
dist = dx*dx + dy*dy
if dist < min_dist:
min_dist = dist
nearest_rock = r
if nearest_rock:
tx = nearest_rock['x']
ty = nearest_rock['y']
target_theta = math.atan2(ty - py, tx - px)
# Rotate toward target
angle_diff = target_theta - theta
# Normalize angle to -pi..pi
while angle_diff > math.pi:
angle_diff -= 2 * math.pi
while angle_diff < -math.pi:
angle_diff += 2 * math.pi
if abs(angle_diff) < 0.08:
theta = target_theta
else:
theta += 0.08 if angle_diff > 0 else -0.08
# Shoot if aligned and off cooldown
if abs(angle_diff) < 0.25:
if cooldown == 0:
bx = px + 12 * math.cos(theta)
by = py + 12 * math.sin(theta)
bvx = 5.0 * math.cos(theta)
bvy = 5.0 * math.sin(theta)
bullets.append({'x': bx, 'y': by, 'vx': bvx, 'vy': bvy, 'life': 45})
cooldown = 15
if cooldown > 0:
cooldown -= 1
# Update rocks
for r in rocks:
r['x'] += r['vx']
r['y'] += r['vy']
# Wrap boundaries
rad = r['radius']
if r['x'] < -rad:
r['x'] = width + rad
elif r['x'] > width + rad:
r['x'] = -rad
if r['y'] < -rad:
r['y'] = height + rad
elif r['y'] > height + rad:
r['y'] = -rad
# Update bullets
next_bullets = []
for b in bullets:
b['x'] += b['vx']
b['y'] += b['vy']
b['life'] -= 1
# Wrap
if b['x'] < 0:
b['x'] = width
elif b['x'] > width:
b['x'] = 0
if b['y'] < 0:
b['y'] = height
elif b['y'] > height:
b['y'] = 0
# Collision with rocks
hit = False
for r in rocks:
rdx = b['x'] - r['x']
rdy = b['y'] - r['y']
if rdx*rdx + rdy*rdy < r['radius']*r['radius']:
hit = True
rocks.remove(r)
score += int(100 / r['radius'] * 10)
# Split rock
if r['radius'] > 8:
new_r = r['radius'] // 2
rocks.append(make_rock(r['x'], r['y'], new_r))
rocks.append(make_rock(r['x'], r['y'], new_r))
# Spawn particle sparks
for _ in range(6):
explosions.append({
'x': r['x'],
'y': r['y'],
'vx': random.uniform(-2, 2),
'vy': random.uniform(-2, 2),
'life': 12
})
break
if not hit and b['life'] > 0:
next_bullets.append(b)
bullets = next_bullets
# Update explosions
next_explosions = []
for p in explosions:
p['x'] += p['vx']
p['y'] += p['vy']
p['life'] -= 1
if p['life'] > 0:
next_explosions.append(p)
explosions = next_explosions
# Render frame
display.clear(0)
# Draw header
display.text(text if text else "ASTEROID DRIFT", 10, 10, 1)
display.text(f"SCORE: {score:05d}", width - 120, 10, 1)
display.line(0, 26, width, 26, 1)
# Draw ship (triangle)
fx = px + 12 * math.cos(theta)
fy = py + 12 * math.sin(theta)
blx = px + 8 * math.cos(theta + 2.5)
bly = py + 8 * math.sin(theta + 2.5)
brx = px + 8 * math.cos(theta - 2.5)
bry = py + 8 * math.sin(theta - 2.5)
display.line(int(fx), int(fy), int(blx), int(bly), 1)
display.line(int(blx), int(bly), int(brx), int(bry), 1)
display.line(int(brx), int(bry), int(fx), int(fy), 1)
# Draw small engine flame when firing
if cooldown > 10:
flame_tip_x = px - 12 * math.cos(theta) + random.uniform(-2, 2)
flame_tip_y = py - 12 * math.sin(theta) + random.uniform(-2, 2)
display.line(int(blx), int(bly), int(flame_tip_x), int(flame_tip_y), 1)
display.line(int(brx), int(bry), int(flame_tip_x), int(flame_tip_y), 1)
# Draw rocks
for r in rocks:
draw_rock(display, r)
# Draw bullets
for b in bullets:
display.fill_rect(int(b['x']) - 1, int(b['y']) - 1, 2, 2, 1)
# Draw particles
for p in explosions:
display.pixel(int(p['x']), int(p['y']), 1) if hasattr(display, 'pixel') else display.fill_rect(int(p['x']), int(p['y']), 1, 1, 1)
display.show()
frame += 1
sleep_ms(30)
except KeyboardInterrupt:
print("Screensaver stopped by user interrupt.")
if __name__ in ('__main__', '__name__'):
run()
+297
View File
@@ -0,0 +1,297 @@
# Standalone MicroPython Screensaver: Block Puzzle
# Inspired by falling-block puzzle classic. Exposes run(text="", frames=None).
import time
import math
import random
try:
import board_config
display = getattr(board_config, 'display_instance', None)
except Exception:
board_config = None
display = None
def sleep_ms(ms):
try:
time.sleep_ms(ms)
except AttributeError:
time.sleep(ms / 1000.0)
def check_touch(d, bc):
"""Robust check for screen touch to stop the screensaver."""
if bc is not None:
touch_inst = getattr(bc, 'touch_instance', None)
if touch_inst is not None:
if hasattr(touch_inst, 'is_touched'):
try:
if touch_inst.is_touched():
return True
except Exception:
pass
for meth in ('get_touch', 'read_touch'):
if hasattr(touch_inst, meth):
try:
res = getattr(touch_inst, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
touch_val = getattr(bc, 'touch', None)
if touch_val is not None:
if callable(touch_val):
try:
res = touch_val()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
else:
if hasattr(touch_val, 'is_touched'):
try:
if touch_val.is_touched():
return True
except Exception:
pass
for meth in ('get_touch', 'read_touch'):
if hasattr(touch_val, meth):
try:
res = getattr(touch_val, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
if d is not None:
for meth in ('get_touch', 'touch'):
if hasattr(d, meth):
try:
res = getattr(d, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
return False
# Shapes relative coordinates
SHAPES = [
[(0, 0), (1, 0), (2, 0), (3, 0)], # I
[(0, 0), (1, 0), (0, 1), (1, 1)], # O
[(1, 0), (0, 1), (1, 1), (2, 1)], # T
[(0, 0), (0, 1), (1, 1), (2, 1)], # L
[(2, 0), (0, 1), (1, 1), (2, 1)], # J
[(1, 0), (2, 0), (0, 1), (1, 1)], # S
[(0, 0), (1, 0), (1, 1), (2, 1)] # Z
]
def rotate_coords(coords, rotation):
# Rotate coords clockwise
res = coords
for _ in range(rotation % 4):
res = [(-y, x) for x, y in res]
# Normalize coordinates so min x and min y are at least 0 (easier bounding)
min_x = min(x for x, y in res)
min_y = min(y for x, y in res)
return [(x - min_x, y - min_y) for x, y in res]
def get_piece_dims(coords):
w = max(x for x, y in coords) - min(x for x, y in coords) + 1
h = max(y for x, y in coords) - min(y for x, y in coords) + 1
return w, h
def run(text="", frames=None):
if not display:
print("board_config.display_instance is not available. Skipping screensaver.")
return
width = getattr(display, 'width', 400)
height = getattr(display, 'height', 300)
# Calculate grid sizing
cell_size = (height - 40) // 20
board_w = 10 * cell_size
board_h = 20 * cell_size
board_x = (width - board_w) // 2
board_y = (height - board_h) // 2
grid = [[0 for _ in range(10)] for _ in range(20)]
score = 0
lines_cleared = 0
# Game states
current_shape_idx = random.randint(0, len(SHAPES) - 1)
next_shape_idx = random.randint(0, len(SHAPES) - 1)
# Piece rotation & coords
piece_rot = 0
piece_coords = rotate_coords(SHAPES[current_shape_idx], piece_rot)
# Initial piece placement
pw, ph = get_piece_dims(piece_coords)
px = (10 - pw) // 2
py = 0
# Self-playing AI target
target_x = random.randint(0, 10 - pw)
target_rot = random.randint(0, 3)
frame = 0
game_over_delay = 0
try:
while True:
if frames is not None and frames > 0 and frame >= frames:
break
if check_touch(display, board_config):
print("Screensaver stopped by screen touch.")
break
# If in game over state, delay and restart
if game_over_delay > 0:
game_over_delay -= 1
if game_over_delay == 0:
grid = [[0 for _ in range(10)] for _ in range(20)]
score = 0
lines_cleared = 0
display.clear(0)
display.text("GAME OVER", width // 2 - 36, height // 2 - 4, 1)
display.show()
frame += 1
sleep_ms(30)
continue
# AI logic: adjust rotation and column position as block falls
# Try to rotate toward target rotation
if piece_rot != target_rot:
new_rot = (piece_rot + 1) % 4
new_coords = rotate_coords(SHAPES[current_shape_idx], new_rot)
npw, nph = get_piece_dims(new_coords)
# Keep position valid when rotating
npx = max(0, min(px, 10 - npw))
# Validate
valid = True
for dx, dy in new_coords:
tx, ty = npx + dx, py + dy
if ty >= 20 or tx < 0 or tx >= 10 or grid[ty][tx]:
valid = False
break
if valid:
piece_rot = new_rot
piece_coords = new_coords
px = npx
# Try to move toward target column
if px != target_x:
dx_step = 1 if target_x > px else -1
npx = px + dx_step
valid = True
for dx, dy in piece_coords:
tx, ty = npx + dx, py + dy
if ty >= 20 or tx < 0 or tx >= 10 or grid[ty][tx]:
valid = False
break
if valid:
px = npx
# Apply gravity every 4 frames (speeds up self-play nicely)
if frame % 4 == 0:
npy = py + 1
can_drop = True
for dx, dy in piece_coords:
tx, ty = px + dx, npy + dy
if ty >= 20 or grid[ty][tx]:
can_drop = False
break
if can_drop:
py = npy
else:
# Lock piece
for dx, dy in piece_coords:
grid[py + dy][px + dx] = 1
# Check lines
lines_this_turn = 0
for r in range(20):
if all(grid[r]):
del grid[r]
grid.insert(0, [0 for _ in range(10)])
lines_this_turn += 1
if lines_this_turn > 0:
lines_cleared += lines_this_turn
score += [0, 40, 100, 300, 1200][min(lines_this_turn, 4)]
# Spawn next piece
current_shape_idx = next_shape_idx
next_shape_idx = random.randint(0, len(SHAPES) - 1)
piece_rot = 0
piece_coords = rotate_coords(SHAPES[current_shape_idx], piece_rot)
pw, ph = get_piece_dims(piece_coords)
px = (10 - pw) // 2
py = 0
# Set next targets
target_x = random.randint(0, 10 - pw)
target_rot = random.randint(0, 3)
# Check game over (spawning block collides immediately)
for dx, dy in piece_coords:
if grid[py + dy][px + dx]:
game_over_delay = 60 # 2 seconds of game over display
break
# Draw game state
display.clear(0)
# Draw board border
display.rect(board_x - 2, board_y - 2, board_w + 4, board_h + 4, 1)
# Draw grid contents
for r in range(20):
for c in range(10):
cx_px = board_x + c * cell_size
cy_px = board_y + r * cell_size
if grid[r][c] == 1:
display.fill_rect(cx_px + 1, cy_px + 1, cell_size - 1, cell_size - 1, 1)
# Draw active piece
for dx, dy in piece_coords:
cx_px = board_x + (px + dx) * cell_size
cy_px = board_y + (py + dy) * cell_size
if 0 <= py + dy < 20:
display.fill_rect(cx_px + 1, cy_px + 1, cell_size - 1, cell_size - 1, 1)
# Add a small white dot in falling piece for visual difference
display.fill_rect(cx_px + cell_size//2, cy_px + cell_size//2, 2, 2, 0)
# Side panel (Left) - Title & Info
display.text(text if text else "FALLING", 10, 20, 1)
display.text("PUZZLE", 10, 32, 1)
display.text(f"SCORE:", 10, 80, 1)
display.text(f"{score:05d}", 10, 92, 1)
display.text(f"LINES:", 10, 130, 1)
display.text(f"{lines_cleared}", 10, 142, 1)
# Side panel (Right) - Next piece preview
preview_x = board_x + board_w + 20
display.text("NEXT:", preview_x, 20, 1)
display.rect(preview_x, 35, 50, 50, 1)
next_coords = rotate_coords(SHAPES[next_shape_idx], 0)
npw, nph = get_piece_dims(next_coords)
off_x = preview_x + (50 - npw * cell_size) // 2
off_y = 35 + (50 - nph * cell_size) // 2
for dx, dy in next_coords:
display.fill_rect(off_x + dx * cell_size + 1, off_y + dy * cell_size + 1, cell_size - 1, cell_size - 1, 1)
display.show()
frame += 1
sleep_ms(30)
except KeyboardInterrupt:
print("Screensaver stopped by user interrupt.")
if __name__ in ('__main__', '__name__'):
run()
+29
View File
@@ -0,0 +1,29 @@
{
"screensavers": [
{
"filename": "maze_chomper.py",
"inspired_by_style": "maze-chase arcade classic",
"purpose": "A wedge-shaped chomper navigates a grid maze eating dots while being chased by a ghost. If they collide, they reset after showing a collision warning."
},
{
"filename": "block_puzzle.py",
"inspired_by_style": "falling-block puzzle classic",
"purpose": "Falling tetromino blocks are auto-arranged by a self-playing heuristic, aligning and locking into a grid. Full lines are cleared, and a game over triggers a reset when the grid fills to the top."
},
{
"filename": "alien_invaders.py",
"inspired_by_style": "alien-wave shooter classic",
"purpose": "A grid of crawling invaders crawls across and down the screen. An automated player tank tracks the closest invaders and shoots them down, dodging falling enemy bombs."
},
{
"filename": "asteroid_drift.py",
"inspired_by_style": "vector-rock space classic",
"purpose": "A central vector-style triangular ship rotates to automatically track and shoot laser bullets at drifting wireframe rocks. Shot rocks split into smaller rocks and wrap around borders."
},
{
"filename": "paddle_bounce.py",
"inspired_by_style": "paddle-ball arcade classic",
"purpose": "Two automated paddles play table tennis with a bouncing ball that speeds up with each hit. Paddles track the ball with minor errors to allow points to be scored."
}
]
}
+289
View File
@@ -0,0 +1,289 @@
# Standalone MicroPython Screensaver: Maze Chomper
# Inspired by maze-chase arcade classic. Exposes run(text="", frames=None).
import time
import math
import random
try:
import board_config
display = getattr(board_config, 'display_instance', None)
except Exception:
board_config = None
display = None
def sleep_ms(ms):
try:
time.sleep_ms(ms)
except AttributeError:
time.sleep(ms / 1000.0)
def check_touch(d, bc):
"""Robust check for screen touch to stop the screensaver."""
if bc is not None:
touch_inst = getattr(bc, 'touch_instance', None)
if touch_inst is not None:
if hasattr(touch_inst, 'is_touched'):
try:
if touch_inst.is_touched():
return True
except Exception:
pass
for meth in ('get_touch', 'read_touch'):
if hasattr(touch_inst, meth):
try:
res = getattr(touch_inst, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
touch_val = getattr(bc, 'touch', None)
if touch_val is not None:
if callable(touch_val):
try:
res = touch_val()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
else:
if hasattr(touch_val, 'is_touched'):
try:
if touch_val.is_touched():
return True
except Exception:
pass
for meth in ('get_touch', 'read_touch'):
if hasattr(touch_val, meth):
try:
res = getattr(touch_val, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
if d is not None:
for meth in ('get_touch', 'touch'):
if hasattr(d, meth):
try:
res = getattr(d, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
return False
def random_choice(seq):
if not seq:
return None
return seq[random.randint(0, len(seq) - 1)]
def draw_chomper(d, cx, cy, rad, direction, mouth_open):
# Draw body
for dy in range(-rad, rad + 1):
dx = int(math.sqrt(rad*rad - dy*dy))
d.line(cx - dx, cy + dy, cx + dx, cy + dy, 1)
# Draw open mouth
if mouth_open:
dx_dir, dy_dir = direction
if dx_dir == 0 and dy_dir == 0:
dx_dir = 1
if dx_dir > 0:
for dy in range(-rad//2, rad//2 + 1):
mx = rad - abs(dy) * 2
d.line(cx, cy + dy, cx + mx, cy + dy, 0)
elif dx_dir < 0:
for dy in range(-rad//2, rad//2 + 1):
mx = rad - abs(dy) * 2
d.line(cx - mx, cy + dy, cx, cy + dy, 0)
elif dy_dir > 0:
for dx in range(-rad//2, rad//2 + 1):
my = rad - abs(dx) * 2
d.line(cx + dx, cy, cx + dx, cy + my, 0)
elif dy_dir < 0:
for dx in range(-rad//2, rad//2 + 1):
my = rad - abs(dx) * 2
d.line(cx + dx, cy - my, cx + dx, cy, 0)
def draw_ghost(d, gcx, gcy, rad):
for dy in range(-rad, 0):
dx = int(math.sqrt(rad*rad - dy*dy))
d.line(gcx - dx, gcy + dy, gcx + dx, gcy + dy, 1)
d.fill_rect(gcx - rad, gcy, rad * 2 + 1, rad, 1)
eye_offset = rad // 3
d.fill_rect(gcx - eye_offset - 1, gcy - eye_offset, 2, 2, 0)
d.fill_rect(gcx + eye_offset - 1, gcy - eye_offset, 2, 2, 0)
for i in range(gcx - rad, gcx + rad + 1, 4):
d.fill_rect(i, gcy + rad - 2, 2, 3, 0)
def run(text="", frames=None):
if not display:
print("board_config.display_instance is not available. Skipping screensaver.")
return
width = getattr(display, 'width', 400)
height = getattr(display, 'height', 300)
header_h = 30
grid_y_offset = header_h
cell_size = 40
cols = width // cell_size
rows = (height - header_h) // cell_size
grid = []
for r in range(rows):
row_data = []
for c in range(cols):
is_wall = False
if r == 0 or r == rows - 1 or c == 0 or c == cols - 1:
is_wall = True
elif (r == 2 or r == rows - 3) and (2 <= c <= 3 or cols - 4 <= c <= cols - 3):
is_wall = True
elif r == rows // 2 and (cols // 2 - 1 <= c <= cols // 2 + 1):
is_wall = True
row_data.append(2 if is_wall else 1)
grid.append(row_data)
score = 0
chomper_x, chomper_y = cell_size, grid_y_offset + cell_size
chomper_dx, chomper_dy = 1, 0
ghost_x, ghost_y = (cols - 2) * cell_size, grid_y_offset + (rows - 2) * cell_size
ghost_dx, ghost_dy = -1, 0
frame = 0
try:
while True:
if frames is not None and frames > 0 and frame >= frames:
break
if check_touch(display, board_config):
print("Screensaver stopped by screen touch.")
break
chomper_rel_y = chomper_y - grid_y_offset
if chomper_x % cell_size == 0 and chomper_rel_y % cell_size == 0:
cx, cy = chomper_x // cell_size, chomper_rel_y // cell_size
if grid[cy][cx] == 1:
grid[cy][cx] = 0
score += 10
has_dots = False
for r_data in grid:
if 1 in r_data:
has_dots = True
break
if not has_dots:
for r in range(rows):
for c in range(cols):
if grid[r][c] == 0:
grid[r][c] = 1
valid_dirs = []
dot_dirs = []
for ndx, ndy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
nx, ny = cx + ndx, cy + ndy
if 0 <= nx < cols and 0 <= ny < rows:
if grid[ny][nx] != 2:
valid_dirs.append((ndx, ndy))
if grid[ny][nx] == 1:
dot_dirs.append((ndx, ndy))
if dot_dirs:
if (chomper_dx, chomper_dy) in dot_dirs:
pass
else:
chomper_dx, chomper_dy = random_choice(dot_dirs)
elif valid_dirs:
if (chomper_dx, chomper_dy) in valid_dirs and random.random() > 0.3:
pass
else:
chomper_dx, chomper_dy = random_choice(valid_dirs)
else:
chomper_dx, chomper_dy = 0, 0
ghost_rel_y = ghost_y - grid_y_offset
if ghost_x % cell_size == 0 and ghost_rel_y % cell_size == 0:
gcx, gcy = ghost_x // cell_size, ghost_rel_y // cell_size
valid_gdirs = []
for ndx, ndy in [(-1, 0), (1, 0), (0, -1), (0, 1)]:
if ndx == -ghost_dx and ndy == -ghost_dy:
continue
nx, ny = gcx + ndx, gcy + ndy
if 0 <= nx < cols and 0 <= ny < rows:
if grid[ny][nx] != 2:
valid_gdirs.append((ndx, ndy))
if not valid_gdirs:
valid_gdirs = [(-ghost_dx, -ghost_dy)]
best_dir = valid_gdirs[0]
min_dist = 999999
ccx, ccy = chomper_x // cell_size, chomper_rel_y // cell_size
for ndx, ndy in valid_gdirs:
nx, ny = gcx + ndx, gcy + ndy
dist = (nx - ccx)**2 + (ny - ccy)**2
if dist < min_dist:
min_dist = dist
best_dir = (ndx, ndy)
if random.random() < 0.25:
ghost_dx, ghost_dy = random_choice(valid_gdirs)
else:
ghost_dx, ghost_dy = best_dir
chomper_x += chomper_dx * 2
chomper_y += chomper_dy * 2
ghost_x += ghost_dx * 2
ghost_y += ghost_dy * 2
dist_sq = (chomper_x - ghost_x)**2 + (chomper_y - ghost_y)**2
if dist_sq < 256:
display.clear(0)
display.text("COLLISION!", width // 2 - 40, height // 2 - 10, 1)
display.show()
sleep_ms(800)
chomper_x, chomper_y = cell_size, grid_y_offset + cell_size
chomper_dx, chomper_dy = 1, 0
ghost_x, ghost_y = (cols - 2) * cell_size, grid_y_offset + (rows - 2) * cell_size
ghost_dx, ghost_dy = -1, 0
frame += 1
continue
display.clear(0)
# Header
display.text(text if text else "MAZE CHOMPER", 10, 10, 1)
display.text(f"SCORE: {score:05d}", width - 120, 10, 1)
display.line(0, 28, width, 28, 1)
# Grid / Walls / Dots
for r in range(rows):
for c in range(cols):
cell_val = grid[r][c]
cx_px = c * cell_size
cy_px = grid_y_offset + r * cell_size
if cell_val == 2:
display.fill_rect(cx_px + 2, cy_px + 2, cell_size - 4, cell_size - 4, 1)
elif cell_val == 1:
display.fill_rect(cx_px + cell_size//2 - 1, cy_px + cell_size//2 - 1, 3, 3, 1)
# Sprites
mouth_open = (frame % 4) < 2
draw_chomper(display, chomper_x + cell_size//2, chomper_y + cell_size//2, 12, (chomper_dx, chomper_dy), mouth_open)
draw_ghost(display, ghost_x + cell_size//2, ghost_y + cell_size//2, 12)
display.show()
frame += 1
sleep_ms(30)
except KeyboardInterrupt:
print("Screensaver stopped by user interrupt.")
if __name__ in ('__main__', '__name__'):
run()
+223
View File
@@ -0,0 +1,223 @@
# Standalone MicroPython Screensaver: Paddle Bounce
# Inspired by paddle-ball arcade classic. Exposes run(text="", frames=None).
import time
import math
import random
try:
import board_config
display = getattr(board_config, 'display_instance', None)
except Exception:
board_config = None
display = None
def sleep_ms(ms):
try:
time.sleep_ms(ms)
except AttributeError:
time.sleep(ms / 1000.0)
def check_touch(d, bc):
"""Robust check for screen touch to stop the screensaver."""
if bc is not None:
touch_inst = getattr(bc, 'touch_instance', None)
if touch_inst is not None:
if hasattr(touch_inst, 'is_touched'):
try:
if touch_inst.is_touched():
return True
except Exception:
pass
for meth in ('get_touch', 'read_touch'):
if hasattr(touch_inst, meth):
try:
res = getattr(touch_inst, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
touch_val = getattr(bc, 'touch', None)
if touch_val is not None:
if callable(touch_val):
try:
res = touch_val()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
else:
if hasattr(touch_val, 'is_touched'):
try:
if touch_val.is_touched():
return True
except Exception:
pass
for meth in ('get_touch', 'read_touch'):
if hasattr(touch_val, meth):
try:
res = getattr(touch_val, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
if d is not None:
for meth in ('get_touch', 'touch'):
if hasattr(d, meth):
try:
res = getattr(d, meth)()
if res is not None and res is not False and res != 0:
return True
except Exception:
pass
return False
def run(text="", frames=None):
if not display:
print("board_config.display_instance is not available. Skipping screensaver.")
return
width = getattr(display, 'width', 400)
height = getattr(display, 'height', 300)
# Paddle parameters
pad_w = 6
pad_h = 36
left_x = 15
right_x = width - 15 - pad_w
# Initial states
ly = (height - pad_h) // 2
ry = (height - pad_h) // 2
bx, by = width // 2, height // 2
vx, vy = 3.5, 1.8
score_l = 0
score_r = 0
paddle_speed = 3.0
# Simulated tracking error offset
target_offset_l = 0
target_offset_r = 0
def reset_ball(serve_to_left):
nonlocal bx, by, vx, vy, target_offset_l, target_offset_r
bx = width // 2
by = height // 2
vx = -3.5 if serve_to_left else 3.5
vy = random.choice([-2.0, -1.0, 1.0, 2.0])
target_offset_l = random.randint(-12, 12)
target_offset_r = random.randint(-12, 12)
frame = 0
serve_delay = 0
try:
while True:
if frames is not None and frames > 0 and frame >= frames:
break
if check_touch(display, board_config):
print("Screensaver stopped by screen touch.")
break
if serve_delay > 0:
serve_delay -= 1
else:
# Move ball
bx += vx
by += vy
# Ceiling/floor bounce
if by <= 32:
by = 32
vy = -vy
elif by >= height - 8:
by = height - 8
vy = -vy
# AI tracking
if vx < 0: # Ball heading Left
# Track ball center with error offset
target_y = by + 3 - (pad_h // 2) + target_offset_l
if ly < target_y:
ly = min(height - pad_h - 6, ly + paddle_speed)
elif ly > target_y:
ly = max(32, ly - paddle_speed)
else: # Ball heading Right
target_y = by + 3 - (pad_h // 2) + target_offset_r
if ry < target_y:
ry = min(height - pad_h - 6, ry + paddle_speed)
elif ry > target_y:
ry = max(32, ry - paddle_speed)
# Check Left boundary
if bx <= left_x + pad_w:
if left_x <= bx:
# Ball within x-range of paddle, check y-collision
if ly - 3 <= by <= ly + pad_h + 3:
bx = left_x + pad_w
# Bounce & increase speed
vx = -vx * 1.05
# Cap horizontal speed to prevent teleporting
if abs(vx) > 8.0:
vx = 8.0 if vx > 0 else -8.0
vy = vy * 1.05 + random.uniform(-0.6, 0.6)
# Assign new tracking error for right paddle
target_offset_r = random.randint(-15, 15)
else:
# Miss! Point for Right
score_r += 1
reset_ball(serve_to_left=False)
serve_delay = 40
# Check Right boundary
if bx + 6 >= right_x:
if bx <= right_x + pad_w:
# Ball within x-range of paddle, check y-collision
if ry - 3 <= by <= ry + pad_h + 3:
bx = right_x - 6
# Bounce & increase speed
vx = -vx * 1.05
if abs(vx) > 8.0:
vx = 8.0 if vx > 0 else -8.0
vy = vy * 1.05 + random.uniform(-0.6, 0.6)
# Assign new tracking error for left paddle
target_offset_l = random.randint(-15, 15)
else:
# Miss! Point for Left
score_l += 1
reset_ball(serve_to_left=True)
serve_delay = 40
# Draw frame
display.clear(0)
# Header score line
display.text(text if text else "PADDLE BOUNCE", 10, 10, 1)
display.text(f"{score_l:02d} {score_r:02d}", width // 2 - 24, 10, 1)
display.line(0, 26, width, 26, 1)
# Draw center dotted line
for y in range(30, height, 16):
display.fill_rect(width // 2 - 1, y, 2, 8, 1)
# Draw paddles
display.fill_rect(left_x, int(ly), pad_w, pad_h, 1)
display.fill_rect(right_x, int(ry), pad_w, pad_h, 1)
# Draw ball (6x6 square)
if serve_delay == 0 or (serve_delay // 4) % 2 == 0:
display.fill_rect(int(bx), int(by), 6, 6, 1)
display.show()
frame += 1
sleep_ms(30)
except KeyboardInterrupt:
print("Screensaver stopped by user interrupt.")
if __name__ in ('__main__', '__name__'):
run()
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.4 KiB

+1
View File
@@ -87,6 +87,7 @@ def connect_socket(esp32_ip, port, protocol):
if protocol == "tcp":
sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
sock.connect((esp32_ip, port))
sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1)
print(f"Connected to stream server at {esp32_ip}:{port}")
return sock
else: # udp
+2
View File
@@ -62,6 +62,7 @@ def main():
files_to_upload = [
("lib/board_config.py", "lib/board_config.py"),
("lib/st7796.py", "lib/st7796.py"),
("lib/ili9341.py", "lib/ili9341.py"),
("lib/ft6336u.py", "lib/ft6336u.py"),
("lib/rlcd.py", "lib/rlcd.py"),
("lib/battery_util.py", "lib/battery_util.py"),
@@ -72,6 +73,7 @@ def main():
("demo_audio_loopback.py", "demo_audio_loopback.py"),
("video_stream.py", "video_stream.py"),
("mcp_server.py", "mcp_server.py"),
("wifi_config.py", "wifi_config.py"),
("boot.py", "boot.py"),
("main.py", "main.py")
]
+201 -4
View File
@@ -5,15 +5,18 @@ import sys
import micropython
class VideoStreamServer:
def __init__(self, display, tcp_port=8081, udp_port=8082):
def __init__(self, display, tcp_port=8081, udp_port=8082, color_port=8083):
self.display = display
self.tcp_port = tcp_port
self.udp_port = udp_port
self.color_port = color_port
# Sockets
self.tcp_server = None
self.tcp_client = None
self.udp_sock = None
self.color_server = None
self.color_client = None
# State
self.active = False
@@ -31,6 +34,19 @@ class VideoStreamServer:
self.current_frame_id = -1
self.chunks_received = 0 # Bitmask for chunks 0-14
# Color buffering (Pre-allocated, 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_view = memoryview(self.color_header)
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
# Performance Stats
self.debug = False
self.frames_drawn = 0
@@ -115,7 +131,7 @@ class VideoStreamServer:
def start(self):
"""Initialize and start the sockets."""
# 1. Start TCP Server
# 1. Start TCP Server (Mono)
try:
self.tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.tcp_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -126,7 +142,7 @@ class VideoStreamServer:
except Exception as e:
print(f"Failed to start TCP stream server: {e}")
# 2. Start UDP Server
# 2. Start UDP Server (Mono)
try:
self.udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
@@ -136,6 +152,17 @@ class VideoStreamServer:
except Exception as e:
print(f"Failed to start UDP stream server: {e}")
# 3. Start Color TCP Server
try:
self.color_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.color_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.color_server.bind(('', self.color_port))
self.color_server.listen(1)
self.color_server.setblocking(False)
print(f"Color TCP Stream Server started on port {self.color_port}")
except Exception as e:
print(f"Failed to start Color TCP stream server: {e}")
def restart_tcp_server(self):
"""Re-initializes the TCP stream server socket after a fatal error."""
print("Restarting TCP Stream Server...")
@@ -176,6 +203,27 @@ class VideoStreamServer:
except Exception as e:
print(f"Failed to restart UDP stream server: {e}")
def restart_color_server(self):
"""Re-initializes the color TCP stream server socket after a fatal error."""
print("Restarting Color TCP Stream Server...")
self.close_color_client()
try:
if self.color_server:
self.color_server.close()
except:
pass
self.color_server = None
time.sleep_ms(100)
try:
self.color_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.color_server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.color_server.bind(('', self.color_port))
self.color_server.listen(1)
self.color_server.setblocking(False)
print(f"Color TCP Stream Server restarted on port {self.color_port}")
except Exception as e:
print(f"Failed to restart color TCP stream server: {e}")
def update(self):
"""Updates connection states and checks for incoming video data.
Call this periodically in the main execution loop.
@@ -194,6 +242,7 @@ class VideoStreamServer:
print("Video stream timed out. Returning to dashboard.")
self.active = False
self.tcp_bytes_received = 0
self.color_bytes_received = 0
# 2. Handle incoming UDP packet (if UDP is active)
if self.udp_sock:
@@ -246,7 +295,7 @@ class VideoStreamServer:
self.restart_udp_sock()
break
# 3. Handle TCP stream
# 3. Handle TCP stream (Mono)
if self.tcp_server:
# If no client is connected, try to accept one
if self.tcp_client is None:
@@ -296,6 +345,126 @@ class VideoStreamServer:
print(f"TCP Stream socket error: {e}")
self.close_tcp_client()
# 4. 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"Fatal Color TCP stream server socket error in accept(): {e}. Re-initializing...")
self.restart_color_server()
if self.color_client is not None:
try:
# A. Read header first if we haven't got it
if self.color_header_received < 16:
read_start_time = time.ticks_ms()
while self.color_header_received < 16:
# 100ms timeout for header read to prevent locking up if stalled
if time.ticks_diff(time.ticks_ms(), read_start_time) > 100:
break
remaining_header = 16 - self.color_header_received
try:
n = self.color_client.readinto(self.color_header_view[self.color_header_received : self.color_header_received + remaining_header])
if n is not None:
if n == 0:
print("Color TCP Stream client disconnected during header read.")
self.close_color_client()
break
else:
self.color_header_received += n
self.last_packet_time = now
self.active = True
else:
time.sleep_ms(1)
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:
time.sleep_ms(1)
else:
raise e
# Check if header is now fully received
if self.color_header_received == 16:
magic = self.color_header[:4]
if magic != b'RAW\x01':
print(f"Invalid stream magic: {magic}. Disconnecting client.")
self.close_color_client()
else:
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]
self.color_bytes_received = 0
if self.color_payload_len > len(self.color_buffer):
print(f"Payload too large: {self.color_payload_len} bytes. Disconnecting.")
self.close_color_client()
# B. Read payload if header is fully received
if self.color_header_received == 16 and self.color_payload_len > 0:
read_start_time = time.ticks_ms()
while self.color_bytes_received < self.color_payload_len:
# 1000ms timeout for payload read to prevent locking up if stalled
if time.ticks_diff(time.ticks_ms(), read_start_time) > 1000:
print("Timeout reading color payload.")
self.close_color_client()
break
remaining_payload = self.color_payload_len - self.color_bytes_received
try:
n = self.color_client.readinto(self.color_view[self.color_bytes_received : self.color_bytes_received + remaining_payload])
if n is not None:
if n == 0:
print("Color TCP Stream client disconnected during payload read.")
self.close_color_client()
break
else:
self.color_bytes_received += n
self.last_packet_time = now
self.active = True
else:
time.sleep_ms(1)
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:
time.sleep_ms(1)
else:
raise e
# If frame payload is fully received, draw it!
if self.color_bytes_received == self.color_payload_len:
self._draw_color_frame()
# Reset header state for the next frame
self.color_header_received = 0
self.color_payload_len = 0
self.color_bytes_received = 0
except OSError as e:
print(f"Color TCP Stream socket error: {e}")
self.close_color_client()
def _draw_frame(self):
"""Sends the frame buffer directly to the SPI display bus."""
draw_start = time.ticks_ms()
@@ -317,6 +486,22 @@ class VideoStreamServer:
self.frames_drawn += 1
self.fps_frame_count += 1
def _draw_color_frame(self):
"""Draws the received color frame directly to the display using draw_rgb565."""
draw_start = time.ticks_ms()
# Draw the frame using fast path and sync_canvas=False
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
)
self.last_draw_ms = time.ticks_diff(time.ticks_ms(), draw_start)
self.frames_drawn += 1
self.fps_frame_count += 1
def get_stats(self):
"""Returns streaming performance counters."""
return {
@@ -339,3 +524,15 @@ class VideoStreamServer:
pass
self.tcp_client = None
self.tcp_bytes_received = 0
def close_color_client(self):
"""Safely disconnects the color TCP client socket."""
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
+70
View File
@@ -0,0 +1,70 @@
# Reyna little32 ESP32-S3 Voice State Animations
A collection of 14 standalone MicroPython animation scripts designed for the Reyna little32 ESP32-S3 reflective screen board. These animations support voice gateway visual states instead of a spoken initial acknowledgement (ACK).
## Hardware & API Assumptions
- **Display Size:** Auto-scaling (reads `display.width` and `display.height`; defaults to 400x300 for RLCD, but supports others).
- **Colors:** Designed for monochrome-ish 0 (White/Background) and 1 (Black/Foreground) high-contrast visuals.
- **Methods Used:** `clear(c)`, `fill_rect(x,y,w,h,c)`, `rect(x,y,w,h,c)`, `line(x1,y1,x2,y2,c)`, `text(msg,x,y,c)`, `show()`.
- **Standalone:** Each script is entirely self-contained. It can be uploaded individually to board flash and requires no helper modules.
- **Robustness:** Gracefully falls back and prints a warning if `board_config` or `board_config.display_instance` is missing.
---
## Animation List & State Mappings
| Filename | Voice States / Purposes | Visual Behavior |
| :--- | :--- | :--- |
| **[ack_pulse.py](file:///home/adolforeyna/Projects/Screen/voice_animations/ack_pulse.py)** | `ack`, `received`, `got_it` | Bold checkmark with expanding concentric radar pulses. |
| **[listening_wave.py](file:///home/adolforeyna/Projects/Screen/voice_animations/listening_wave.py)** | `listening`, `audio_input` | A decorative mic icon at the top and dual active overlapping sine waves. |
| **[caption_scan.py](file:///home/adolforeyna/Projects/Screen/voice_animations/caption_scan.py)** | `captioning`, `transcribing` | Simulated typing log with scanner sweeps and blinking typewriter cursors. |
| **[waiting_orbit.py](file:///home/adolforeyna/Projects/Screen/voice_animations/waiting_orbit.py)** | `waiting`, `thinking`, `processing` | 4 circular ring nodes orbiting a central pulsing energy reactor core. |
| **[working_gears.py](file:///home/adolforeyna/Projects/Screen/voice_animations/working_gears.py)** | `working`, `tool_use`, `executing` | Large and small mechanical gears interlocking and rotating in opposite directions. |
| **[speaking_mouth.py](file:///home/adolforeyna/Projects/Screen/voice_animations/speaking_mouth.py)** | `speaking`, `audio_output` | Equalizer voiceprint spectrum bars bouncing symmetrically from a center axis. |
| **[success_spark.py](file:///home/adolforeyna/Projects/Screen/voice_animations/success_spark.py)** | `success`, `done`, `completed` | Bold checkmark inside a blooming radial starburst explosion. |
| **[error_alert.py](file:///home/adolforeyna/Projects/Screen/voice_animations/error_alert.py)** | `error`, `failure`, `alert` | Flashing bold exclamation triangle with diagonal warning hazard stripes. |
| **[network_retry.py](file:///home/adolforeyna/Projects/Screen/voice_animations/network_retry.py)** | `network_retry`, `reconnecting` | Wi-Fi waves lighting up sequentially inside looping dashed reload arrows. |
| **[battery_low.py](file:///home/adolforeyna/Projects/Screen/voice_animations/battery_low.py)** | `battery_low`, `low_power` | Battery outline with tips, flashing empty charge block, and warning triangle. |
| **[update_progress.py](file:///home/adolforeyna/Projects/Screen/voice_animations/update_progress.py)** | `update`, `uploading`, `downloading` | Sliding download arrow dropping into a tray above a percentage progress bar. |
| **[breathing_idle.py](file:///home/adolforeyna/Projects/Screen/voice_animations/breathing_idle.py)** | `idle`, `ready`, `breathing` | Concentric squares and accent dots scaling slowly in a calm breathing rhythm. |
| **[wake_attention.py](file:///home/adolforeyna/Projects/Screen/voice_animations/wake_attention.py)** | `wake`, `attention`, `active` | Stylized robot eyes blinking and shifting gaze left and right to look around. |
| **[kid_companion.py](file:///home/adolforeyna/Projects/Screen/voice_animations/kid_companion.py)** | `kid_friendly`, `companion` | Friendly robot head with wiggling ears, a winking eye, and a warm smile. |
---
## Execution Guide
### 1. Uploading to Flash
You can upload the desired animation files using tools like `ampy`, `rshell`, `mpremote`, or via the board's web console. E.g.
```bash
mpremote cp voice_animations/*.py :
```
### 2. Execution from REPL (Script Mode)
Run any script automatically with default parameters using `exec`:
```python
# Runs for 100 frames (approx. 5 seconds) and exits
exec(open("ack_pulse.py").read())
```
### 3. Importing and Running (Module Mode)
You can import the module and call its `run` method directly. The `run` method supports text customization and infinite looping:
```python
import listening_wave
# Run with custom caption text for 50 frames
listening_wave.run(text="HOTWORD CAPTURED", frames=50)
# Run indefinitely (blocking loop, break with Ctrl+C)
listening_wave.run(text="READY TO HEAR", frames=-1)
```
---
## Technical Details
### Customizing Loop Duration
In the `run()` function:
- Set `frames > 0` to run for a finite duration (useful for transition ACK animations).
- Set `frames=None` or `frames=-1` (or omit) to run indefinitely inside a `while True` loop.
- All scripts catch `KeyboardInterrupt` to exit gracefully, leaving the screen in a clean state if interrupted.
+68
View File
@@ -0,0 +1,68 @@
# Standalone MicroPython Voice Animation: ACK / Received
# Exposes run(text="", frames=...) and runs automatically if executed directly.
import time
try:
import board_config
display = getattr(board_config, 'display_instance', None)
except Exception:
board_config = None
display = None
def draw_center_text(d, msg, y, w, scale=1, c=1):
char_w = 8 * scale
x = max(0, (w - len(msg) * char_w) // 2)
if hasattr(d, 'text_large') and scale > 1:
try:
d.text_large(msg, x, y, scale, c)
return
except Exception:
pass
d.text(msg, x, y, c)
def run(text="", frames=100):
if not display:
print("board_config.display_instance is not available. Skipping animation.")
return
width = getattr(display, 'width', 400)
height = getattr(display, 'height', 300)
cx, cy = width // 2, height // 2
label = text if text else "GOT IT"
frame = 0
try:
while True:
if frames is not None and frames > 0 and frame >= frames:
break
display.clear(0)
# Draw a thick checkmark in the center
display.line(cx - 24, cy + 4, cx - 6, cy + 22, 1)
display.line(cx - 23, cy + 4, cx - 5, cy + 22, 1)
display.line(cx - 6, cy + 22, cx + 30, cy - 14, 1)
display.line(cx - 5, cy + 22, cx + 31, cy - 14, 1)
# Concentric pulsing squares radiating outwards
for p in range(3):
# Scale pulse radius based on frame count
r = 30 + ((frame * 4 + p * 30) % 90)
display.rect(cx - r, cy - r, r * 2, r * 2, 1)
# Draw centered text label at the bottom
draw_center_text(display, label, height - 30, width, scale=1, c=1)
display.show()
frame += 1
try:
time.sleep_ms(50)
except AttributeError:
time.sleep(0.05)
except KeyboardInterrupt:
print("Animation interrupted by user.")
if __name__ in ('__main__', '__name__'):
run()

Some files were not shown because too many files have changed in this diff Show More