Compare commits

...

38 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
Adolfo Reyna d0fcc5fc7b Fix socket read stream crashes and NameError on boot 2026-06-18 11:35:06 -04:00
Adolfo Reyna 5968b4934d Configure demo_audio_loopback.py to record for a fixed 10-second duration to eliminate button-release checking from loop 2026-06-17 23:15:22 -04:00
Adolfo Reyna 4ff86ab415 Replace BoardButtons with direct Pin polling in demo_audio_loopback.py to avoid interrupt storms on mechanical button release 2026-06-17 23:10:56 -04:00
Adolfo Reyna d5f2abf2b8 Implement safe, non-conflicting loopback boot method with BOOT button exit and automatic 120s timeout reset 2026-06-17 23:07:58 -04:00
Adolfo Reyna 95ee2fc036 Create local audio loopback utility script to test microphone and speaker hardware locally using physical buttons 2026-06-17 23:00:32 -04:00
Adolfo Reyna c8c2373b83 Update Hermes API endpoint in main.py to use direct IP address to resolve connection errors 2026-06-17 22:53:54 -04:00
Adolfo Reyna 7d61824723 Resolve I2C pin conflict on Waveshare RLCD board, use SoftI2C for dynamic configurations, and add RTC/SHTC3 utilities to upload list 2026-06-17 22:51:25 -04:00
Adolfo Reyna 82de0f328b Fix I2C scan on Waveshare RLCD board using SoftI2C and implement button PTT trigger fallback when touch is missing 2026-06-17 22:40:21 -04:00
Adolfo Reyna 31363734c5 Allow upload_files.py to accept target IP as command line argument 2026-06-17 22:36:32 -04:00
Adolfo Reyna c63712e3aa Implement dynamic hardware auto-detection in board_config.py to support both Hosyond and Waveshare RLCD boards out-of-the-box 2026-06-17 22:35:47 -04:00
Adolfo Reyna 75475723fa Reorganize drivers and utility modules into lib/ subdirectory and update server and upload scripts 2026-06-17 22:22:02 -04:00
Adolfo Reyna 2813c11104 Save current changes before reorganizing drivers and cleaning workspace 2026-06-17 22:17:59 -04:00
Adolfo Reyna 3356e5d4a2 Implement notetaking app, ST7796 display with touch support, audio beep navigation, and video streaming 2026-06-08 15:04:36 -04:00
Adolfo Reyna 070390355e Add auto-discovery support (UDP discovery responder & subnet scanner) and implement virtual screen MCP server 2026-06-03 14:29:49 -04:00
Adolfo Reyna 81e7b15b61 Add blog_post.md draft and project README.md 2026-06-01 16:06:55 -04:00
Adolfo Reyna 092e241f89 Implement stream-based download_file tool for downloading media files directly to flash or SD card 2026-06-01 15:59:50 -04:00
Adolfo Reyna 3cd3428e94 Add play_audio_base64 tool to MCP server 2026-06-01 14:53:22 -04:00
Adolfo Reyna 612ba63c59 Implement play_wav, record_voice, and host-side voice assistant coordinator script 2026-06-01 14:38:21 -04:00
Adolfo Reyna f443da0352 Remove screen override timeout to allow drawing to persist indefinitely 2026-06-01 14:13:38 -04:00
Adolfo Reyna 8302db03db Expose speaker playback and play_tone tool via MCP 2026-06-01 13:26:05 -04:00
Adolfo Reyna 05df3d3839 Expose remote file management and Python code execution via MCP 2026-06-01 13:11:09 -04:00
Adolfo Reyna 0c67ff4e20 Add support to draw images via MCP on the RLCD display 2026-06-01 12:41:14 -04:00
160 changed files with 24099 additions and 291 deletions
+28
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,5 +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
+147
View File
@@ -0,0 +1,147 @@
# ESP32-S3-RLCD-4.2 Model Context Protocol (MCP) Companion
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.
---
## 1. System Architecture
To bypass the memory and protocol constraints of the microcontroller, we use a hybrid **Stdio-to-HTTP LAN Bridge**:
```
┌──────────────────────────────┐
│ Local LLM Client / Harness │
└──────────────┬───────────────┘
│ (JSON-RPC over Stdio)
┌──────────────────────────────┐
│ mcp_bridge.py │ <── (Converts PNG/PBM images on-the-fly)
└──────────────┬───────────────┘
│ (JSON-RPC over HTTP POST)
┌──────────────────────────────┐
│ ESP32-S3 HTTP Server │ (Running on Port 80)
└──────────────┬───────────────┘
│ (MicroPython calls)
┌──────────────────────────────┐
│ Hardware Peripherals │ (Display, Led, Mic, Speaker, Sensors)
└──────────────────────────────┘
```
1. **MicroPython HTTP Server (`mcp_server.py`)**: Runs directly on the ESP32-S3, accepting JSON-RPC 2.0 requests at `POST /api/mcp`.
2. **Host Stdio Bridge (`mcp_bridge.py`)**: Runs on your host PC, listening for stdio JSON-RPC calls, forwarding them to the board over Wi-Fi, and returning the output.
3. **On-the-Fly Image Conversion**: The board returns raw dithered PBM screen buffers to save bandwidth. The host bridge automatically converts these into standard PNG images (using macOS `sips` or `Pillow`) so the LLM can "see" what is currently rendered on the screen.
---
## 2. Directory Structure & File Manifest
### MicroPython Device Files
* **[boot.py](boot.py)**: Automatically connects to Wi-Fi using credentials in `wifi_config.py` and renders connection logs on the screen.
* **[main.py](main.py)**: Background execution loop. Handlers for buttons (manual dashboard refresh, LED state cycle), NeoPixel animation loop, and non-blocking MCP server requests.
* **[mcp_server.py](mcp_server.py)**: The lightweight JSON-RPC server implementing the MCP tools list and call handlers.
* **[wifi_config.py](wifi_config.py)**: Wi-Fi credentials. **(Do not commit real credentials to Git)**.
* **`lib/`**: Hardware drivers and utility modules automatically searched by MicroPython:
* **[audio_util.py](lib/audio_util.py)**: Audio drivers for the ES7210 microphone array and the ES8311 speaker DAC. Implements sine-wave tone generation (`play_tone`), voice recording (`record_audio`), and file-based audio playing (`play_wav`).
* **[rlcd.py](lib/rlcd.py)**: Low-level FrameBuffer driver for the 4.2" Reflective LCD, including PBM loaders and screenshot exporters.
* **[sd_util.py](lib/sd_util.py)**: Mounting and filesystem management utility for the onboard microSD card slot.
* **[shtc3_util.py](lib/shtc3_util.py)**: Temperature & Humidity sensor utility.
* **[rtc_util.py](lib/rtc_util.py)**: PCF85063 Hardware Real-Time Clock driver.
* **[battery_util.py](lib/battery_util.py)**: Battery voltage and capacity reader.
* **[rgb_led_util.py](lib/rgb_led_util.py)**: WS2812 NeoPixel animation manager.
* **[button_util.py](lib/button_util.py)**: Boot/Key button debouncer utility.
* **[ble_util.py](lib/ble_util.py)**: Passive BLE scanning and BLE UART interface.
* **[ft6336u.py](lib/ft6336u.py)**: I2C Capacitive touchscreen controller driver.
* **[ili9341.py](lib/ili9341.py)**: ILI9341 LCD driver.
* **[st7796.py](lib/st7796.py)**: ST7796 LCD driver.
* **[download_util.py](lib/download_util.py)**: Helper for streaming downloads to the local filesystem.
### Host-Side files
* **[mcp_bridge.py](mcp_bridge.py)**: The stdio-to-HTTP LAN bridge connecting the LLM client to the board. Handles image resizing, dithering, and formatting.
* **[voice_assistant.py](voice_assistant.py)**: An end-to-end local voice assistant loop script (requires OpenAI/local Whisper for STT and Mac TTS commands).
* **[blog_post.md](blog_post.md)**: A draft technical write-up detailing this project's architecture and capabilities.
---
## 3. Quickstart Guide
### Step 1: Configure Wi-Fi
Edit `wifi_config.py` on your computer and set your local SSID and Password:
```python
WIFI_SSID = "Your_Wi-Fi_Name"
WIFI_PASS = "Your_Password"
```
### Step 2: Upload Files to the Board
Connect the board to your computer via USB (making sure to use the active ESP32 USB port).
Upload all Python files to the device flash memory using `mpremote`:
```bash
mpremote connect /dev/cu.usbmodem101 cp *.py :
```
*(If your serial port differs, change `/dev/cu.usbmodem101` accordingly).*
### Step 3: Boot the Board
Reset the board to apply changes:
```bash
mpremote connect /dev/cu.usbmodem101 reset
```
Once booted, the reflective screen will display connection logs, followed by the local IP address (e.g. `192.168.68.122`) once connected to Wi-Fi.
### Step 4: Register in Claude Desktop
Open your local Claude Desktop config file:
* **macOS**: `/Users/<username>/Library/Application Support/Claude/claude_desktop_config.json`
* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
Add the bridge script under the `mcpServers` list:
```json
{
"mcpServers": {
"esp32-rlcd": {
"command": "python3",
"args": [
"/absolute/path/to/this/repository/mcp_bridge.py",
"--ip",
"192.168.68.122"
]
}
}
}
```
Restart Claude Desktop, and the assistant will have direct, real-time control over your physical device!
---
## 4. MCP Tools Reference
Here is a summary of the MCP tools exposed by the bridge:
| Tool Name | Parameters | Description |
|---|---|---|
| `clear_screen` | `color` (0=White, 1=Black) | Clears the RLCD display. |
| `draw_text` | `text`, `x`, `y`, `size` | Draws normal/large text on the display. |
| `draw_image` | `image_base64`, `x`, `y`, `dither` | Uploads and dithers an image to the display. |
| `get_screenshot` | *(None)* | Captures the screen buffer as a PNG image for the LLM. |
| `get_sensors` | *(None)* | Returns temperature & humidity from the SHTC3. |
| `get_battery` | *(None)* | Returns voltage and capacity percentage. |
| `scan_ble` | `duration_ms` | Scans for nearby BLE beacons / smart tags. |
| `play_tone` | `frequency`, `duration_ms`, `volume` | Plays a pure sine wave tone. |
| `play_audio` | `filename`, `volume` | Plays a WAV file from the flash storage or SD. |
| `play_audio_base64` | `wav_base64`, `volume` | Plays a base64 WAV stream (automatic caching). |
| `record_voice` | `duration_sec`, `filename` | Records audio from the microphones to a PCM file. |
| `download_file` | `url`, `filename`, `use_sd` | Memory-efficient download stream to flash or microSD. |
| `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).
-128
View File
@@ -1,128 +0,0 @@
import time
import machine
from machine import Pin, I2C, I2S
class ES7210:
"""MicroPython driver for the ES7210 4-Channel Audio ADC (Microphone Array).
Controls the ES7210 chip over I2C to configure clocks, channels, gain, and format.
"""
ADDR = 0x40
def __init__(self, i2c):
self.i2c = i2c
def init(self, sample_rate=16000, bit_width=16):
"""Initializes the ES7210 registers for dual-microphone recording.
Args:
sample_rate (int): Audio sample rate (e.g. 16000, 44100, 48000).
bit_width (int): Data bit depth (16 or 24).
Returns:
bool: True if initialization was successful, False otherwise.
"""
print("Initializing ES7210 Microphone ADC...")
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
# 2. Power management and system configuration
self._write(0x01, 0x00) # Enable analog power, reference voltage
self._write(0x11, 0x60) # Enable master clock PLL
# 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)
# 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
# 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)
# 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
# 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
print("ES7210 initialization complete.")
return True
except Exception as e:
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=5, filename='recording.pcm'):
"""Records raw stereo PCM data from the dual microphones to a file.
Args:
duration_seconds (int): How long to record in seconds.
filename (str): Name of output raw PCM file on the device.
"""
# 1. Start I2C Control Bus (SDA=13, SCL=14)
i2c = I2C(0, sda=Pin(13), scl=Pin(14))
# 2. Configure I2S Receiver
# Pins: sck=BCLK (GPIO 9), ws=WS/LRCK (GPIO 45), sd=DIN (GPIO 10)
i2s = I2S(1,
sck=Pin(9),
ws=Pin(45),
sd=Pin(10),
mode=I2S.RX,
ibuf=16000,
rate=16000,
bits=16,
format=I2S.STEREO)
# 3. Wake up and configure the ES7210 microphone chip
mic_adc = ES7210(i2c)
if not mic_adc.init(sample_rate=16000, bit_width=16):
i2s.deinit()
return False
print(f"Recording {duration_seconds} seconds of audio...")
# Create reading buffer (reads 100ms chunks: 16000 samples/sec * 2 channels * 2 bytes/sample * 0.1s = 6400 bytes)
buffer = bytearray(6400)
start_time = time.time()
total_bytes = 0
try:
with open(filename, 'wb') as f:
while (time.time() - start_time) < duration_seconds:
# 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
print(f"Recording saved successfully to '{filename}' ({total_bytes} bytes).")
return True
except Exception as e:
print(f"Error during recording: {e}")
return False
finally:
# Always release the I2S peripheral resources
i2s.deinit()
print("I2S receiver deinitialized.")
+126
View File
@@ -0,0 +1,126 @@
# Bridging the Physical & Virtual: Building a MicroPython MCP Server for Local AI Harnesses
Large Language Models (LLMs) are incredibly capable, but they are traditionally trapped inside a digital sandbox—unable to perceive or interact with the physical world.
Enter the **Model Context Protocol (MCP)**, an open standard developed by Anthropic that provides a unified way for LLMs to query external databases, run code, and invoke APIs. While most developers use MCP to hook LLMs to spreadsheets or GitHub repositories, we decided to take it a step further: **giving a local AI harness direct control over physical hardware using MicroPython.**
In this post, well walk through how we transformed a specialized ESP32-S3 development board into an interactive, local AI desktop companion capable of rendering graphics, playing audio streams, monitoring ambient environments, and executing remote python scripts—all driven natively by local LLM harnesses like **OpenClaude**, **Hermes**, or **Claude Desktop**.
---
## 1. The Hardware Platform: Waveshare ESP32-S3-RLCD-4.2
To build a true desktop companion, we needed a board that combined low power, rich displays, and audio capabilities. We selected the **Waveshare ESP32-S3-RLCD-4.2** development kit.
Here is what makes this hardware special:
* **Core SoC**: ESP32-S3 Dual-core Xtensa processor with Wi-Fi and Bluetooth.
* **The Screen**: A 4.2" Reflective LCD (400x300 resolution). Similar to E-paper, it has extremely high sunlight readability, requires zero backlighting (it reflects ambient room light), and retains static images indefinitely with minimal power draw.
* **Environmental Telemetry**: Onboard SHTC3 temperature and relative humidity sensor.
* **Time & Battery**: PCF85063 hardware RTC for accurate offline timekeeping, and a MAX1704x-equivalent battery monitor.
* **Interactive Input**: Onboard Key/Boot buttons and a passive Bluetooth Low Energy (BLE) radio for nearby device tracking.
* **RGB LED**: A multi-mode WS2812 NeoPixel for state animations.
* **The Audio Pipeline**: An **ES7210 4-channel microphone array** for capturing voice commands, paired with an **ES8311 audio decoder/speaker driver** and built-in amplifier.
* **Storage Expansion**: A microSD card slot to cache large media files like podcasts.
---
## 2. MicroPython MCP Architecture: Stdio-to-HTTP Bridge
Typical desktop LLMs interact with MCP servers locally over standard input/output (stdio) pipelines. However, running a full node environment or complex stdio protocol parser directly on a resource-constrained microcontroller is impractical.
To bridge this gap, we designed a **hybrid stdio-to-network bridge**:
```
[ Local LLM Client / Harness ]
│ (JSON-RPC over Stdio)
[ host_bridge.py ] <─── (Pillow converts PNGs/PBMs on-the-fly)
│ (JSON-RPC over HTTP POST)
[ ESP32-S3 Web Server ]
│ (MicroPython execution)
[ Hardware Peripherals ]
```
1. **On-Board HTTP Server**: A lightweight, non-blocking TCP socket server written in MicroPython (`mcp_server.py`) runs directly on the ESP32, listening on port `80`. It handles `POST /api/mcp` requests, parsing JSON-RPC 2.0 structures.
2. **Host-Side Stdio Bridge**: A zero-dependency Python script (`mcp_bridge.py`) runs on the host PC. It reads stdio packets from the LLM client, forwards them over the local Wi-Fi to the ESP32s IP address, and relays the board's responses back.
3. **On-the-Fly Media Conversion**: Since the ESP32 has limited RAM, it returns screenshots as raw binary 1-bit PBM data. The host bridge intercepts this data, converts it into standard PNG format on your PC using native image tools (`sips` or `Pillow`), and serves it to the LLM. It does the reverse for image uploads (`draw_image`), rendering them locally as 1-bit dithered PBMs before sending them to the microcontroller.
---
## 3. The Tool Belt: 15 Exposed Hardware Utilities
By connecting the board to the MCP server, we exposed 15 specialized tools to the local AI. The LLM can choose to invoke any of these actions in response to your prompts:
### Display & Graphics
1. **`clear_screen(color)`**: Clear the 400x300 reflective display to pure white or black.
2. **`draw_text(text, x, y, size)`**: Draw custom labels or telemetry readouts at specified coordinates.
3. **`draw_image(image_base64, x, y, dither)`**: Upload any standard image format (JPEG/PNG/GIF). The host bridge automatically resizes and dithers it, and the board displays it.
4. **`get_screenshot()`**: Captures the exact reflective LCD frame buffer and returns a PNG. **This allows the LLM to inspect the screen and confirm if its renders are correct!**
### Environment & System Telemetry
5. **`get_sensors()`**: Returns live temperature (°C) and humidity (%) from the SHTC3 sensor.
6. **`get_battery()`**: Reads the current battery voltage and estimated capacity percentage.
7. **`scan_ble(duration_ms)`**: Performs a BLE scan to log nearby smart tags or trackers.
### Audio & Sound Control
8. **`play_tone(frequency, duration_ms, volume)`**: Play a clean, synthesized sine wave tone.
9. **`play_audio(filename, volume)`**: Stream and play standard WAV files (e.g. system alerts, sound effects) from the board's storage.
10. **`play_audio_base64(wav_base64, volume)`**: Allows the LLM to synthesize speech locally on your computer and stream it to the board's speaker in a single tool call (auto-caching and auto-cleaning temporary flash memory).
11. **`record_voice(duration_sec, filename)`**: Activates the microphone array to record your voice to flash memory for voice command loops.
### Remote File & Code Execution
12. **`write_file(path, content)`**: Write configuration text or script scripts to flash.
13. **`read_file(path)`**: Read any file on the board's local filesystem.
14. **`execute_python(code)`**: Run arbitrary Python code dynamically on the board via `exec()`. Standard output and tracebacks are captured and returned.
15. **`download_file(url, filename, use_sd)`**: Downloads large media files (like music or podcasts) from the web directly to the microSD card in memory-efficient stream chunks.
---
## 4. How to Register the MCP Server on Local AI Harnesses
Adding this ESP32-S3 companion to your local AI environment is simple.
### Step 1: Discover the Device IP
Ensure your ESP32 board is connected to the same local Wi-Fi router. Upon boot, the board's screen will display its assigned local IP address (e.g., `192.168.68.122`).
### Step 2: Register in Claude Desktop or Hermes/OpenClaude
Open your local harness config file. For Claude Desktop, this is located at:
* **macOS**: `/Users/<username>/Library/Application Support/Claude/claude_desktop_config.json`
* **Windows**: `%APPDATA%\Claude\claude_desktop_config.json`
Add the bridge script under the `mcpServers` entry, specifying the board's IP address:
```json
{
"mcpServers": {
"esp32-rlcd-companion": {
"command": "python3",
"args": [
"/absolute/path/to/your/workspace/mcp_bridge.py",
"--ip",
"192.168.68.122"
]
}
}
}
```
### Step 3: Launch and Interact
Restart your local AI harness. You will see a small plug icon or tools notification indicating that the board's functions are registered.
You can now prompt the AI using natural language commands:
* *"Check the room temperature. If it's over 25 degrees, set the LED to breathing red and show a warning message on the screen."*
* *"Draw a cute dithered pixel-art cat at the center of the display, then take a screenshot so you can check how it looks."*
* *"Download the podcast at this WAV URL to the SD card, and then play it on the speaker at 60% volume."*
The LLM will translate these requests into tool calls, control your hardware over the Wi-Fi bridge, and report back!
---
## Conclusion
By mapping hardware control to standard MCP tool schemas, we've demonstrated how easily microcontrollers can be integrated into the modern agentic LLM ecosystem. The ESP32-S3 is no longer just a standalone sensor node—it is a physical avatar for your local AI.
Whether you want to build a smart e-paper calendar, an interactive virtual desk pet, or an offline voice assistant, MicroPython combined with MCP provides the ultimate foundation.
+67 -37
View File
@@ -1,26 +1,45 @@
# This file is executed on every boot (including wake-boot from deepsleep)
import network
import time
import rlcd
from machine import Pin, SPI
import sys
import board_config
import wifi_config
try:
import network
has_network = True
except ImportError:
has_network = False
def connect_wifi():
# Initialize display to show connection progress
display = None
try:
spi = SPI(1, baudrate=20000000, polarity=0, phase=0, sck=Pin(11), mosi=Pin(12))
display = rlcd.RLCD(spi, cs=Pin(40), dc=Pin(5), rst=Pin(41))
display.clear(0)
display.text("ESP32-S3-RLCD-4.2", 10, 10, 1)
display.line(10, 20, 390, 20, 1)
display.text("Connecting to Wi-Fi...", 10, 35, 1)
display.text(f"SSID: {wifi_config.WIFI_SSID}", 10, 50, 1)
display.show()
except Exception as e:
print("Display init failed in boot.py:", e)
if sys.platform == 'rp2':
# Skip display and connection setup on RP2 (no network/Wi-Fi hardware)
time.sleep(1.5)
return
# Use the pre-initialized display from board_config
display = board_config.display_instance
if display:
try:
display.clear(0)
board_name = "ESP32-S3 " + str(board_config.BOARD_TYPE)
display.text(board_name, 10, 10, 1)
display.line(10, 20, display.width - 10, 20, 1)
display.text("Connecting to Wi-Fi...", 10, 35, 1)
display.text(f"SSID: {wifi_config.WIFI_SSID}", 10, 50, 1)
display.show()
except Exception as e:
print("Display setup failed in boot.py:", e)
# Initialize Wi-Fi Station
if not has_network:
return
try:
network.hostname('esp32screen')
print("Hostname set to: {}".format(network.hostname()))
except Exception as he:
print("Failed to set hostname: {}".format(he))
wlan = network.WLAN(network.STA_IF)
wlan.active(True)
@@ -36,34 +55,45 @@ def connect_wifi():
timeout -= 1
print("Connecting...")
if display:
display.text(".", dot_x, 70, 1)
dot_x += 10
display.show()
try:
display.text(".", dot_x, 70, 1)
dot_x += 10
display.show()
except:
pass
if wlan.isconnected():
ip = wlan.ifconfig()[0]
print(f"Wi-Fi Connected! IP Address: {ip}")
if display:
display.clear(0)
display.text("ESP32-S3-RLCD-4.2", 10, 10, 1)
display.line(10, 20, 390, 20, 1)
display.text("Wi-Fi Status: Connected!", 10, 35, 1)
display.text(f"SSID: {wifi_config.WIFI_SSID}", 10, 50, 1)
display.text(f"IP: {ip}", 10, 65, 1)
display.text("Starting MCP Server...", 10, 90, 1)
display.show()
time.sleep(1.5)
try:
display.clear(0)
board_name = "ESP32-S3 " + str(board_config.BOARD_TYPE)
display.text(board_name, 10, 10, 1)
display.line(10, 20, display.width - 10, 20, 1)
display.text("Wi-Fi Status: Connected!", 10, 35, 1)
display.text(f"SSID: {wifi_config.WIFI_SSID}", 10, 50, 1)
display.text(f"IP: {ip}", 10, 65, 1)
display.text("Starting MCP Server...", 10, 90, 1)
display.show()
time.sleep(1.5)
except:
pass
else:
print("Wi-Fi Connection Failed.")
if display:
display.clear(0)
display.text("ESP32-S3-RLCD-4.2", 10, 10, 1)
display.line(10, 20, 390, 20, 1)
display.text("Wi-Fi Status: Connection Failed!", 10, 35, 1)
display.text("Check credentials in:", 10, 55, 1)
display.text("wifi_config.py", 20, 70, 1)
display.text("Starting offline...", 10, 95, 1)
display.show()
time.sleep(2)
try:
display.clear(0)
board_name = "ESP32-S3 " + str(board_config.BOARD_TYPE)
display.text(board_name, 10, 10, 1)
display.line(10, 20, display.width - 10, 20, 1)
display.text("Wi-Fi Status: Failed!", 10, 35, 1)
display.text("Check credentials in:", 10, 55, 1)
display.text("wifi_config.py", 20, 70, 1)
display.text("Starting offline...", 10, 95, 1)
display.show()
time.sleep(2)
except:
pass
connect_wifi()
+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
+236
View File
@@ -0,0 +1,236 @@
import time
import machine
from machine import Pin, I2S
import board_config
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
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)
# 2. Wake up and configure ES8311 DAC
dac = ES8311(board_config.i2c_bus)
dac.init(sample_rate=rate)
dac.set_volume(volume)
# 3. Configure I2S TX
i2s_format = I2S.MONO if channels == 1 else I2S.STEREO
i2s = 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=rate,
bits=bits,
format=i2s_format)
# 4. Enable Speaker Amplifier
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=on_val)
try:
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 RAM playback:", e)
finally:
time.sleep_ms(100) # Let buffer play out
amp_pin.value(off_val) # Disable amp
i2s.deinit()
if mclk_pwm:
mclk_pwm.deinit()
print("Playback complete.")
def main():
display = board_config.display_instance
print("=== Dynamic RAM-based Audio Loopback Script ===")
print(f"Board Detected: {board_config.BOARD_TYPE}")
# 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)
# Helper to check if trigger is active
def is_talk_trigger_active():
if board_config.touch:
return board_config.touch.is_touched()
return key_pin.value() == 0
while True:
if display:
display.clear(0)
if board_config.BOARD_TYPE == 'WAVESHARE_RLCD':
display.text("RLCD Audio Loopback Test", 10, 10, 1)
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 (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 (RAM-based)...", 10, 100, 1)
display.show()
print("Ready: Press and hold key/screen to record...")
start_wait = time.ticks_ms()
while not is_talk_trigger_active():
if boot_pin.value() == 0 or time.ticks_diff(time.ticks_ms(), start_wait) > 120000:
print("Exit condition met. Deleting flag and resetting...")
if display:
display.clear(0)
if board_config.BOARD_TYPE == 'WAVESHARE_RLCD':
display.text("Exiting test...", 15, 60, 1)
display.text("Rebooting to normal...", 15, 80, 1)
else:
display.text("Exiting test...", 10, 50, 1)
display.text("Rebooting to normal...", 10, 70, 1)
display.show()
time.sleep(1)
try:
import os
os.remove("run_loopback.txt")
except:
pass
import machine
machine.reset()
time.sleep_ms(30)
print("Recording started! Speak into the microphone...")
if display:
display.clear(0)
if board_config.BOARD_TYPE == 'WAVESHARE_RLCD':
display.text("RLCD Audio Loopback Test", 10, 10, 1)
display.line(10, 20, 390, 20, 1)
display.text("RECORDING: 10 seconds...", 15, 80, 1)
display.text("Speak into microphone!", 15, 100, 1)
else:
display.text("Touch Audio Loopback Test", 10, 10, 1)
display.line(10, 20, 310, 20, 1)
display.text("RECORDING: 10 seconds...", 10, 60, 1)
display.text("Speak now!", 10, 80, 1)
display.show()
# 1. Start MCLK PWM using board config parameters
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)
# 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=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)
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
mic_adc._write(0x16, 0x01) # Enable +6dB gain boost
mic_adc._write(0x17, 0xC8) # Set ADC digital volume
except:
pass
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.ticks_ms()
max_duration_ms = 10000 # 10 seconds
try:
while time.ticks_diff(time.ticks_ms(), start_rec_time) < max_duration_ms:
bytes_read = i2s_rx.readinto(buffer)
if bytes_read > 0:
audio_chunks.append(bytes(buffer[:bytes_read]))
total_bytes += bytes_read
except Exception as e:
print("Recording failed:", e)
finally:
i2s_rx.deinit()
if mclk_pwm:
mclk_pwm.deinit()
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 from RAM
if display:
display.clear(0)
if board_config.BOARD_TYPE == 'WAVESHARE_RLCD':
display.text("RLCD Audio Loopback Test", 10, 10, 1)
display.line(10, 20, 390, 20, 1)
display.text("PLAYING BACK RESPONSE...", 15, 80, 1)
display.text(f"Bytes: {total_bytes}", 15, 100, 1)
else:
display.text("Touch Audio Loopback Test", 10, 10, 1)
display.line(10, 20, 310, 20, 1)
display.text("PLAYING BACK RESPONSE...", 10, 60, 1)
display.text(f"Bytes: {total_bytes}", 10, 80, 1)
display.show()
play_ram_pcm(audio_chunks, channels=2, rate=16000, bits=16, volume=95)
if display:
display.clear(0)
if board_config.BOARD_TYPE == 'WAVESHARE_RLCD':
display.text("RLCD Audio Loopback Test", 10, 10, 1)
display.line(10, 20, 390, 20, 1)
display.text("Playback Finished!", 15, 70, 1)
display.text("Release trigger to restart...", 15, 95, 1)
else:
display.text("Touch Audio Loopback Test", 10, 10, 1)
display.line(10, 20, 310, 20, 1)
display.text("Playback Finished!", 10, 50, 1)
display.text("Release trigger to restart...", 10, 75, 1)
display.show()
# Wait for release of button/touch to debounce before ready again
while is_talk_trigger_active():
time.sleep_ms(30)
time.sleep_ms(500)
if __name__ == "__main__":
main()
+248
View File
@@ -0,0 +1,248 @@
import time
import struct
import machine
from machine import Pin, SPI, I2C, I2S
import urequests
import ili9341
from ft6336u import FT6336U
from audio_util import ES8311
# --- CONFIGURATION ---
HERMES_API_URL = "http://192.168.68.126:8642/api/esp32/voice"
DEVICE_ID = "kitchen-button"
# 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 create_wav_header(data_size):
# Generates a 44-byte WAV header for 16kHz, 16-bit mono PCM
riff = b'RIFF'
file_size = data_size + 36
wave = b'WAVE'
fmt = b'fmt '
chunk_size = 16
audio_format = 1 # PCM
channels = 1 # Mono
sample_rate = 16000
bits_per_sample = 16
byte_rate = sample_rate * channels * (bits_per_sample // 8)
block_align = channels * (bits_per_sample // 8)
data_label = b'data'
return struct.pack('<4sI4s4sIHHIIHH4sI',
riff, file_size, wave, fmt, chunk_size,
audio_format, channels, sample_rate, byte_rate,
block_align, bits_per_sample, data_label, data_size)
def main():
print("--- Starting Hermes Voice Assistant Demo ---")
# 1. Initialize display
spi = SPI(1, baudrate=40000000, polarity=0, phase=0, sck=Pin(12), mosi=Pin(11), miso=Pin(13))
display = ili9341.ILI9341(spi, cs=Pin(10), dc=Pin(46), bl=Pin(45), rst=None)
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.show()
# 2. Initialize touch
i2c = I2C(0, sda=Pin(16), scl=Pin(15))
touch = FT6336U(i2c, rst_pin=18, int_pin=17, width=320, height=240, swap_xy=True, invert_x=False, invert_y=True)
# 3. Configure audio codec ES8311
# We need MCLK pin (GPIO 4) active at 6.144 MHz
mclk_pin = Pin(4, Pin.OUT)
mclk_pwm = machine.PWM(mclk_pin)
mclk_pwm.freq(6144000)
mclk_pwm.duty_u16(32768)
codec = ES8311(i2c)
if not codec.init(sample_rate=16000):
print("ES8311 init failed!")
return
codec.set_volume(90)
# Configure microphone registers on ES8311
try:
codec._write(0x14, 0x1A) # Enable analog mic input & set PGA gain
codec._write(0x16, 0x01) # Boost MIC digital gain to +6dB
codec._write(0x17, 0xC8) # Set ADC digital volume
print("Microphone registers initialized.")
except Exception as e:
print("Failed to write microphone registers:", e)
amp_pin = Pin(1, Pin.OUT, value=1) # start with amp disabled (1 = disabled)
buffer = bytearray(1024)
while True:
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text("Hold screen & ask", 50, 70, 1)
display.text("a question...", 50, 90, 1)
display.text("Status: Idle", 10, 220, 1)
display.show()
# Wait for touch
while not touch.is_touched():
time.sleep_ms(30)
print("Touch detected! Starting recording to RAM...")
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text("Listening...", 80, 80, 1)
display.fill_rect(130, 110, 30, 30, 1)
display.text("Status: Recording", 10, 220, 1)
display.show()
# 4. Open I2S RX for recording (Mono 16kHz for Hermes STT)
i2s_rx = I2S(1,
sck=Pin(5),
ws=Pin(7),
sd=Pin(6),
mode=I2S.RX,
ibuf=8000,
rate=16000,
bits=16,
format=I2S.MONO)
audio_chunks = []
total_data_bytes = 0
try:
# Record loop - as long as touch is held
while touch.is_touched():
bytes_read = i2s_rx.readinto(buffer)
if bytes_read > 0:
audio_chunks.append(bytes(buffer[:bytes_read]))
total_data_bytes += bytes_read
print(f"Touch released! Recorded {total_data_bytes} bytes.")
except Exception as e:
print("Error recording:", e)
finally:
i2s_rx.deinit()
if total_data_bytes < 1000:
print("Recording too short, ignoring.")
continue
# 5. Connect and POST to Hermes voice endpoint
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text("Sending audio...", 50, 80, 1)
display.text("Waiting for agent...", 50, 100, 1)
display.text("Status: Processing", 10, 220, 1)
display.show()
# Join chunks and prepend WAV header
raw_pcm = b"".join(audio_chunks)
wav_data = create_wav_header(len(raw_pcm)) + raw_pcm
# Headers
headers = {
"Authorization": f"Bearer {HERMES_API_KEY}",
"Content-Type": "audio/wav",
"X-Device-ID": DEVICE_ID
}
print(f"Posting raw WAV ({len(wav_data)} bytes) to {HERMES_API_URL}...")
try:
res = urequests.post(HERMES_API_URL, headers=headers, data=wav_data)
print(f"HTTP Status: {res.status_code}")
if res.status_code == 200:
response_data = res.content
print(f"Received {len(response_data)} bytes of audio response.")
# Check response format
if response_data[0:4] == b'RIFF' and response_data[8:12] == b'WAVE':
# Parse WAV header
idx = response_data.find(b'fmt ')
if idx != -1:
fmt_chunk = response_data[idx:idx+24]
audio_format, channels, sample_rate, byte_rate, block_align, bits = struct.unpack('<HHIIHH', fmt_chunk[8:24])
print(f"Playing WAV: {sample_rate}Hz, {channels}ch, {bits}bit")
# Find data chunk
data_idx = response_data.find(b'data')
if data_idx != -1:
audio_start = data_idx + 8
display.clear(0)
display.text("Hermes Assistant", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text("Playing response...", 50, 80, 1)
display.text("Status: Speaking", 10, 220, 1)
display.show()
# Enable amp & play
amp_pin.value(0) # Active Low Enable
i2s_format = I2S.MONO if channels == 1 else I2S.STEREO
i2s_tx = I2S(1,
sck=Pin(5),
ws=Pin(7),
sd=Pin(8),
mode=I2S.TX,
ibuf=4096,
rate=sample_rate,
bits=bits,
format=i2s_format)
try:
pos = audio_start
chunk_size = 2048
while pos < len(response_data):
chunk = response_data[pos:pos+chunk_size]
i2s_tx.write(chunk)
pos += chunk_size
finally:
time.sleep_ms(150)
amp_pin.value(1) # Disable
i2s_tx.deinit()
else:
print("Error: Could not parse WAV format.")
else:
# Check if it is MP3
if response_data[0:3] == b'ID3' or (len(response_data) > 2 and response_data[0] == 0xFF and (response_data[1] & 0xE0) == 0xE0):
print("Error: Server returned MP3 format. ESP32 requires WAV.")
display.clear(0)
display.text("Error: Got MP3", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text("Hermes returned MP3.", 30, 80, 1)
display.text("Configure server to", 30, 100, 1)
display.text("convert MP3 to WAV.", 30, 120, 1)
display.show()
time.sleep(4)
else:
print(f"Error: Unknown response format. Starts with: {response_data[0:16]}")
else:
print(f"Request failed with status {res.status_code}: {res.text}")
display.clear(0)
display.text("HTTP Error", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text(f"Status: {res.status_code}", 50, 80, 1)
display.show()
time.sleep(3)
except Exception as e:
print("Failed to contact Hermes server:", e)
display.clear(0)
display.text("Server Error", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text("Could not connect", 50, 80, 1)
display.text("to API gateway.", 50, 100, 1)
display.show()
time.sleep(3)
# Debounce touch release
while touch.is_touched():
time.sleep_ms(30)
time.sleep_ms(300)
if __name__ == "__main__":
main()
+144
View File
@@ -0,0 +1,144 @@
import time
import machine
from machine import Pin, SPI, I2C, I2S
import ili9341
from ft6336u import FT6336U
from audio_util import ES8311
def main():
print("--- Starting RAM-based Touch Mic Demo ---")
# 1. Initialize display
spi = SPI(1, baudrate=40000000, polarity=0, phase=0, sck=Pin(12), mosi=Pin(11), miso=Pin(13))
display = ili9341.ILI9341(spi, cs=Pin(10), dc=Pin(46), bl=Pin(45), rst=None)
display.clear(0)
display.text("RAM Touch Mic", 10, 10, 1)
display.show()
# 2. Initialize touch
i2c = I2C(0, sda=Pin(16), scl=Pin(15))
touch = FT6336U(i2c, rst_pin=18, int_pin=17, width=320, height=240, swap_xy=True, invert_x=False, invert_y=True)
# 3. Configure audio codec ES8311
# Generate MCLK PWM on GPIO 4 at 6.144 MHz
mclk_pin = Pin(4, Pin.OUT)
mclk_pwm = machine.PWM(mclk_pin)
mclk_pwm.freq(6144000)
mclk_pwm.duty_u16(32768)
codec = ES8311(i2c)
if not codec.init(sample_rate=16000):
print("ES8311 init failed!")
return
# Set speaker volume to 95%
codec.set_volume(95)
# Configure microphone registers on ES8311
try:
codec._write(0x14, 0x1A) # Enable analog mic input & set PGA gain
codec._write(0x16, 0x01) # Boost MIC digital gain to +6dB to prevent saturation
codec._write(0x17, 0xC8) # Set ADC digital volume
print("Microphone registers initialized with gain boost.")
except Exception as e:
print("Failed to write microphone registers:", e)
amp_pin = Pin(1, Pin.OUT, value=1) # start with amp disabled (1 = disabled)
# We allocate a buffer for I2S read
buffer = bytearray(2048)
while True:
display.clear(0)
display.text("RAM Touch Mic", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text("Press & hold screen", 50, 80, 1)
display.text("to record voice...", 50, 100, 1)
display.show()
# Wait for touch
while not touch.is_touched():
time.sleep_ms(30)
print("Touch detected! Starting recording to RAM...")
display.clear(0)
display.text("Recording...", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text("SPEAK NOW!", 80, 80, 1)
display.fill_rect(130, 110, 30, 30, 1)
display.show()
# 4. Open I2S RX for recording
i2s_rx = I2S(1,
sck=Pin(5),
ws=Pin(7),
sd=Pin(6),
mode=I2S.RX,
ibuf=8000,
rate=16000,
bits=16,
format=I2S.STEREO)
audio_chunks = []
total_data_bytes = 0
try:
# Record loop - append chunks directly in RAM
while touch.is_touched():
bytes_read = i2s_rx.readinto(buffer)
if bytes_read > 0:
audio_chunks.append(bytes(buffer[:bytes_read]))
total_data_bytes += bytes_read
print(f"Touch released! Recorded {total_data_bytes} bytes in RAM.")
except Exception as e:
print("Error recording:", e)
finally:
i2s_rx.deinit()
# 5. Playback recorded audio
display.clear(0)
display.text("Playing back...", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text("Listening to RAM...", 50, 80, 1)
display.show()
print("Starting playback from RAM...")
amp_pin.value(0) # Enable amp
i2s_tx = I2S(1,
sck=Pin(5),
ws=Pin(7),
sd=Pin(8),
mode=I2S.TX,
ibuf=4096,
rate=16000,
bits=16,
format=I2S.STEREO)
try:
for chunk in audio_chunks:
i2s_tx.write(chunk)
except Exception as e:
print("Error during playback:", e)
finally:
time.sleep_ms(100) # wait to clear buffer
amp_pin.value(1) # Disable amp
i2s_tx.deinit()
print("Playback finished.")
display.clear(0)
display.text("Finished!", 10, 10, 1)
display.line(10, 22, 310, 22, 1)
display.text("Touch screen to", 50, 80, 1)
display.text("record again.", 50, 100, 1)
display.show()
# Debounce touch release
while touch.is_touched():
time.sleep_ms(30)
time.sleep_ms(400)
if __name__ == "__main__":
main()
+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()
+79
View File
@@ -0,0 +1,79 @@
# Desktop Companion Client for AI Agents
This is a cross-platform (Linux, macOS, Windows) client that allows your computer to be used directly by AI Agents as a physical-like communication and display companion.
It implements the Model Context Protocol (MCP) and exposes the host machine's actual hardware:
- **Audio Output**: Plays pure tones and WAV files on your actual speakers.
- **Audio Input**: Records voice messages from your microphone.
- **Battery Status**: Exposes your laptop's real battery voltage and percentage.
- **System Telemetry**: Reads host metrics (uptime, CPU loading).
- **Dedicated Canvas Window**: Renders images and text drawn by the agent.
- **Video Streaming**: Renders incoming 1-bit monochrome video streams (from sources streaming to TCP port 8081 or UDP port 8082).
- **UDP Discovery**: Responds to local discovery beacons.
## Requirements
- Python 3.10+
- **Pillow** (PIL) library (already installed in your environment)
- **Audio Playback**: Uses native system players (`afplay` on macOS, `aplay`/`paplay`/`pw-play` on Linux, `winsound` on Windows).
- **Audio Recording**:
- **Linux**: Standard `arecord` (pre-installed via `alsa-utils`).
- **macOS / Windows**: If `sounddevice` or `pyaudio` Python packages are installed, they will be used. Otherwise, it falls back to `sox`/`rec` if available, or generates a clean simulated wave format if no audio recorder is present.
## Running the Client
Start the client manually with:
```bash
python3 desktop_client/client.py
```
Options:
- `--port`: HTTP JSON-RPC port (default is `8080`)
- `--width`: Canvas width (default `480`)
- `--height`: Canvas height (default `320`)
- `--headless`: Run headlessly without Pygame GUI window (ideal for remote SSH servers)
### Keyboard Controls (GUI Mode)
- **`D`**: Switch the screen canvas back to the local **Host Telemetry Dashboard**.
- **`S`**: Save a screenshot of the companion window.
### Hidden Mode Behavior
To avoid cluttering your screen, the GUI window starts **hidden** on boot. It runs silently in the background and only pops to the foreground when a message (drawing or video stream) is sent by the AI Agent.
---
## Running as a macOS Service (LaunchAgent)
To run the companion client persistently as a background service that launches automatically on login, use the provided scripts:
1. **Install and Start the Service**:
```bash
./desktop_client/setup_service.sh
```
2. **Uninstall/Stop the Service**:
```bash
./desktop_client/uninstall_service.sh
```
Logs are captured dynamically at:
- Standard Out: `desktop_client/stdout.log`
- Standard Error: `desktop_client/stderr.log`
---
## Configuring Claude Desktop / Agent Host
To add this desktop client as an MCP tool provider, add the following to your `claude_desktop_config.json`:
```json
{
"mcpServers": {
"desktop-companion": {
"command": "/Users/adolforeyna/.pyenv/versions/3.10.12/bin/python3",
"args": [
"/Users/adolforeyna/Projects/MicroPython/test1/Screen/desktop_client/client.py"
]
}
}
}
```
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,23 @@
<?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>Label</key>
<string>com.adolforeyna.companionclient</string>
<key>ProgramArguments</key>
<array>
<string>/Users/adolforeyna/.pyenv/versions/3.10.12/bin/python3</string>
<string>/Users/adolforeyna/Projects/MicroPython/test1/Screen/desktop_client/client.py</string>
</array>
<key>WorkingDirectory</key>
<string>/Users/adolforeyna/Projects/MicroPython/test1/Screen</string>
<key>RunAtLoad</key>
<true/>
<key>KeepAlive</key>
<true/>
<key>StandardOutPath</key>
<string>/Users/adolforeyna/Projects/MicroPython/test1/Screen/desktop_client/stdout.log</string>
<key>StandardErrorPath</key>
<string>/Users/adolforeyna/Projects/MicroPython/test1/Screen/desktop_client/stderr.log</string>
</dict>
</plist>
+34
View File
@@ -0,0 +1,34 @@
#!/bin/bash
PLIST_NAME="com.adolforeyna.companionclient.plist"
LOCAL_PLIST="/Users/adolforeyna/Projects/MicroPython/test1/Screen/desktop_client/${PLIST_NAME}"
TARGET_DIR="${HOME}/Library/LaunchAgents"
TARGET_PLIST="${TARGET_DIR}/${PLIST_NAME}"
echo "=== Installing Desktop Companion LaunchAgent ==="
# 1. Create target directory if it doesn't exist
mkdir -p "${TARGET_DIR}"
# 2. Unload the service if it's already running
echo "Stopping existing agent if running..."
launchctl bootout gui/$(id -u) "${TARGET_PLIST}" 2>/dev/null
launchctl unload "${TARGET_PLIST}" 2>/dev/null
# 3. Copy the plist file
echo "Copying plist configuration..."
cp "${LOCAL_PLIST}" "${TARGET_PLIST}"
chmod 644 "${TARGET_PLIST}"
# 4. Load/start the LaunchAgent
echo "Loading and starting the service..."
launchctl bootstrap gui/$(id -u) "${TARGET_PLIST}"
# Or fallback if bootstrap fails
if [ $? -ne 0 ]; then
launchctl load "${TARGET_PLIST}"
fi
echo "Service successfully installed and started!"
echo "Check status using: launchctl list | grep com.adolforeyna.companionclient"
echo "Logs are available at:"
echo " STDOUT: /Users/adolforeyna/Projects/MicroPython/test1/Screen/desktop_client/stdout.log"
echo " STDERR: /Users/adolforeyna/Projects/MicroPython/test1/Screen/desktop_client/stderr.log"
+17
View File
@@ -0,0 +1,17 @@
#!/bin/bash
PLIST_NAME="com.adolforeyna.companionclient.plist"
TARGET_PLIST="${HOME}/Library/LaunchAgents/${PLIST_NAME}"
echo "=== Uninstalling Desktop Companion LaunchAgent ==="
if [ -f "${TARGET_PLIST}" ]; then
echo "Stopping and unloading the service..."
launchctl bootout gui/$(id -u) "${TARGET_PLIST}" 2>/dev/null
launchctl unload "${TARGET_PLIST}" 2>/dev/null
echo "Removing plist configuration..."
rm "${TARGET_PLIST}"
echo "Service successfully uninstalled!"
else
echo "Service is not installed."
fi
Binary file not shown.
Binary file not shown.
File diff suppressed because one or more lines are too long
+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()
+480
View File
@@ -0,0 +1,480 @@
import time
import machine
from machine import Pin, I2C, I2S
import board_config
class ES7210:
"""MicroPython driver for the ES7210 4-Channel Audio ADC (Microphone Array).
Controls the ES7210 chip over I2C to configure clocks, channels, gain, and format.
"""
ADDR = 0x40
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.
Args:
sample_rate (int): Audio sample rate (e.g. 16000, 44100, 48000).
bit_width (int): Data bit depth (16 or 24).
Returns:
bool: True if initialization was successful, False otherwise.
"""
print("Initializing ES7210 Microphone ADC (ESPHome sequence)...")
try:
# 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. Timing control
self._write(0x09, 0x30)
self._write(0x0A, 0x30)
# 3. High-pass filter
self._write(0x23, 0x2A)
self._write(0x22, 0x0A)
self._write(0x20, 0x0A)
self._write(0x21, 0x2A)
# 4. Mode config: clear bit 0 of Reg 0x08
val08 = self._read(0x08)
self._write(0x08, val08 & ~0x01)
# 5. Configure analog power
self._write(0x40, 0xC3)
# 6. Mic bias voltage
self._write(0x41, 0x70)
self._write(0x42, 0x70)
# 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
except Exception as e:
print(f"Failed to initialize ES7210: {e}")
return False
def record_audio(duration_seconds=10, filename='recording.pcm'):
"""Records raw stereo PCM data from the dual microphones to a file.
Args:
duration_seconds (int): How long to record in seconds.
filename (str): Name of output raw PCM file on the device.
"""
# 1. Use I2C Control Bus from board_config
i2c = board_config.i2c_bus
# 1a. Setup Master Clock (MCLK) using PWM if configured
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)
# 2. Configure I2S Receiver
i2s = 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)
# 3. Wake up and configure the microphone chip (ES7210 vs ES8311)
if board_config.audio_mic_codec == "ES7210":
mic_adc = ES7210(i2c)
if not mic_adc.init(sample_rate=16000, bit_width=16):
i2s.deinit()
if mclk_pwm: mclk_pwm.deinit()
return False
else:
mic_adc = ES8311(i2c)
if not mic_adc.init(sample_rate=16000):
i2s.deinit()
if mclk_pwm: mclk_pwm.deinit()
return False
print(f"Recording {duration_seconds} seconds of audio...")
# 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
try:
with open(filename, 'wb') as f:
while (time.time() - start_time) < duration_seconds:
# Read raw stereo PCM data from I2S
bytes_read = i2s.readinto(buffer)
if bytes_read > 0:
# 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
except Exception as e:
print(f"Error during recording: {e}")
return False
finally:
# Always release the I2S peripheral resources
try:
i2s.deinit()
except:
pass
if mclk_pwm:
try:
mclk_pwm.deinit()
except:
pass
print("I2S receiver and MCLK deinitialized.")
class ES8311:
"""MicroPython driver for the ES8311 Audio Codec (Speaker DAC).
Controls the ES8311 chip over I2C to configure clocks, audio format, and volume.
"""
ADDR = 0x18
def __init__(self, i2c):
self.i2c = i2c
def init(self, sample_rate=16000):
"""Initializes the ES8311 registers for audio playback.
Args:
sample_rate (int): Audio sample rate (typically 16000).
Returns:
bool: True if initialization was successful, False otherwise.
"""
print("Initializing ES8311 Speaker DAC...")
try:
# 1. Reset the chip
self._write(0x00, 0x1F)
time.sleep_ms(10)
self._write(0x00, 0x00)
time.sleep_ms(10)
# Clock Configuration (16kHz sample rate, MCLK=6.144MHz)
self._write(0x01, 0x3F) # Enable all clocks, use MCLK pin
self._write(0x02, 0x48) # pre_div=3, pre_mult=1
self._write(0x03, 0x10) # fs_mode=0, adc_osr=16
self._write(0x04, 0x10) # dac_osr=16
self._write(0x05, 0x00) # adc_div=1, dac_div=1
self._write(0x06, 0x03) # bclk_div=4 (4-1=3)
self._write(0x07, 0x00) # lrck_h=0
self._write(0x08, 0xFF) # lrck_l=255
# Audio Format Configuration (I2S standard format, 16-bit)
self._write(0x09, 0x0C) # SDP in: 16-bit I2S
self._write(0x0A, 0x0C) # SDP out: 16-bit I2S
# System / DAC Power Up
self._write(0x0D, 0x01) # Power up analog circuitry
self._write(0x0E, 0x02) # Enable analog PGA, enable ADC modulator
self._write(0x12, 0x00) # Power up DAC
self._write(0x13, 0x10) # Enable output to HP drive (speaker/hp output)
self._write(0x1C, 0x6A) # ADC Equalizer bypass
self._write(0x37, 0x08) # Bypass DAC equalizer
# Set Volume (0xBF = 0dB)
self._write(0x32, 0xBF)
# Unmute DAC
self._write(0x31, 0x00)
# Power On
self._write(0x00, 0x80)
print("ES8311 initialization complete.")
return True
except Exception as e:
print(f"Failed to initialize ES8311: {e}")
return False
def set_volume(self, val):
"""Sets DAC digital volume (0-100 scale)."""
# Volume register 0x32 accepts values from 0 (mute) to 255 (+0dB / max volume).
reg_val = int((val / 100.0) * 255.0)
reg_val = max(0, min(255, reg_val))
try:
self._write(0x32, reg_val)
except Exception as e:
print(f"Failed to set volume: {e}")
def _write(self, reg, val):
self.i2c.writeto_mem(self.ADDR, reg, bytes([val]))
def play_tone(frequency=440, duration_ms=1000, volume=50):
"""Plays a pure sine wave tone on the board speaker.
Args:
frequency (int): Tone frequency in Hz (e.g. 440 for A4).
duration_ms (int): Tone duration in milliseconds.
volume (int): Volume level from 0 to 100.
"""
import math
import struct
print(f"Playing tone: {frequency}Hz for {duration_ms}ms (vol={volume})...")
# 1. Setup Master Clock (MCLK) using PWM if configured
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)
# 2. Use dynamic I2C Control Bus from board_config
i2c = board_config.i2c_bus
# 3. Initialize the ES8311 DAC
dac = ES8311(i2c)
if not dac.init(sample_rate=16000):
if mclk_pwm: mclk_pwm.deinit()
return False
dac.set_volume(volume)
# 4. Configure I2S TX
i2s = 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=8000,
rate=16000,
bits=16,
format=I2S.STEREO)
# 5. Enable Speaker Amplifier
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=on_val)
# 6. Generate sine wave cycle
# Approximate frequency to make integer number of samples per cycle
# (avoiding phase clicking)
N = int(16000 / frequency)
N = max(4, N) # prevent division by zero or extremely high frequencies
volume_scale = int((volume / 100.0) * 32767)
cycle_data = bytearray()
for i in range(N):
val = int(volume_scale * math.sin(2 * math.pi * i / N))
cycle_data.extend(struct.pack("<hh", val, val)) # Stereo (L/R)
cycle_bytes = bytes(cycle_data)
# Write to I2S in chunks
total_samples = int(16000 * duration_ms / 1000)
total_cycles = int(total_samples / N)
written_cycles = 0
while written_cycles < total_cycles:
cycles_to_write = min(total_cycles - written_cycles, 100)
i2s.write(cycle_bytes * cycles_to_write)
written_cycles += cycles_to_write
# 7. Clean up
time.sleep_ms(100) # Let the remaining buffer play out
amp_pin.value(off_val) # Disable amp
i2s.deinit()
if mclk_pwm: mclk_pwm.deinit()
print("Tone playback complete.")
return True
def play_wav(filename, volume=50):
"""Plays a standard WAV audio file on the board speaker.
Standard format: 16-bit PCM, 16kHz sample rate (recommended).
"""
import struct
print(f"Playing WAV: {filename} (vol={volume})...")
try:
f = open(filename, 'rb')
except OSError:
print(f"Error: Cannot open WAV file '{filename}'")
return False
try:
# 1. Parse WAV header chunk by chunk
riff_header = f.read(12)
if len(riff_header) < 12 or riff_header[0:4] != b'RIFF' or riff_header[8:12] != b'WAVE':
print("Error: Invalid WAV file format")
f.close()
return False
channels = 1
sample_rate = 16000
bits = 16
while True:
chunk_header = f.read(8)
if len(chunk_header) < 8:
break
chunk_id, chunk_size = struct.unpack('<4sI', chunk_header)
if chunk_id == b'fmt ':
fmt_data = f.read(chunk_size)
if len(fmt_data) >= 16:
audio_format, channels, sample_rate, byte_rate, block_align, bits = struct.unpack('<HHIIHH', fmt_data[:16])
if audio_format != 1:
print(f"Warning: Non-PCM audio format ({audio_format})")
else:
print("Error: fmt chunk too small")
f.close()
return False
elif chunk_id == b'data':
# Audio data begins immediately after this chunk size
break
else:
# Skip unknown chunk (align to even byte)
skip_bytes = (chunk_size + 1) & ~1
f.seek(skip_bytes, 1)
print(f"WAV Info: {sample_rate}Hz, {bits} bits, {'Mono' if channels == 1 else 'Stereo'}")
# 2. Setup Master Clock (MCLK) using PWM if configured
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)
# 3. Use dynamic I2C Control Bus from board_config
i2c = board_config.i2c_bus
# 4. Initialize the ES8311 DAC
dac = ES8311(i2c)
if not dac.init(sample_rate=16000):
if mclk_pwm: mclk_pwm.deinit()
f.close()
return False
dac.set_volume(volume)
# 5. Configure I2S TX
i2s_format = I2S.MONO if channels == 1 else I2S.STEREO
i2s = 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,
format=i2s_format)
# 6. Enable Speaker Amplifier
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=on_val)
# 7. Read and stream chunks to I2S
buf = bytearray(2048)
while True:
bytes_read = f.readinto(buf)
if bytes_read == 0:
break
i2s.write(buf[:bytes_read])
# 8. Clean up
time.sleep_ms(100) # Let the remaining buffer play out
amp_pin.value(off_val) # Disable amp
i2s.deinit()
if mclk_pwm: mclk_pwm.deinit()
f.close()
print("WAV playback complete.")
return True
except Exception as e:
print(f"Error during WAV playback: {e}")
try:
f.close()
except:
pass
return False
+8 -7
View File
@@ -3,10 +3,11 @@ from machine import ADC, Pin
class BatteryMonitor:
"""Utility class for monitoring battery voltage and capacity on ESP32-S3-RLCD-4.2."""
def __init__(self, pin_num=4):
# Initialize ADC on GPIO 4 with 11dB attenuation
def __init__(self, pin_num=9):
import sys
self.adc = ADC(Pin(pin_num))
self.adc.atten(ADC.ATTN_11DB)
if sys.platform == 'esp32':
self.adc.atten(ADC.ATTN_11DB)
def read_voltage(self):
"""Reads the battery voltage in Volts using internal calibration.
@@ -17,16 +18,16 @@ class BatteryMonitor:
try:
# Try calibrated reading in microvolts first
uv = self.adc.read_uv()
# 3x voltage divider onboard scales 3.0V-4.2V battery to 1.0V-1.4V
voltage = (uv / 1_000_000.0) * 3.0
# 2x voltage divider onboard scales 3.0V-4.2V battery to 1.5V-2.1V
voltage = (uv / 1_000_000.0) * 2.0
return round(voltage, 3)
except AttributeError:
# Fallback if read_uv() is not supported on older MicroPython builds
try:
# Read 12-bit value (0-4095)
raw = self.adc.read()
# 3.3V reference at 11dB attenuation, 3x divider
voltage = (raw / 4095.0) * 3.3 * 3.0
# 3.3V reference at 11dB attenuation, 2x divider
voltage = (raw / 4095.0) * 3.3 * 2.0
return round(voltage, 3)
except Exception as e:
print(f"Error reading battery raw ADC: {e}")
+35 -17
View File
@@ -1,25 +1,40 @@
import bluetooth
try:
import bluetooth
has_ble = True
except ImportError:
has_ble = False
import struct
import time
# BLE UUID definitions (Nordic UART Service)
_UART_UUID = bluetooth.UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
_UART_TX = (
bluetooth.UUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"),
bluetooth.FLAG_NOTIFY,
)
_UART_RX = (
bluetooth.UUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"),
bluetooth.FLAG_WRITE | bluetooth.FLAG_WRITE_NO_RESPONSE,
)
_UART_SERVICE = (_UART_UUID, (_UART_TX, _UART_RX))
if not has_ble:
class BLEUART:
def __init__(self, ble=None, name="ESP32-S3-RLCD"):
print("BLE not supported on this platform.")
def on_rx(self, callback): pass
def write(self, data): return False
def is_connected(self): return False
def scan(self, duration_ms=3000): return {}
def close(self): pass
else:
# BLE UUID definitions (Nordic UART Service)
_UART_UUID = bluetooth.UUID("6E400001-B5A3-F393-E0A9-E50E24DCCA9E")
_UART_TX = (
bluetooth.UUID("6E400003-B5A3-F393-E0A9-E50E24DCCA9E"),
bluetooth.FLAG_NOTIFY,
)
_UART_RX = (
bluetooth.UUID("6E400002-B5A3-F393-E0A9-E50E24DCCA9E"),
bluetooth.FLAG_WRITE | bluetooth.FLAG_WRITE_NO_RESPONSE,
)
_UART_SERVICE = (_UART_UUID, (_UART_TX, _UART_RX))
# BLE IRQ constants
_IRQ_CENTRAL_CONNECT = 1
_IRQ_CENTRAL_DISCONNECT = 2
_IRQ_GATTS_WRITE = 3
# BLE IRQ constants
_IRQ_CENTRAL_CONNECT = 1
_IRQ_CENTRAL_DISCONNECT = 2
_IRQ_GATTS_WRITE = 3
class BLEUART:
class _ActiveBLEUART:
"""A helper class to manage BLE UART (Serial Over BLE) for raw text data exchange."""
def __init__(self, ble=None, name="ESP32-S3-RLCD"):
@@ -174,3 +189,6 @@ class BLEUART:
self._ble.gap_disconnect(conn_handle)
self._ble.active(False)
print("BLE closed.")
if has_ble:
BLEUART = _ActiveBLEUART
+218
View File
@@ -0,0 +1,218 @@
import sys
import machine
from machine import Pin, I2C, SPI
# Global configuration variables
BOARD_TYPE = None # 'HOSYOND', 'WAVESHARE_RLCD', or 'RP2'
DISPLAY_TYPE = None # 'ILI9341', 'RLCD', or 'ST7796'
DISPLAY_WIDTH = 320
DISPLAY_HEIGHT = 240
# Bus and display references
spi_bus = None
i2c_bus = None
display_instance = None
touch = None
# Component support flags
has_sensor = False
has_rtc = False
# WS2812 NeoPixel pin
led_pin = None
# Audio pins, clocks, and codecs config
audio_mclk_pin = None
audio_mclk_freq = 6144000
audio_i2c_sda = None
audio_i2c_scl = None
audio_i2s_sck = None
audio_i2s_ws = None
audio_i2s_tx_sd = None
audio_i2s_rx_sd = None
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
global has_sensor, has_rtc, led_pin
global audio_mclk_pin, audio_mclk_freq, audio_i2c_sda, audio_i2c_scl
global audio_i2s_sck, audio_i2s_ws, audio_i2s_tx_sd, audio_i2s_rx_sd
global audio_amp_pin, audio_amp_active_level, audio_mic_codec
# 1. Check if running on RP2 platform (e.g. Raspberry Pi Pico / RP2350)
if sys.platform == 'rp2':
print("Auto-detected: Raspberry Pi RP2 platform")
BOARD_TYPE = 'RP2'
DISPLAY_TYPE = 'ST7796'
DISPLAY_WIDTH = 480
DISPLAY_HEIGHT = 320
led_pin = 25
# Setup SPI
spi_bus = SPI(1, baudrate=32000000, polarity=0, phase=0, sck=Pin(10), mosi=Pin(11))
# Setup Display: ST7796
import st7796
display_instance = st7796.ST7796(spi_bus, cs=Pin(7), dc=Pin(4), rst=Pin(9), bl=Pin(6))
# 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)
except Exception as e:
print("Failed to initialize touch on RP2:", e)
return
# 2. We are on ESP32 platform.
# Use SoftI2C for dynamic detection scan to avoid hardware I2C pin conflicts
from machine import SoftI2C
# 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:
print("Auto-detected: Hosyond ESP32-S3 Touchscreen board")
BOARD_TYPE = 'HOSYOND'
DISPLAY_TYPE = 'ILI9341'
DISPLAY_WIDTH = 320
DISPLAY_HEIGHT = 240
has_sensor = False
has_rtc = False
led_pin = 48
# Now initialize SoftI2C
i2c_bus = SoftI2C(sda=Pin(16), scl=Pin(15))
# Setup SPI
spi_bus = SPI(1, baudrate=40000000, polarity=0, phase=0, sck=Pin(12), mosi=Pin(11), miso=Pin(13))
# Setup Display: ILI9341
import ili9341
display_instance = ili9341.ILI9341(spi_bus, cs=Pin(10), dc=Pin(46), bl=Pin(45), rst=None)
# Setup Touch: FT6336U
try:
from ft6336u import FT6336U
touch = FT6336U(i2c_bus, rst_pin=18, int_pin=17, width=320, height=240, swap_xy=True, invert_x=False, invert_y=True)
except Exception as te:
print("Failed to initialize touchscreen:", te)
# Audio pins & clocks configuration
audio_mclk_pin = 4
audio_mclk_freq = 6144000
audio_i2c_sda = 16
audio_i2c_scl = 15
audio_i2s_sck = 5
audio_i2s_ws = 7
audio_i2s_tx_sd = 8
audio_i2s_rx_sd = 6
audio_amp_pin = 1
audio_amp_active_level = 0 # Active Low (0 = enabled)
audio_mic_codec = "ES8311"
return
except Exception as e:
print("SoftI2C scan on Hosyond pins failed:", e)
# 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:
print("Auto-detected: Waveshare ESP32-S3-RLCD-4.2 board")
BOARD_TYPE = 'WAVESHARE_RLCD'
DISPLAY_TYPE = 'RLCD'
DISPLAY_WIDTH = 400
DISPLAY_HEIGHT = 300
has_sensor = True
has_rtc = True
led_pin = 38
# Setup SPI
spi_bus = SPI(1, baudrate=20000000, polarity=0, phase=0, sck=Pin(11), mosi=Pin(12))
# Setup Display: RLCD
import rlcd
display_instance = rlcd.RLCD(spi_bus, cs=Pin(40), dc=Pin(5), rst=Pin(41))
# Now initialize SoftI2C (after SPI/Display setup to avoid MISO pin conflict on Pin 13)
i2c_bus = SoftI2C(sda=Pin(13), scl=Pin(14))
# Audio pins & clocks configuration
audio_mclk_pin = 16
audio_mclk_freq = 12288000
audio_i2c_sda = 13
audio_i2c_scl = 14
audio_i2s_sck = 9
audio_i2s_ws = 45
audio_i2s_tx_sd = 8
audio_i2s_rx_sd = 10
audio_amp_pin = 46
audio_amp_active_level = 1 # Active High (1 = enabled)
audio_mic_codec = "ES7210"
return
except Exception as e:
print("SoftI2C scan on Waveshare RLCD pins failed:", e)
# 4. Fallback default if auto-detection failed completely
print("Auto-detection failed. Falling back to Hosyond ESP32-S3 defaults...")
BOARD_TYPE = 'HOSYOND'
DISPLAY_TYPE = 'ILI9341'
DISPLAY_WIDTH = 320
DISPLAY_HEIGHT = 240
led_pin = 48
try:
from machine import SoftI2C
i2c_bus = SoftI2C(sda=Pin(16), scl=Pin(15))
spi_bus = SPI(1, baudrate=40000000, polarity=0, phase=0, sck=Pin(12), mosi=Pin(11), miso=Pin(13))
import ili9341
display_instance = ili9341.ILI9341(spi_bus, cs=Pin(10), dc=Pin(46), bl=Pin(45), rst=None)
from ft6336u import FT6336U
touch = FT6336U(i2c_bus, rst_pin=18, int_pin=17, width=320, height=240, swap_xy=True, invert_x=False, invert_y=True)
except Exception as e:
print("Fallback hardware setup failed:", e)
audio_mclk_pin = 4
audio_mclk_freq = 6144000
audio_i2c_sda = 16
audio_i2c_scl = 15
audio_i2s_sck = 5
audio_i2s_ws = 7
audio_i2s_tx_sd = 8
audio_i2s_rx_sd = 6
audio_amp_pin = 1
audio_amp_active_level = 0
audio_mic_codec = "ES8311"
# Run dynamic detection immediately on module load
detect_board()
+68
View File
@@ -0,0 +1,68 @@
import os
import urequests
from sd_util import SDCardManager
def download_file(url, dest_filename, use_sd=True):
"""Downloads a file from a URL over the network in memory-efficient chunks.
If use_sd is True, it automatically mounts the microSD card and saves to /sd/dest_filename.
Otherwise, it saves to the local flash storage.
Returns:
str: The full path to the downloaded file on success, None on failure.
"""
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 = urequests.get(url, stream=True)
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:
# Read from socket stream in chunks to avoid OutOfMemory errors
chunk_size = 4096
total_downloaded = 0
with open(dest_path, 'wb') as f:
while True:
# Read chunk from the raw socket connection stream
chunk = res.raw.read(chunk_size)
if not chunk:
break
f.write(chunk)
total_downloaded += len(chunk)
# Dynamic logging
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}")
# Clean up partial file on failure
try:
os.remove(dest_path)
except:
pass
return None
finally:
res.close()
+133
View File
@@ -0,0 +1,133 @@
import time
from machine import Pin, I2C
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=28, int_pin=25, width=480, height=320, swap_xy=True, invert_x=True, invert_y=False):
self.i2c = i2c
self.rst = Pin(rst_pin, Pin.OUT)
self.int = Pin(int_pin, Pin.IN, Pin.PULL_UP)
self.width = width
self.height = height
self.swap_xy = swap_xy
self.invert_x = invert_x
self.invert_y = invert_y
self.initialized = False
self.reset()
self.init_chip()
def reset(self):
self.rst(0)
time.sleep_ms(10)
self.rst(1)
time.sleep_ms(300) # Wait for chip to wake up
def read_reg(self, reg, n=1):
try:
return self.i2c.readfrom_mem(self.ADDR, reg, n)
except Exception as e:
print(f"I2C read failed at reg 0x{reg:02X}: {e}")
return None
def write_reg(self, reg, val):
try:
self.i2c.writeto_mem(self.ADDR, reg, bytearray([val]))
return True
except Exception as e:
print(f"I2C write failed at reg 0x{reg:02X}: {e}")
return False
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:
# Retry once
time.sleep_ms(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 and clearing coordinates."""
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 touch_count > 0 and 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.
Returns:
(x, y) coordinates of first touch point, or None if no touch.
"""
if not self.initialized:
return None
# Read TD_STATUS to check if there is an active touch
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
# Read P1 coordinates (6 bytes starting at P1_XH)
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]
# Apply coordinate transformations to map touch coordinate space to screen space
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
# Clamp coordinates to screen boundaries
x = max(0, min(self.width - 1, raw_x))
y = max(0, min(self.height - 1, raw_y))
return (x, y)
+500
View File
@@ -0,0 +1,500 @@
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):
self.spi = spi
self.cs = cs
self.dc = dc
self.rst = rst
self.bl = bl
self.width = width
self.height = height
self.invert_color = invert_color
# 1. 1-bit Canvas Buffer (Standard MONO_HLSB for drawing)
self.hw_len = (self.width * self.height) // 8
self.canvas_buffer = bytearray(self.hw_len)
self.canvas = framebuf.FrameBuffer(self.canvas_buffer, self.width, self.height, framebuf.MONO_HLSB)
# Pre-allocate chunk buffer for conversion (16 rows: 320 * 16 * 2 = 10240 bytes)
self.chunk_rows = 16
self.row_buffer = bytearray(self.width * self.chunk_rows * 2)
# Initialize pins
self.cs.init(self.cs.OUT, value=1)
self.dc.init(self.dc.OUT, value=0)
if self.rst is not None:
self.rst.init(self.rst.OUT, value=1)
if self.bl is not None:
self.bl.init(self.bl.OUT, value=1)
self.reset()
self.init_display()
self.clear(0)
self.show()
# --- DRAWING WRAPPERS ---
def pixel(self, x, y, c): self.canvas.pixel(x, y, c)
def line(self, x1, y1, x2, y2, c): self.canvas.line(x1, y1, x2, y2, c)
def rect(self, x, y, w, h, c): self.canvas.rect(x, y, w, h, c)
def fill_rect(self, x, y, w, h, c): self.canvas.fill_rect(x, y, w, h, c)
def text(self, msg, x, y, c=1): self.canvas.text(msg, x, y, c)
def clear(self, c=0): self.canvas.fill(c)
# --- SCALABLE TEXT ---
def text_large(self, msg, x, y, scale=2, c=1):
char_w = 8; char_h = 8
tmp_buf = bytearray(char_w * char_h // 8)
tmp_fb = framebuf.FrameBuffer(tmp_buf, char_w, char_h, framebuf.MONO_HLSB)
for char in msg:
tmp_fb.fill(0); tmp_fb.text(char, 0, 0, 1)
for py in range(8):
for px in range(8):
if tmp_fb.pixel(px, py):
self.canvas.fill_rect(x + (px * scale), y + (py * scale), scale, scale, c)
x += (8 * scale)
# --- RAW BITMAPS (1:1 scale) ---
def bitmap(self, x, y, w, h, pixel_data):
img = framebuf.FrameBuffer(pixel_data, w, h, framebuf.MONO_HLSB)
self.canvas.blit(img, x, y)
# --- PBM FILE LOADER WITH SCALING ---
def draw_pbm(self, filename, x, y, scale=1):
try:
with open(filename, 'rb') as f:
line1 = f.readline()
if not line1.startswith(b'P4'): print("Err: Not P4 PBM"); return
while True:
line = f.readline()
if not line.startswith(b'#'): break
dims = line.split(); w = int(dims[0]); h = int(dims[1])
data = bytearray(f.read())
src_fb = framebuf.FrameBuffer(data, w, h, framebuf.MONO_HLSB)
if scale == 1:
self.canvas.blit(src_fb, x, y)
else:
for sy in range(h):
for sx in range(w):
if src_fb.pixel(sx, sy):
self.canvas.fill_rect(x + (sx * scale), y + (sy * scale), scale, scale, 1)
print(f"Loaded {filename} (scale {scale})")
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}...")
try:
with open(filename, 'wb') as f:
f.write(b'P4\n')
f.write(f"{self.width} {self.height}\n".encode())
f.write(self.canvas_buffer)
print("Saved!")
except Exception as e:
print(f"Error saving screenshot: {e}")
# --- HARDWARE LOGIC ---
def reset(self):
if self.rst is not None:
self.rst(1); time.sleep_ms(5); self.rst(0); time.sleep_ms(15); self.rst(1); time.sleep_ms(15)
def write_cmd(self, cmd):
self.dc(0); self.cs(0); self.spi.write(bytearray([cmd])); self.cs(1)
def write_data(self, data):
self.dc(1); self.cs(0)
if isinstance(data, int): self.spi.write(bytearray([data]))
elif isinstance(data, list): self.spi.write(bytearray(data))
else: self.spi.write(data)
self.cs(1)
def init_display(self):
# ILI9341 Initialization Sequence
self.write_cmd(0x01) # SWRESET
time.sleep_ms(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") # Positive Gamma Correction
self.write_cmd(0xE1); self.write_data(b"\x00\x0A\x0F\x04\x11\x08\x36\x58\x4D\x07\x10\x0C\x32\x34\x0F") # Negative Gamma Correction
if self.invert_color:
self.write_cmd(0x21) # INVON
else:
self.write_cmd(0x20) # INVOFF
self.write_cmd(0x11) # SLPOUT (Exit sleep mode)
time.sleep_ms(120)
self.write_cmd(0x29) # DISPON (Display on)
time.sleep_ms(10)
def invert(self, enable):
if enable:
self.write_cmd(0x21) # INVON
else:
self.write_cmd(0x20) # INVOFF
def set_window(self, x0, y0, x1, y1):
# Column Address Set (CASET)
self.write_cmd(0x2A)
self.write_data(bytearray([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF]))
# Row Address Set (RASET)
self.write_cmd(0x2B)
self.write_data(bytearray([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF]))
# Memory Write (RAMWR)
self.write_cmd(0x2C)
@micropython.native
def _convert_rows(self, start_row, num_rows, row_buf):
"""Converts 1-bit monochrome row segment to 16-bit RGB565 format.
Compiles block-wise bitwise operations at native speed.
"""
width = self.width
canvas_buf = self.canvas_buffer
idx = 0
for y in range(start_row, start_row + num_rows):
byte_offset = y * (width // 8)
for x_byte_idx in range(width // 8):
val = canvas_buf[byte_offset + x_byte_idx]
# Unroll 8 bits for speed
# Bit 7
if val & 0x80:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 6
if val & 0x40:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 5
if val & 0x20:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 4
if val & 0x10:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 3
if val & 0x08:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 2
if val & 0x04:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 1
if val & 0x02:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 0
if val & 0x01:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
def show(self):
"""Refreshes the screen by writing the frame buffer segment-by-segment."""
self.set_window(0, 0, self.width - 1, self.height - 1)
self.dc(1)
self.cs(0)
num_chunks = self.height // self.chunk_rows
for chunk in range(num_chunks):
start_row = chunk * self.chunk_rows
self._convert_rows(start_row, self.chunk_rows, self.row_buffer)
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)
+4 -1
View File
@@ -6,7 +6,10 @@ import neopixel
class BoardLED:
"""Utility class to control the onboard WS2812 (NeoPixel) RGB LED on GPIO 38."""
def __init__(self, pin_num=38):
def __init__(self, pin_num=None):
import sys
if pin_num is None:
pin_num = 14 if sys.platform == 'rp2' else 42
self.np = neopixel.NeoPixel(Pin(pin_num), 1)
self.base_color = (0, 0, 0)
self.off()
+116 -2
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}...")
@@ -129,13 +221,19 @@ class RLCD:
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(0x20) # Inversion OFF (White Background)
self.write_cmd(0x21) # Inversion ON (Black Background by default)
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 invert(self, enable):
if enable:
self.write_cmd(0x21) # Inversion ON (Black Background)
else:
self.write_cmd(0x20) # Inversion OFF (White Background)
# --- THE HEAVY LIFTER (Optimized) ---
@micropython.native
def show(self):
@@ -157,4 +255,20 @@ class RLCD:
self.write_cmd(0x2C)
self.cs(0); self.dc(1)
self.spi.write(self.hw_buffer)
self.cs(1)
self.cs(1)
def set_brightness(self, level):
"""Set backlight brightness percentage. RLCD is reflective and doesn't support backlight."""
print("RLCD is a reflective LCD and does not support backlight brightness control.")
pass
def set_power(self, on):
"""Set display power status (True = ON, False = OFF)."""
if on:
self.write_cmd(0x11) # SLPOUT
time.sleep_ms(120)
self.write_cmd(0x29) # DISPON
else:
self.write_cmd(0x28) # DISPOFF
self.write_cmd(0x10) # SLPIN
time.sleep_ms(10)
+2 -1
View File
@@ -11,7 +11,8 @@ class PCF85063:
def __init__(self, i2c=None):
if i2c is None:
# Default to ESP32-S3-RLCD-4.2 onboard I2C pins
self.i2c = I2C(0, sda=Pin(13), scl=Pin(14))
from machine import SoftI2C
self.i2c = SoftI2C(sda=Pin(13), scl=Pin(14))
else:
self.i2c = i2c
View File
+2 -1
View File
@@ -13,7 +13,8 @@ class SHTC3:
def __init__(self, i2c=None):
if i2c is None:
# Default to ESP32-S3-RLCD-4.2 onboard I2C pins
self.i2c = I2C(0, sda=Pin(13), scl=Pin(14))
from machine import SoftI2C
self.i2c = SoftI2C(sda=Pin(13), scl=Pin(14))
else:
self.i2c = i2c
+488
View File
@@ -0,0 +1,488 @@
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):
self.spi = spi
self.cs = cs
self.dc = dc
self.rst = rst
self.bl = bl
self.width = width
self.height = height
self.invert_color = invert_color
# 1. 1-bit Canvas Buffer (Standard MONO_HLSB for drawing)
self.hw_len = (self.width * self.height) // 8
self.canvas_buffer = bytearray(self.hw_len)
self.canvas = framebuf.FrameBuffer(self.canvas_buffer, self.width, self.height, framebuf.MONO_HLSB)
# Pre-allocate chunk buffer for conversion (16 rows: 480 * 16 * 2 = 15360 bytes)
self.chunk_rows = 16
self.row_buffer = bytearray(self.width * self.chunk_rows * 2)
# Initialize pins
self.cs.init(self.cs.OUT, value=1)
self.dc.init(self.dc.OUT, value=0)
self.rst.init(self.rst.OUT, value=1)
if self.bl is not None:
# PWM or Pin backlight control
if isinstance(self.bl, Pin):
self.bl.init(self.bl.OUT, value=1)
self.reset()
self.init_display()
self.clear(0)
self.show()
# --- DRAWING WRAPPERS ---
def pixel(self, x, y, c): self.canvas.pixel(x, y, c)
def line(self, x1, y1, x2, y2, c): self.canvas.line(x1, y1, x2, y2, c)
def rect(self, x, y, w, h, c): self.canvas.rect(x, y, w, h, c)
def fill_rect(self, x, y, w, h, c): self.canvas.fill_rect(x, y, w, h, c)
def text(self, msg, x, y, c=1): self.canvas.text(msg, x, y, c)
def clear(self, c=0): self.canvas.fill(c)
# --- SCALABLE TEXT ---
def text_large(self, msg, x, y, scale=2, c=1):
char_w = 8; char_h = 8
tmp_buf = bytearray(char_w * char_h // 8)
tmp_fb = framebuf.FrameBuffer(tmp_buf, char_w, char_h, framebuf.MONO_HLSB)
for char in msg:
tmp_fb.fill(0); tmp_fb.text(char, 0, 0, 1)
for py in range(8):
for px in range(8):
if tmp_fb.pixel(px, py):
self.canvas.fill_rect(x + (px * scale), y + (py * scale), scale, scale, c)
x += (8 * scale)
# --- RAW BITMAPS (1:1 scale) ---
def bitmap(self, x, y, w, h, pixel_data):
img = framebuf.FrameBuffer(pixel_data, w, h, framebuf.MONO_HLSB)
self.canvas.blit(img, x, y)
# --- PBM FILE LOADER WITH SCALING ---
def draw_pbm(self, filename, x, y, scale=1):
try:
with open(filename, 'rb') as f:
line1 = f.readline()
if not line1.startswith(b'P4'): print("Err: Not P4 PBM"); return
while True:
line = f.readline()
if not line.startswith(b'#'): break
dims = line.split(); w = int(dims[0]); h = int(dims[1])
data = bytearray(f.read())
src_fb = framebuf.FrameBuffer(data, w, h, framebuf.MONO_HLSB)
if scale == 1:
self.canvas.blit(src_fb, x, y)
else:
for sy in range(h):
for sx in range(w):
if src_fb.pixel(sx, sy):
self.canvas.fill_rect(x + (sx * scale), y + (sy * scale), scale, scale, 1)
print(f"Loaded {filename} (scale {scale})")
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}...")
try:
with open(filename, 'wb') as f:
f.write(b'P4\n')
f.write(f"{self.width} {self.height}\n".encode())
f.write(self.canvas_buffer)
print("Saved!")
except Exception as e:
print(f"Error saving screenshot: {e}")
# --- HARDWARE LOGIC ---
def reset(self):
self.rst(1); time.sleep_ms(5); self.rst(0); time.sleep_ms(15); self.rst(1); time.sleep_ms(15)
def write_cmd(self, cmd):
self.dc(0); self.cs(0); self.spi.write(bytearray([cmd])); self.cs(1)
def write_data(self, data):
self.dc(1); self.cs(0)
if isinstance(data, int): self.spi.write(bytearray([data]))
elif isinstance(data, list): self.spi.write(bytearray(data))
else: self.spi.write(data)
self.cs(1)
def init_display(self):
# ST7796 Minimal Initialization Sequence (ported from working C++ codebase)
self.write_cmd(0x01) # SWRESET
time.sleep_ms(150)
self.write_cmd(0x11) # SLPOUT
time.sleep_ms(120)
# Pixel Format (COLMOD) = 0x55 (16-bit color / RGB565)
self.write_cmd(0x3A); self.write_data(0x55)
time.sleep_ms(10)
# Memory Access Control (MADCTL) = 0xE0 (Landscape: MY=1, MX=1, MV=1, RGB order)
self.write_cmd(0x36); self.write_data(0xE0)
time.sleep_ms(10)
# Display Inversion Control
if self.invert_color:
self.write_cmd(0x21) # INVON
else:
self.write_cmd(0x20) # INVOFF
time.sleep_ms(10)
self.write_cmd(0x13) # NORON
time.sleep_ms(10)
self.write_cmd(0x29) # DISPON
time.sleep_ms(120)
def invert(self, enable):
if enable:
self.write_cmd(0x21) # INVON
else:
self.write_cmd(0x20) # INVOFF
def set_window(self, x0, y0, x1, y1):
# Column Address Set (CASET)
self.write_cmd(0x2A)
self.write_data(bytearray([x0 >> 8, x0 & 0xFF, x1 >> 8, x1 & 0xFF]))
# Row Address Set (RASET)
self.write_cmd(0x2B)
self.write_data(bytearray([y0 >> 8, y0 & 0xFF, y1 >> 8, y1 & 0xFF]))
# Memory Write (RAMWR)
self.write_cmd(0x2C)
@micropython.native
def _convert_rows(self, start_row, num_rows, row_buf):
"""Converts 1-bit monochrome row segment to 16-bit RGB565 format.
Compiles block-wise bitwise operations at native speed.
"""
width = self.width
canvas_buf = self.canvas_buffer
idx = 0
for y in range(start_row, start_row + num_rows):
byte_offset = y * (width // 8)
for x_byte_idx in range(width // 8):
val = canvas_buf[byte_offset + x_byte_idx]
# Unroll 8 bits for speed
# Bit 7
if val & 0x80:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 6
if val & 0x40:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 5
if val & 0x20:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 4
if val & 0x10:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 3
if val & 0x08:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 2
if val & 0x04:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 1
if val & 0x02:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
# Bit 0
if val & 0x01:
row_buf[idx] = 0xFF; row_buf[idx+1] = 0xFF
else:
row_buf[idx] = 0x00; row_buf[idx+1] = 0x00
idx += 2
def show(self):
"""Refreshes the screen by writing the frame buffer segment-by-segment."""
self.set_window(0, 0, self.width - 1, self.height - 1)
self.dc(1)
self.cs(0)
num_chunks = self.height // self.chunk_rows
for chunk in range(num_chunks):
start_row = chunk * self.chunk_rows
self._convert_rows(start_row, self.chunk_rows, self.row_buffer)
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
+601 -63
View File
@@ -1,8 +1,28 @@
import time
import network
import json
import sys
import machine
from machine import Pin, SPI, I2C
import rlcd
from machine import Pin, SPI, I2C, I2S
try:
import network
has_network = True
except ImportError:
has_network = False
import board_config
class DummyMCP:
def __init__(self):
self.override_active = False
self.active_led_mode = "off"
def update(self): pass
def start(self, port=80): pass
class DummyVStream:
def __init__(self):
self.active = False
def update(self): pass
def start(self): pass
# Import our utility classes
from shtc3_util import SHTC3
@@ -12,6 +32,151 @@ from button_util import BoardButtons
from rgb_led_util import BoardLED
from ble_util import BLEUART
from mcp_server import MCPServer
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
riff = b'RIFF'
file_size = data_size + 36
wave = b'WAVE'
fmt = b'fmt '
chunk_size = 16
audio_format = 1 # PCM
channels = 1 # Mono
sample_rate = 16000
bits_per_sample = 16
byte_rate = sample_rate * channels * (bits_per_sample // 8)
block_align = channels * (bits_per_sample // 8)
data_label = b'data'
return struct.pack('<4sI4s4sIHHIIHH4sI',
riff, file_size, wave, fmt, chunk_size,
audio_format, channels, sample_rate, byte_rate,
block_align, bits_per_sample, data_label, data_size)
def socket_readline(s):
line = bytearray()
while True:
try:
char = s.recv(1)
except OSError:
break
if not char:
break
line.extend(char)
if char == b'\n':
break
return line
def socket_read_exactly(s, n):
res = bytearray()
while len(res) < n:
try:
chunk = s.recv(n - len(res))
except OSError:
break
if not chunk:
break
res.extend(chunk)
return res
def stream_hermes_request(url, headers, filename):
# Parse URL
proto, _, host_port_path = url.split('/', 2)
host_port = host_port_path.split('/', 1)[0]
path = '/' + host_port_path.split('/', 1)[1] if '/' in host_port_path else '/'
if ':' in host_port:
host, port = host_port.split(':')
port = int(port)
else:
host, port = host_port, 80
import socket
addr = socket.getaddrinfo(host, port)[0][-1]
s = socket.socket()
s.settimeout(30.0)
s.connect(addr)
# Calculate file size
import os
try:
file_size = os.stat(filename)[6]
except OSError:
file_size = 0
content_length = file_size + 44 # WAV header + PCM
# Send request headers
s.write(f"POST {path} HTTP/1.1\r\n".encode())
s.write(f"Host: {host_port}\r\n".encode())
for k, v in headers.items():
s.write(f"{k}: {v}\r\n".encode())
s.write(f"Content-Length: {content_length}\r\n".encode())
s.write(b"\r\n")
# Write WAV header
s.write(create_wav_header(file_size))
# Stream audio file from flash
if file_size > 0:
buf = bytearray(2048)
with open(filename, "rb") as f:
while True:
n = f.readinto(buf)
if n == 0:
break
s.write(buf[:n])
# Read status line
status_line = socket_readline(s).decode()
parts = status_line.split(' ')
status_code = int(parts[1]) if len(parts) >= 2 else 500
# Read headers
resp_headers = {}
while True:
line = socket_readline(s)
if line == b"\r\n" or not line:
break
p = line.decode().split(':', 1)
if len(p) == 2:
resp_headers[p[0].strip().lower()] = p[1].strip()
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 = [
@@ -26,37 +191,90 @@ led_modes = [
local_led_mode_idx = 1 # Green breathing
def main():
# Check if we should run the audio loopback test instead
import os
try:
os.stat("run_loopback.txt")
print("run_loopback.txt found! Starting local audio loopback test...")
import demo_audio_loopback
demo_audio_loopback.main()
return
except OSError:
pass
global local_led_mode_idx
print("=== Starting ESP32-S3-RLCD-4.2 Main Boot ===")
import board_config
# 1. Initialize shared buses and peripherals
i2c = I2C(0, sda=Pin(13), scl=Pin(14))
print("=== Starting MCP Server Main Boot (Type: {}) ===".format(board_config.BOARD_TYPE))
spi = SPI(1, baudrate=20000000, polarity=0, phase=0, sck=Pin(11), mosi=Pin(12))
display = rlcd.RLCD(spi, cs=Pin(40), dc=Pin(5), rst=Pin(41))
# 1. Use pre-initialized display and buses from board_config
i2c = board_config.i2c_bus
spi = board_config.spi_bus
display = board_config.display_instance
touch = board_config.touch
# Configure Audio Amp control pin to save power
amp_pin = Pin(46, Pin.OUT, value=0)
if sys.platform != 'rp2' and board_config.audio_amp_pin is not None:
# Turn OFF amplifier on boot (active-low vs active-high)
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)
# 2. Initialize utility objects
sensor = SHTC3(i2c)
rtc_chip = PCF85063(i2c)
sensor = None
rtc_chip = None
if i2c is not None:
if board_config.has_sensor:
try:
sensor = SHTC3(i2c)
except Exception as e:
print("Failed to initialize SHTC3:", e)
if board_config.has_rtc:
try:
rtc_chip = PCF85063(i2c)
except Exception as e:
print("Failed to initialize PCF85063:", e)
battery = BatteryMonitor()
led = BoardLED(38)
buttons = BoardButtons()
ble_uart = BLEUART(name="ESP32-S3-RLCD")
led = BoardLED(board_config.led_pin)
buttons = None
if sys.platform != 'rp2':
buttons = BoardButtons()
ble_name = "ESP32-S3-" + ("RLCD" if board_config.BOARD_TYPE == "WAVESHARE_RLCD" else "Touch")
ble_uart = BLEUART(name=ble_name)
# Sync system clock from RTC chip
rtc_chip.sync_to_system()
if rtc_chip:
try:
rtc_chip.sync_to_system()
except Exception as e:
print("Failed to sync clock:", e)
# 3. Connect to Wi-Fi status check (boot.py already attempted connection)
wlan = network.WLAN(network.STA_IF)
ip_addr = wlan.ifconfig()[0] if wlan.isconnected() else "Disconnected"
# 4. Start MCP Server
mcp = MCPServer(display, led, battery, sensor, rtc_chip, ble_uart)
mcp.start(port=80)
# 3. Connect to Wi-Fi status check
ip_addr = "Offline (USB)"
mcp = DummyMCP()
vstream = DummyVStream()
if has_network:
try:
wlan = network.WLAN(network.STA_IF)
ip_addr = wlan.ifconfig()[0] if wlan.isconnected() else "Disconnected"
# 4. Start background TCP/UDP Video Streaming Server
from video_stream import VideoStreamServer
vstream = VideoStreamServer(display, tcp_port=8081, udp_port=8082)
vstream.start()
# 4b. Start MCP Server
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)
# Default to Green Breathing
led_modes[local_led_mode_idx][1](led)
mcp.active_led_mode = led_modes[local_led_mode_idx][2]
@@ -64,9 +282,19 @@ def main():
# Last user actions
last_action_str = "Boot finished."
force_dashboard_redraw = True
voice_assistant_active = False
def is_talk_trigger_active():
if touch:
return touch.is_touched()
elif buttons and buttons.key:
return buttons.key.is_pressed()
return False
# 5. Register Button Handlers
def on_key_click():
if voice_assistant_active:
return
global local_led_mode_idx
local_led_mode_idx = (local_led_mode_idx + 1) % len(led_modes)
mode_name, color_fn, mode_type = led_modes[local_led_mode_idx]
@@ -87,12 +315,15 @@ def main():
mcp.override_active = False
force_dashboard_redraw = True
buttons.key.on_click(on_key_click)
buttons.boot.on_click(on_boot_click)
if buttons:
buttons.key.on_click(on_key_click)
buttons.boot.on_click(on_boot_click)
# Loop state
last_dashboard_update = 0
dashboard_update_interval_ms = 5000
last_led_update = 0
last_wifi_check = 0
print("ESP32 MCP loop running...")
@@ -100,23 +331,305 @@ def main():
while True:
now = time.ticks_ms()
# A. Check Wi-Fi status periodically (every 10 seconds) and auto-reconnect
if has_network and time.ticks_diff(now, last_wifi_check) >= 10000:
last_wifi_check = now
if not wlan.isconnected():
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:
wlan.connect(wifi_config.WIFI_SSID, wifi_config.WIFI_PASS)
except Exception as e:
print("Wi-Fi reconnect trigger failed:", e)
elif ip_addr == "Disconnected" or ip_addr == "Offline (USB)":
ip_addr = wlan.ifconfig()[0]
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():
print("Voice assistant trigger detected! Starting Hermes Voice Assistant...")
voice_assistant_active = True
def draw_status_bar(text):
y_bar = display.height - 25
display.line(0, y_bar, display.width, y_bar, 1)
display.fill_rect(0, y_bar + 1, display.width, 24, 0)
display.text(text, 10, y_bar + 7, 1)
display.show()
def clear_status_bar():
y_bar = display.height - 25
display.fill_rect(0, y_bar, display.width, 25, 0)
display.show()
draw_status_bar("PTT Voice: Initializing...")
# Start recording immediately!
tap_started = True
if tap_started:
draw_status_bar("Recording: 10s...")
# 1. Start MCLK PWM and configure mic path based on board config
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)
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) # Enable analog mic input & PGA
codec._write(0x16, 0x01) # Enable +6dB gain boost
codec._write(0x17, 0xC8) # Set ADC digital volume
except Exception as e:
print("Failed to set mic gain:", e)
# 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=16000,
rate=16000,
bits=16,
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(2048)
mono_buf = bytearray(1024)
rec_start_time = time.ticks_ms()
max_rec_duration_ms = 10000 # 10 seconds max duration
try:
# 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
bytes_read = i2s_rx.readinto(buffer)
if bytes_read > 0:
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 during 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.")
try:
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"}))
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 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...")
on_val = 0 if board_config.audio_amp_active_level == 0 else 1
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)
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=16000,
bits=16,
format=i2s_format)
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:
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)
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:
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 WS query failed:", he)
draw_status_bar("Connection Error")
time.sleep(2)
finally:
try:
ws.close()
except:
pass
# Deinit MCLK PWM
if mclk_pwm:
try:
mclk_pwm.deinit()
except:
pass
# Debounce release at the very end of wizard
clear_status_bar()
release_end = time.ticks_ms()
while is_talk_trigger_active():
if time.ticks_diff(time.ticks_ms(), release_end) > 2000:
break
time.sleep_ms(30)
time.sleep_ms(200)
voice_assistant_active = False
last_action_str = "Voice query finished"
force_dashboard_redraw = True
# A. Handle non-blocking MCP client connection updates
mcp.update()
# B. Check if custom draw text override has expired
if mcp.override_active and time.ticks_diff(now, mcp.override_timeout) > 0:
print("MCP Screen override expired. Returning to status dashboard...")
mcp.override_active = False
force_dashboard_redraw = True
# C. Draw local dashboard (if not overridden by MCP draw text commands)
if not mcp.override_active:
# B. Handle non-blocking background video stream updates
vstream.update()
# C. Draw local dashboard (if not overridden by MCP draw text commands or active video stream)
if not mcp.override_active and not vstream.active:
if force_dashboard_redraw or time.ticks_diff(now, last_dashboard_update) >= dashboard_update_interval_ms:
force_dashboard_redraw = False
last_dashboard_update = now
# Fetch sensor data
t, h = sensor.read_sensor()
t, h = sensor.read_sensor() if sensor else (None, None)
t_str = f"{t} C" if t is not None else "Error"
h_str = f"{h} %" if h is not None else "Error"
@@ -126,45 +639,70 @@ def main():
bat_str = f"{bat_v:.2f}V ({bat_p}%)" if bat_v is not None else "Error"
# Fetch current time
dt = rtc_chip.get_datetime()
dt = rtc_chip.get_datetime() if rtc_chip else None
time_str = f"{dt[0]:04d}-{dt[1]:02d}-{dt[2]:02d} {dt[4]:02d}:{dt[5]:02d}:{dt[6]:02d}" if dt else "RTC Error"
# Draw standard status dashboard layout
display.clear(0)
display.text("ESP32-S3-RLCD MCP SERVER", 10, 10, 1)
display.line(10, 20, 390, 20, 1)
line_w = display.width - 10
display.text_large("ENVIRONMENT", 15, 30, scale=2, c=1)
display.text(f"Temp : {t_str}", 25, 55, 1)
display.text(f"Humid : {h_str}", 25, 70, 1)
display.line(10, 95, 390, 95, 1)
display.text_large("MCP NET CONNECTION", 15, 105, scale=2, c=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, 390, 185, 1)
display.text_large("SYSTEM STATUS", 15, 195, scale=2, c=1)
display.text(f"Battery : {bat_str}", 25, 220, 1)
display.text(f"Time : {time_str}", 25, 235, 1)
display.line(10, 255, 390, 255, 1)
display.text(f"Status: {last_action_str}", 15, 265, 1)
if board_config.BOARD_TYPE == 'WAVESHARE_RLCD' or sys.platform == 'rp2':
title_text = "RP2350-TFT MCP SERVER" if sys.platform == 'rp2' else "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, c=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, c=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 : {ble_name}", 25, 160, 1)
display.line(10, 185, line_w, 185, 1)
display.text_large("SYSTEM STATUS", 15, 195, scale=2, c=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 (System & Environment)
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 (Network)
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(f"BLE : {ble_name[-6:]}", 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()
# D. Update NeoPixel animation smoothly (runs every 50ms)
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()
if time.ticks_diff(now, last_led_update) >= 50:
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()
time.sleep_ms(50)
# Poll rapidly if stream is active, otherwise sleep 50ms to save power
if vstream.active:
time.sleep_ms(2)
else:
time.sleep_ms(50)
if __name__ == "__main__":
main()
+182 -2
View File
@@ -4,13 +4,125 @@ import json
import urllib.request
import argparse
def discover_screen():
import socket
sys.stderr.write("Searching for ESP32 Screen via UDP broadcast on port 5000...\n")
sys.stderr.flush()
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_BROADCAST, 1)
s.settimeout(1.5)
broadcast_ips = ['255.255.255.255', '<broadcast>']
try:
hostname = socket.gethostname()
ip_list = socket.gethostbyname_ex(hostname)[2]
for ip in ip_list:
if not ip.startswith('127.'):
parts = ip.split('.')
if len(parts) == 4:
subnet_bcast = f"{parts[0]}.{parts[1]}.{parts[2]}.255"
if subnet_bcast not in broadcast_ips:
broadcast_ips.append(subnet_bcast)
except:
pass
for target_ip in broadcast_ips:
try:
s.sendto(b"DISCOVER_SCREEN", (target_ip, 5000))
except:
pass
try:
data, addr = s.recvfrom(128)
if data == b"SCREEN_IP_80":
sys.stderr.write(f"Auto-discovered ESP32 Screen at IP: {addr[0]} via UDP\n")
sys.stderr.flush()
return addr[0]
except:
pass
finally:
s.close()
return None
def scan_subnet():
import socket
import concurrent.futures
try:
hostname = socket.gethostname()
local_ips = [ip for ip in socket.gethostbyname_ex(hostname)[2] if not ip.startswith('127.')]
if not local_ips:
return None
local_ip = local_ips[0]
except Exception as e:
sys.stderr.write(f"Subnet lookup error: {e}\n")
sys.stderr.flush()
return None
parts = local_ip.split('.')
if len(parts) == 4:
base_ip = f"{parts[0]}.{parts[1]}.{parts[2]}."
else:
return None
sys.stderr.write(f"Scanning local subnet {base_ip}1 to {base_ip}254 on port 80...\n")
sys.stderr.flush()
def check_ip(ip_suffix):
target_ip = f"{base_ip}{ip_suffix}"
url = f"http://{target_ip}:80/api/mcp"
payload = {
"jsonrpc": "2.0",
"id": 1,
"method": "tools/list"
}
req_data = json.dumps(payload).encode('utf-8')
req = urllib.request.Request(
url,
data=req_data,
headers={"Content-Type": "application/json"},
method="POST"
)
try:
with urllib.request.urlopen(req, timeout=2.0) as response:
resp_data = json.loads(response.read().decode('utf-8'))
tools = resp_data.get("result", {}).get("tools", [])
if any(t.get("name") == "clear_screen" for t in tools):
return target_ip
except:
pass
return None
with concurrent.futures.ThreadPoolExecutor(max_workers=50) as executor:
futures = [executor.submit(check_ip, i) for i in range(1, 255)]
for fut in concurrent.futures.as_completed(futures):
res = fut.result()
if res:
executor.shutdown(wait=False)
sys.stderr.write(f"Auto-discovered ESP32 Screen at IP: {res} via subnet scan\n")
sys.stderr.flush()
return res
return None
def main():
parser = argparse.ArgumentParser(description="MCP Stdio-to-HTTP Bridge for ESP32-S3-RLCD-4.2")
parser.add_argument("--ip", required=True, help="IP address of the ESP32 board (e.g. 192.168.1.123)")
parser.add_argument("--ip", help="IP address of the ESP32 board. If omitted, auto-discovery is performed.")
parser.add_argument("--port", type=int, default=80, help="Port the MCP server is listening on (default 80)")
args = parser.parse_args()
url = f"http://{args.ip}:{args.port}/api/mcp"
ip = args.ip
if not ip:
ip = discover_screen()
if not ip:
sys.stderr.write("UDP broadcast discovery timed out. Initializing subnet sweep...\n")
sys.stderr.flush()
ip = scan_subnet()
if not ip:
sys.stderr.write("Subnet sweep failed. Falling back to hostname: 'esp32screen.local'\n")
sys.stderr.flush()
ip = "esp32screen.local"
url = f"http://{ip}:{args.port}/api/mcp"
sys.stderr.write(f"ESP32 MCP Stdio-to-HTTP Bridge started. Routing stdio to {url}\n")
sys.stderr.flush()
@@ -24,6 +136,74 @@ def main():
# Parse request to ensure it's valid JSON
req_data = json.loads(line.strip())
# Intercept draw_image tool and pre-process the image on the host PC
if req_data.get("method") == "tools/call" and req_data.get("params", {}).get("name") == "draw_image":
try:
args = req_data.get("params", {}).get("arguments", {})
image_base64 = args.get("image_base64")
if not image_base64:
raise ValueError("Missing image_base64 parameter")
# Strip data URI prefix if present
if image_base64.startswith("data:"):
if ";base64," in image_base64:
image_base64 = image_base64.split(";base64,")[1]
import base64
import io
from PIL import Image
img_bytes = base64.b64decode(image_base64)
img = Image.open(io.BytesIO(img_bytes))
# Convert to grayscale
img = img.convert("L")
# Resize to fit screen dimensions (400x300 max)
max_w = int(args.get("maxWidth", 400))
max_h = int(args.get("maxHeight", 300))
# Prevent going over physical screen bounds
max_w = min(max_w, 400)
max_h = min(max_h, 300)
img.thumbnail((max_w, max_h))
# Convert to 1-bit monochrome (with optional Floyd-Steinberg dithering)
dither = args.get("dither", True)
img_1bit = img.convert("1", dither=Image.FLOYDSTEINBERG if dither else Image.NONE)
# Save as binary PBM (P4 format) using PPM format writer on mode 1
pbm_io = io.BytesIO()
img_1bit.save(pbm_io, format="PPM")
pbm_data = pbm_io.getvalue()
# Encode processed PBM to base64
pbm_b64 = base64.b64encode(pbm_data).decode("utf-8")
# Substitute arguments for ESP32
new_args = {
"pbm_base64": pbm_b64,
"x": args.get("x", 0),
"y": args.get("y", 0)
}
req_data["params"]["arguments"] = new_args
except Exception as e:
# Return error response directly to client without contacting ESP32
err_resp = {
"jsonrpc": "2.0",
"error": {
"code": -32602,
"message": f"Bridge image preprocessing failed: {str(e)}"
},
"id": req_data.get("id")
}
sys.stdout.write(json.dumps(err_resp) + "\n")
sys.stdout.flush()
continue
# Forward the JSON-RPC request to the ESP32 board via HTTP POST
req = urllib.request.Request(
url,
+694 -29
View File
@@ -5,21 +5,23 @@ import time
class MCPServer:
"""A lightweight JSON-RPC HTTP server implementing Model Context Protocol (MCP) endpoints."""
def __init__(self, display, led, battery, sensor, rtc, ble):
def __init__(self, display, led, battery, sensor, rtc, ble, vstream=None, touch=None):
self.display = display
self.led = led
self.battery = battery
self.sensor = sensor
self.rtc = rtc
self.ble = ble
self.vstream = vstream
self.touch = touch
self.sock = None
self.active_led_mode = "off" # static, breath, rainbow, off
self.override_active = False
self.override_timeout = 0
def start(self, port=80):
"""Starts the TCP server non-blockingly."""
self.port = port
self.sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
self.sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.sock.bind(('', port))
@@ -27,26 +29,92 @@ class MCPServer:
# Set socket to non-blocking so the main loop can run animations concurrently
self.sock.setblocking(False)
print(f"MCP server listening on port {port}...")
# Setup UDP socket for auto-discovery on port 5000
try:
self.udp_sock = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
self.udp_sock.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
self.udp_sock.bind(('', 5000))
self.udp_sock.setblocking(False)
print("UDP Discovery responder listening on port 5000...")
except Exception as ue:
print("Failed to bind UDP Discovery socket: {}".format(ue))
self.udp_sock = None
def restart(self):
"""Re-initializes the listening sockets after a fatal error."""
print("Restarting MCP Server sockets...")
try:
if self.sock:
self.sock.close()
except:
pass
self.sock = None
try:
if hasattr(self, 'udp_sock') and self.udp_sock:
self.udp_sock.close()
except:
pass
self.udp_sock = None
# Add a short delay to allow socket reuse to settle
time.sleep_ms(100)
port = getattr(self, 'port', 80)
self.start(port)
def update(self):
"""Check for incoming HTTP requests and handle them non-blockingly."""
# 1. Handle UDP Discovery queries
if hasattr(self, 'udp_sock') and self.udp_sock is not None:
try:
data, addr = self.udp_sock.recvfrom(128)
if data == b"DISCOVER_SCREEN":
self.udp_sock.sendto(b"SCREEN_IP_80", addr)
except OSError:
pass
# 2. Check for incoming HTTP TCP connections
if self.sock is None:
return
t0 = 0
t1 = 0
try:
client, addr = self.sock.accept()
except OSError:
# No incoming connection
t0 = time.ticks_ms()
except OSError as e:
import errno
err = getattr(e, 'errno', None)
if err is None and e.args:
err = e.args[0]
ewouldblock = getattr(errno, 'EWOULDBLOCK', errno.EAGAIN)
if err in (errno.EAGAIN, ewouldblock) or err is None:
# No incoming connection
return
# Fatal socket error
print(f"Fatal socket error in MCP Server accept(): {e}. Re-initializing...")
self.restart()
return
try:
# Handle incoming connection
client.settimeout(2.0)
req = client.recv(2048).decode('utf-8')
# Simple HTTP parser
lines = req.split('\r\n')
if len(lines) == 0:
req_bytes = client.recv(2048)
if not req_bytes:
client.close()
return
header_end = req_bytes.find(b'\r\n\r\n')
if header_end == -1:
header_end = len(req_bytes)
body_start_idx = len(req_bytes)
else:
body_start_idx = header_end + 4
header_text = req_bytes[:header_end].decode('utf-8', 'ignore')
lines = header_text.split('\r\n')
t1 = time.ticks_ms()
if len(lines) == 0 or not lines[0]:
client.close()
return
@@ -66,31 +134,98 @@ 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)
# Send HTTP Response
resp_body = json.dumps(rpc_resp)
resp = "HTTP/1.1 200 OK\r\n"
resp += "Content-Type: application/json\r\n"
resp += f"Content-Length: {len(resp_body)}\r\n"
resp += "Connection: close\r\n\r\n"
resp += resp_body
client.send(resp.encode('utf-8'))
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
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:
@@ -101,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",
@@ -108,7 +247,7 @@ class MCPServer:
"tools": [
{
"name": "clear_screen",
"description": "Clear the 400x300 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": {
@@ -119,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-390)"},
"y": {"type": "integer", "description": "Y coordinate (0-290)"},
"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"]
@@ -150,6 +289,11 @@ class MCPServer:
"description": "Read the current battery voltage and estimated capacity percentage.",
"inputSchema": {"type": "object", "properties": {}}
},
{
"name": "get_touch",
"description": "Read the current touch state and coordinates from the capacitive touchscreen.",
"inputSchema": {"type": "object", "properties": {}}
},
{
"name": "get_sensors",
"description": "Read onboard SHTC3 temperature and relative humidity.",
@@ -167,8 +311,191 @@ 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": f"Draw an image (PNG, JPEG, GIF, BMP, etc.) on the screen. The image will be converted to 1-bit monochrome and fit to display boundaries (max {width}x{height}).",
"inputSchema": {
"type": "object",
"properties": {
"image_base64": {"type": "string", "description": "Base64 encoded string of the source image file"},
"x": {"type": "integer", "description": "X coordinate to place the image (default 0)", "default": 0},
"y": {"type": "integer", "description": "Y coordinate to place the image (default 0)", "default": 0},
"dither": {"type": "boolean", "description": "Whether to use Floyd-Steinberg dithering (default true)", "default": True}
},
"required": ["image_base64"]
}
},
{
"name": "draw_color_bmp",
"description": f"Draw a color BMP image on the {width}x{height} color screen at specified (x,y) coordinates.",
"inputSchema": {
"type": "object",
"properties": {
"bmp_base64": {"type": "string", "description": "Base64 encoded BMP image file (uncompressed 24-bit or 32-bit format)"},
"x": {"type": "integer", "description": "X coordinate to place the image (default 0)", "default": 0},
"y": {"type": "integer", "description": "Y coordinate to place the image (default 0)", "default": 0}
},
"required": ["bmp_base64"]
}
},
{
"name": "get_capabilities",
"description": "Get screen capabilities (resolution, color support, and supported formats).",
"inputSchema": {"type": "object", "properties": {}}
},
{
"name": "sync_time",
"description": "Synchronize the hardware and system clock with an internet NTP server.",
"inputSchema": {"type": "object", "properties": {}}
},
{
"name": "draw_raw_rgb565",
"description": f"Draw raw RGB565 pixel data on the {width}x{height} screen at specified (x,y) coordinates with width (w) and height (h).",
"inputSchema": {
"type": "object",
"properties": {
"rgb565_base64": {"type": "string", "description": "Base64 encoded raw RGB565 pixel data (Big-Endian, 2 bytes per pixel)"},
"x": {"type": "integer", "description": "X coordinate to place the image"},
"y": {"type": "integer", "description": "Y coordinate to place the image"},
"w": {"type": "integer", "description": "Width of the raw pixel block"},
"h": {"type": "integer", "description": "Height of the raw pixel block"}
},
"required": ["rgb565_base64", "x", "y", "w", "h"]
}
},
{
"name": "write_file",
"description": "Write a file (e.g., python script or config) to the board's flash storage.",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The destination file path (e.g. 'my_script.py')"},
"content": {"type": "string", "description": "The text content of the file"}
},
"required": ["path", "content"]
}
},
{
"name": "read_file",
"description": "Read a text file from the board's flash storage.",
"inputSchema": {
"type": "object",
"properties": {
"path": {"type": "string", "description": "The file path to read (e.g. 'boot.py')"}
},
"required": ["path"]
}
},
{
"name": "execute_python",
"description": "Execute arbitrary Python code dynamically on the board. Standard output (prints) and errors will be captured and returned.",
"inputSchema": {
"type": "object",
"properties": {
"code": {"type": "string", "description": "The Python code to execute"}
},
"required": ["code"]
}
},
{
"name": "play_tone",
"description": "Play a pure sine wave tone/beep on the board speaker.",
"inputSchema": {
"type": "object",
"properties": {
"frequency": {"type": "integer", "description": "Frequency of the tone in Hz (e.g. 440 for A4, default 440)", "default": 440},
"duration_ms": {"type": "integer", "description": "Duration of the tone in milliseconds (default 1000)", "default": 1000},
"volume": {"type": "integer", "description": "Volume of the tone from 0 to 100 (default 50)", "default": 50}
}
}
},
{
"name": "play_audio",
"description": "Play a standard WAV audio file on the speaker.",
"inputSchema": {
"type": "object",
"properties": {
"filename": {"type": "string", "description": "WAV file path to play (e.g. 'response.wav')"},
"volume": {"type": "integer", "description": "Volume from 0 to 100 (default 50)", "default": 50}
},
"required": ["filename"]
}
},
{
"name": "record_voice",
"description": "Record voice command from the microphone to a raw stereo PCM file.",
"inputSchema": {
"type": "object",
"properties": {
"duration_sec": {"type": "integer", "description": "Duration in seconds (default 4)", "default": 4},
"filename": {"type": "string", "description": "Destination file path (e.g. 'recording.pcm')", "default": "recording.pcm"}
}
}
},
{
"name": "play_audio_base64",
"description": "Decode a base64-encoded WAV file and play it directly on the speaker.",
"inputSchema": {
"type": "object",
"properties": {
"wav_base64": {"type": "string", "description": "Base64 encoded string of the WAV audio file"},
"volume": {"type": "integer", "description": "Volume from 0 to 100 (default 50)", "default": 50}
},
"required": ["wav_base64"]
}
},
{
"name": "download_file",
"description": "Download a file from a URL over Wi-Fi directly to the board storage or microSD card.",
"inputSchema": {
"type": "object",
"properties": {
"url": {"type": "string", "description": "The URL of the file to download"},
"filename": {"type": "string", "description": "The destination filename (e.g. 'podcast1.wav')"},
"use_sd": {"type": "boolean", "description": "Save to microSD card if true, or local flash if false (default true)", "default": True}
},
"required": ["url", "filename"]
}
},
{
"name": "get_video_streaming_instructions",
"description": "Get detailed instructions and sample Python code to stream video directly into the RLCD screen using TCP or UDP.",
"inputSchema": {
"type": "object",
"properties": {
"protocol": {"type": "string", "enum": ["tcp", "udp", "both"], "description": "The streaming protocol to query (default 'both')"}
}
}
},
{
"name": "get_stream_stats",
"description": "Get real-time device-side performance and frame-rate statistics for the TCP/UDP video stream.",
"inputSchema": {"type": "object", "properties": {}}
},
{
"name": "set_backlight",
"description": "Adjust the brightness of the LCD backlight.",
"inputSchema": {
"type": "object",
"properties": {
"brightness": {"type": "integer", "minimum": 0, "maximum": 100, "description": "Backlight brightness percentage (0-100)"}
},
"required": ["brightness"]
}
},
{
"name": "set_screen_power",
"description": "Turn the screen/display on or off.",
"inputSchema": {
"type": "object",
"properties": {
"power": {"type": "boolean", "description": "True to turn display ON, False to turn display OFF"}
},
"required": ["power"]
}
}
]
},
@@ -216,15 +543,29 @@ class MCPServer:
"""Executes hardware actions depending on the called tool name."""
if name == "clear_screen":
self.override_active = True
self.override_timeout = time.ticks_ms() + 30000 # 30-sec override
color = int(args.get("color", 0))
self.display.clear(color)
self.display.show()
return "Screen cleared."
elif name == "set_backlight":
brightness = int(args.get("brightness", 100))
if hasattr(self.display, "set_brightness"):
self.display.set_brightness(brightness)
return f"Backlight brightness set to {brightness}%."
else:
return "Backlight brightness control not supported on this display."
elif name == "set_screen_power":
power = bool(args.get("power", True))
if hasattr(self.display, "set_power"):
self.display.set_power(power)
status = "ON" if power else "OFF"
return f"Screen power set to {status}."
else:
return "Screen power control not supported on this display."
elif name == "draw_text":
self.override_active = True
self.override_timeout = time.ticks_ms() + 30000 # 30-sec override
text = str(args.get("text", ""))
x = int(args.get("x", 10))
y = int(args.get("y", 10))
@@ -262,6 +603,17 @@ class MCPServer:
t, h = self.sensor.read_sensor()
return json.dumps({"temperature_c": t, "humidity_pct": h})
elif name == "get_touch":
if self.touch is None:
return json.dumps({"error": "Touchscreen not configured on this device."})
is_t = self.touch.is_touched()
x, y = None, None
if is_t:
pt = self.touch.read_touch()
if pt:
x, y = pt
return json.dumps({"is_touched": is_t, "x": x, "y": y})
elif name == "scan_ble":
dur = int(args.get("duration_ms", 3000))
# Scan returns dictionary: {mac: {rssi: rssi, name: name}}
@@ -280,6 +632,319 @@ class MCPServer:
return f"__PBM_BASE64__:{b64}"
except Exception as e:
raise RuntimeError(f"Screenshot capture failed: {e}")
elif name == "draw_image":
self.override_active = True
pbm_b64 = args.get("pbm_base64")
x = int(args.get("x", 0))
y = int(args.get("y", 0))
if not pbm_b64:
raise ValueError("Missing pbm_base64 parameter (preprocessed by bridge)")
import binascii
pbm_bytes = binascii.a2b_base64(pbm_b64)
filename = 'temp_recv.pbm'
with open(filename, 'wb') as f:
f.write(pbm_bytes)
try:
self.display.draw_pbm(filename, x, y, scale=1)
self.display.show()
finally:
import os
try:
os.remove(filename)
except:
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"))
# Auto-create parent directories on the device if present in the path
import os
parts = path.split('/')
if len(parts) > 1:
dir_path = ""
for part in parts[:-1]:
if part:
dir_path = dir_path + "/" + part if dir_path else part
try:
os.mkdir(dir_path)
except OSError:
pass # Directory likely already exists
with open(path, 'w') as f:
f.write(content)
return f"Successfully wrote {len(content)} characters to '{path}'."
elif name == "read_file":
path = str(args.get("path"))
with open(path, 'r') as f:
content = f.read()
return content
elif name == "execute_python":
code = str(args.get("code"))
import builtins
import sys
import io
output_buffer = []
old_print = builtins.print
def custom_print(*args, **kwargs):
sep = kwargs.get('sep', ' ')
end = kwargs.get('end', '\n')
str_args = [str(arg) for arg in args]
msg = sep.join(str_args) + end
output_buffer.append(msg)
builtins.print = custom_print
error = None
try:
exec(code, globals())
except Exception as e:
tb_file = io.StringIO()
sys.print_exception(e, tb_file)
error_msg = tb_file.getvalue()
output_buffer.append(f"\nExecution Failed:\n{error_msg}")
error = e
finally:
builtins.print = old_print
output = "".join(output_buffer)
if error:
return output
return f"Execution Succeeded. Console output:\n{output}"
elif name == "play_tone":
freq = int(args.get("frequency", 440))
duration = int(args.get("duration_ms", 1000))
vol = int(args.get("volume", 50))
# Limit duration to prevent blocking the main loop for too long
duration = max(50, min(5000, duration))
freq = max(50, min(10000, freq))
vol = max(0, min(100, vol))
from audio_util import play_tone
play_tone(frequency=freq, duration_ms=duration, volume=vol)
return f"Played tone of {freq}Hz for {duration}ms at volume {vol}."
elif name == "play_audio":
filename = str(args.get("filename"))
vol = int(args.get("volume", 50))
vol = max(0, min(100, vol))
from audio_util import play_wav
success = play_wav(filename, volume=vol)
if success:
return f"Successfully played audio file '{filename}'."
else:
raise RuntimeError(f"Failed to play audio file '{filename}'. Check format (16kHz 16-bit PCM WAV recommended).")
elif name == "record_voice":
duration = int(args.get("duration_sec", 10))
filename = str(args.get("filename", "recording.pcm"))
# Clamp duration to a reasonable range
duration = max(1, min(15, duration))
from audio_util import record_audio
success = record_audio(duration_seconds=duration, filename=filename)
if success:
return f"Successfully recorded {duration} seconds of audio to '{filename}'."
else:
raise RuntimeError("Audio recording failed.")
elif name == "play_audio_base64":
b64_data = args.get("wav_base64")
vol = int(args.get("volume", 50))
vol = max(0, min(100, vol))
if not b64_data:
raise ValueError("Missing wav_base64 parameter.")
import binascii
try:
audio_bytes = binascii.a2b_base64(b64_data)
except Exception as e:
raise ValueError(f"Failed to decode base64 audio: {e}")
filename = "temp_play.wav"
with open(filename, "wb") as f:
f.write(audio_bytes)
try:
from audio_util import play_wav
success = play_wav(filename, volume=vol)
finally:
import os
try:
os.remove(filename)
except:
pass
if success:
return "Successfully played base64 audio stream."
else:
raise RuntimeError("Failed to play decoded audio stream. Check format (16kHz 16-bit PCM WAV recommended).")
elif name == "download_file":
url = str(args.get("url"))
filename = str(args.get("filename"))
use_sd = bool(args.get("use_sd", True))
from download_util import download_file
saved_path = download_file(url, filename, use_sd=use_sd)
if saved_path:
return f"Successfully downloaded file to '{saved_path}'."
else:
raise RuntimeError(f"Failed to download file from '{url}'.")
elif name == "get_video_streaming_instructions":
protocol = str(args.get("protocol", "both")).lower()
instructions = [
"### RP2350 TFT Video Streaming Instructions",
"Dimensions: 400x300 (decoded and centered automatically on the 480x320 screen), 1-bit monochrome (Floyd-Steinberg dithered).",
"Frame Buffer Size: 15,000 bytes. The host must convert and map standard pixels into the specific RLCD hardware buffer layout before sending.",
"Mapping logic (Python):",
" def map_to_rlcd(pil_img):",
" img_1bit = pil_img.convert('1', dither=1)",
" px = img_1bit.load()",
" buf = bytearray(15000)",
" for y in range(300):",
" for x in range(400):",
" if px[x, y]:",
" inv_y = 299 - y",
" bx = x // 2",
" by = inv_y // 4",
" idx = bx * 75 + by",
" lx, ly = x % 2, inv_y % 4",
" bit = 7 - (ly * 2 + lx)",
" buf[idx] |= (1 << bit)",
" return buf"
]
if protocol in ("tcp", "both"):
instructions.append("\n**TCP Streaming (Port 8081):**")
instructions.append("Open a TCP connection to the device's IP on port 8081 and send consecutive 15,000-byte frame blocks.")
if protocol in ("udp", "both"):
instructions.append("\n**UDP Streaming / Broadcast (Port 8082):**")
instructions.append("Split the 15,000-byte frame into 15 chunks of 1,000 bytes each. Send each chunk as a 1002-byte packet: byte 0 = frame_id (0-255), byte 1 = chunk_idx (0-14), bytes 2..1001 = chunk payload. Send to port 8082 (unicast or broadcast).")
return "\n".join(instructions)
elif name == "get_stream_stats":
if self.vstream is None:
return json.dumps({"error": "Video stream server not initialized."})
return json.dumps(self.vstream.get_stats())
else:
raise ValueError(f"Unknown tool: {name}")

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