Compare commits
38 Commits
17017ff6e1
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f871e499e | |||
| 2863f21459 | |||
| ad30662a48 | |||
| 8dbc5f4c7b | |||
| edf3da1a30 | |||
| 2bc8fbeaca | |||
| 6ee92b6ede | |||
| dc9b47714e | |||
| 9c50ad349e | |||
| b88cb34196 | |||
| b2bcdb5b79 | |||
| 30aff5eb8b | |||
| c8853cc5df | |||
| c944d8c48f | |||
| d778391c5c | |||
| 9eca3549c3 | |||
| d0fcc5fc7b | |||
| 5968b4934d | |||
| 4ff86ab415 | |||
| d5f2abf2b8 | |||
| 95ee2fc036 | |||
| c8c2373b83 | |||
| 7d61824723 | |||
| 82de0f328b | |||
| 31363734c5 | |||
| c63712e3aa | |||
| 75475723fa | |||
| 2813c11104 | |||
| 3356e5d4a2 | |||
| 070390355e | |||
| 81e7b15b61 | |||
| 092e241f89 | |||
| 3cd3428e94 | |||
| 612ba63c59 | |||
| f443da0352 | |||
| 8302db03db | |||
| 05df3d3839 | |||
| 0c67ff4e20 |
+28
@@ -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
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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, we’ll 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 ESP32’s 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.
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
Antigravity’s 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.
|
||||
@@ -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()
|
||||
Executable
+29
@@ -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
|
||||
Executable
+74
@@ -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.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -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()
|
||||
|
||||
@@ -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}%)"
|
||||
@@ -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.")
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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.
@@ -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()
|
||||
@@ -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)
|
||||
@@ -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"
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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>
|
||||
Executable
+34
@@ -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"
|
||||
Executable
+17
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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. I’m 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)
|
||||
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -0,0 +1,5 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface IMCAppDelegate : UIResponder <UIApplicationDelegate>
|
||||
@property (nonatomic, strong) UIWindow *window;
|
||||
@end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,8 @@
|
||||
#import <Foundation/Foundation.h>
|
||||
|
||||
@class IMCViewController;
|
||||
|
||||
@interface IMCMCPServer : NSObject
|
||||
- (instancetype)initWithViewController:(IMCViewController *)viewController port:(NSInteger)port;
|
||||
- (void)start;
|
||||
@end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -0,0 +1,5 @@
|
||||
#import <UIKit/UIKit.h>
|
||||
|
||||
@interface IMCViewController : UIViewController
|
||||
- (NSDictionary *)handleRPCRequest:(NSDictionary *)request;
|
||||
@end
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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()
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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}")
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
@@ -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
@@ -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
Reference in New Issue
Block a user