Compare commits
3 Commits
9eca3549c3
...
c8853cc5df
| Author | SHA1 | Date | |
|---|---|---|---|
| c8853cc5df | |||
| c944d8c48f | |||
| d778391c5c |
@@ -14,3 +14,12 @@ __pycache__/
|
||||
# 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
|
||||
|
||||
|
||||
|
||||
@@ -137,3 +137,10 @@ Here is a summary of the MCP tools exposed by the bridge:
|
||||
| `write_file` | `path`, `content` | Writes text file to board flash. |
|
||||
| `read_file` | `path` | Reads text file from board flash. |
|
||||
| `execute_python` | `code` | Executes arbitrary Python code dynamically. |
|
||||
|
||||
---
|
||||
|
||||
## 5. Hardware & Troubleshooting Findings
|
||||
|
||||
For detailed technical findings regarding the board's hardware (e.g. I2S bit-depth configuration, I2C bus lockup recovery, pinout configuration, and ES7210 microphone clock/OSR registers to resolve popping sound issues), refer to [hardware_findings.md](hardware_findings.md).
|
||||
|
||||
|
||||
+39
-34
@@ -2,9 +2,8 @@ import time
|
||||
import machine
|
||||
from machine import Pin, I2S
|
||||
import board_config
|
||||
import audio_util
|
||||
|
||||
def play_raw_pcm(filename, channels=2, rate=16000, bits=16, volume=90):
|
||||
def play_ram_pcm(audio_chunks, channels=2, rate=16000, bits=16, volume=90):
|
||||
from audio_util import ES8311
|
||||
|
||||
# 1. Start MCLK PWM using board config parameters
|
||||
@@ -38,16 +37,11 @@ def play_raw_pcm(filename, channels=2, rate=16000, bits=16, volume=90):
|
||||
amp_pin = Pin(board_config.audio_amp_pin, Pin.OUT, value=on_val)
|
||||
|
||||
try:
|
||||
print(f"Streaming raw audio to speaker from '{filename}'...")
|
||||
with open(filename, 'rb') as f:
|
||||
buf = bytearray(2048)
|
||||
while True:
|
||||
bytes_read = f.readinto(buf)
|
||||
if bytes_read == 0:
|
||||
break
|
||||
i2s.write(buf[:bytes_read])
|
||||
print(f"Streaming raw audio to speaker from RAM ({len(audio_chunks)} chunks)...")
|
||||
for chunk in audio_chunks:
|
||||
i2s.write(chunk)
|
||||
except Exception as e:
|
||||
print("Error during raw playback:", e)
|
||||
print("Error during RAM playback:", e)
|
||||
finally:
|
||||
time.sleep_ms(100) # Let buffer play out
|
||||
amp_pin.value(off_val) # Disable amp
|
||||
@@ -58,10 +52,10 @@ def play_raw_pcm(filename, channels=2, rate=16000, bits=16, volume=90):
|
||||
|
||||
def main():
|
||||
display = board_config.display_instance
|
||||
print("=== Dynamic Audio Loopback Utility Script ===")
|
||||
print("=== Dynamic RAM-based Audio Loopback Script ===")
|
||||
print(f"Board Detected: {board_config.BOARD_TYPE}")
|
||||
|
||||
# Initialize buttons using polling Pins instead of BoardButtons class (to avoid edge-triggered interrupt storms)
|
||||
# Initialize buttons using polling Pins instead of BoardButtons class
|
||||
key_pin = Pin(18, Pin.IN, Pin.PULL_UP)
|
||||
boot_pin = Pin(0, Pin.IN, Pin.PULL_UP)
|
||||
|
||||
@@ -71,8 +65,6 @@ def main():
|
||||
return board_config.touch.is_touched()
|
||||
return key_pin.value() == 0
|
||||
|
||||
filename = "local_audio_test.pcm"
|
||||
|
||||
while True:
|
||||
if display:
|
||||
display.clear(0)
|
||||
@@ -81,13 +73,13 @@ def main():
|
||||
display.line(10, 20, 390, 20, 1)
|
||||
display.text("1. Press KEY button to record 10s", 15, 60, 1)
|
||||
display.text("2. Playback will start automatically", 15, 80, 1)
|
||||
display.text("Ready...", 15, 120, 1)
|
||||
display.text("Ready (RAM-based)...", 15, 120, 1)
|
||||
else:
|
||||
display.text("Touch Audio Loopback Test", 10, 10, 1)
|
||||
display.line(10, 20, 310, 20, 1)
|
||||
display.text("1. Press screen/KEY to record 10s", 10, 50, 1)
|
||||
display.text("2. Playback starts automatically", 10, 70, 1)
|
||||
display.text("Ready...", 10, 100, 1)
|
||||
display.text("Ready (RAM-based)...", 10, 100, 1)
|
||||
display.show()
|
||||
|
||||
print("Ready: Press and hold key/screen to record...")
|
||||
@@ -129,8 +121,6 @@ def main():
|
||||
display.text("Speak now!", 10, 80, 1)
|
||||
display.show()
|
||||
|
||||
# We record as long as the button is pressed (or up to 10 seconds max)
|
||||
|
||||
# 1. Start MCLK PWM using board config parameters
|
||||
mclk_pwm = None
|
||||
if board_config.audio_mclk_pin is not None:
|
||||
@@ -139,26 +129,28 @@ def main():
|
||||
mclk_pwm.freq(board_config.audio_mclk_freq)
|
||||
mclk_pwm.duty_u16(32768)
|
||||
|
||||
# 2. Configure I2S RX (Stereo 16kHz)
|
||||
# 2. Configure I2S RX (Stereo 16kHz) - ibuf set to 16000 for safety
|
||||
i2s_rx = I2S(1,
|
||||
sck=Pin(board_config.audio_i2s_sck),
|
||||
ws=Pin(board_config.audio_i2s_ws),
|
||||
sd=Pin(board_config.audio_i2s_rx_sd),
|
||||
mode=I2S.RX,
|
||||
ibuf=8000,
|
||||
ibuf=16000,
|
||||
rate=16000,
|
||||
bits=16,
|
||||
format=I2S.STEREO)
|
||||
|
||||
# 3. Wake up and configure the microphone chip (ES7210 vs ES8311)
|
||||
init_ok = False
|
||||
if board_config.audio_mic_codec == "ES7210":
|
||||
from audio_util import ES7210
|
||||
mic_adc = ES7210(board_config.i2c_bus)
|
||||
mic_adc.init(sample_rate=16000, bit_width=16)
|
||||
init_ok = mic_adc.init(sample_rate=16000, bit_width=16)
|
||||
else:
|
||||
from audio_util import ES8311
|
||||
mic_adc = ES8311(board_config.i2c_bus)
|
||||
if mic_adc.init(sample_rate=16000):
|
||||
init_ok = True
|
||||
mic_adc.set_volume(80)
|
||||
try:
|
||||
mic_adc._write(0x14, 0x1A) # Enable analog mic input & PGA
|
||||
@@ -166,19 +158,32 @@ def main():
|
||||
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
|
||||
buffer = bytearray(1024)
|
||||
# Record loop to RAM - using ticks_ms for safe timing
|
||||
buffer = bytearray(2048)
|
||||
audio_chunks = []
|
||||
total_bytes = 0
|
||||
start_rec_time = time.time()
|
||||
start_rec_time = time.ticks_ms()
|
||||
max_duration_ms = 10000 # 10 seconds
|
||||
|
||||
try:
|
||||
with open(filename, 'wb') as f:
|
||||
while (time.time() - start_rec_time) < 10:
|
||||
bytes_read = i2s_rx.readinto(buffer)
|
||||
if bytes_read > 0:
|
||||
f.write(buffer[:bytes_read])
|
||||
total_bytes += bytes_read
|
||||
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:
|
||||
@@ -186,12 +191,12 @@ def main():
|
||||
if mclk_pwm:
|
||||
mclk_pwm.deinit()
|
||||
|
||||
print(f"Recorded {total_bytes} bytes to '{filename}'.")
|
||||
print(f"Recorded {total_bytes} bytes in RAM ({len(audio_chunks)} chunks).")
|
||||
|
||||
# Wait for release of key/trigger to debounce
|
||||
time.sleep_ms(200)
|
||||
|
||||
# 4. Playback
|
||||
# 4. Playback from RAM
|
||||
if display:
|
||||
display.clear(0)
|
||||
if board_config.BOARD_TYPE == 'WAVESHARE_RLCD':
|
||||
@@ -206,7 +211,7 @@ def main():
|
||||
display.text(f"Bytes: {total_bytes}", 10, 80, 1)
|
||||
display.show()
|
||||
|
||||
play_raw_pcm(filename, channels=2, rate=16000, bits=16, volume=95)
|
||||
play_ram_pcm(audio_chunks, channels=2, rate=16000, bits=16, volume=95)
|
||||
|
||||
if display:
|
||||
display.clear(0)
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
+88
-37
@@ -13,6 +13,12 @@ class ES7210:
|
||||
def __init__(self, i2c):
|
||||
self.i2c = i2c
|
||||
|
||||
def _write(self, reg, val):
|
||||
self.i2c.writeto_mem(self.ADDR, reg, bytes([val]))
|
||||
|
||||
def _read(self, reg):
|
||||
return self.i2c.readfrom_mem(self.ADDR, reg, 1)[0]
|
||||
|
||||
def init(self, sample_rate=16000, bit_width=16):
|
||||
"""Initializes the ES7210 registers for dual-microphone recording.
|
||||
|
||||
@@ -23,45 +29,85 @@ class ES7210:
|
||||
Returns:
|
||||
bool: True if initialization was successful, False otherwise.
|
||||
"""
|
||||
print("Initializing ES7210 Microphone ADC...")
|
||||
print("Initializing ES7210 Microphone ADC (ESPHome sequence)...")
|
||||
try:
|
||||
# 1. Reset the chip
|
||||
self._write(0x00, 0xFF) # Write all 1s to reset register
|
||||
time.sleep_ms(10)
|
||||
self._write(0x00, 0x00) # Release reset
|
||||
# 1. Software reset
|
||||
self._write(0x00, 0xFF)
|
||||
time.sleep_ms(20)
|
||||
self._write(0x00, 0x32)
|
||||
time.sleep_ms(20)
|
||||
self._write(0x01, 0x3F) # Clock off during config
|
||||
|
||||
# 2. Power management and system configuration
|
||||
self._write(0x01, 0x00) # Enable analog power, reference voltage
|
||||
self._write(0x11, 0x60) # Enable master clock PLL
|
||||
# 2. Timing control
|
||||
self._write(0x09, 0x30)
|
||||
self._write(0x0A, 0x30)
|
||||
|
||||
# 3. Configure Clock Dividers
|
||||
if sample_rate == 16000:
|
||||
self._write(0x02, 0x0C) # BCLK divider
|
||||
self._write(0x03, 0x10) # LRCK divider
|
||||
else: # 44100 / 48000 defaults
|
||||
self._write(0x02, 0x04)
|
||||
self._write(0x03, 0x08)
|
||||
# 3. High-pass filter
|
||||
self._write(0x23, 0x2A)
|
||||
self._write(0x22, 0x0A)
|
||||
self._write(0x20, 0x0A)
|
||||
self._write(0x21, 0x2A)
|
||||
|
||||
# 4. 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)
|
||||
|
||||
# 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
|
||||
# 10. Power down all MIC bias & PGA initially
|
||||
self._write(0x4B, 0xFF)
|
||||
self._write(0x4C, 0xFF)
|
||||
|
||||
# 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)
|
||||
# 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)
|
||||
|
||||
# 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
|
||||
# 12. Power on mics low power registers
|
||||
self._write(0x47, 0x08)
|
||||
self._write(0x48, 0x08)
|
||||
self._write(0x49, 0x08)
|
||||
self._write(0x4A, 0x08)
|
||||
|
||||
# 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
|
||||
# 13. Power down DLL
|
||||
self._write(0x06, 0x04)
|
||||
|
||||
# 14. Enable device state machine
|
||||
self._write(0x00, 0x71)
|
||||
time.sleep_ms(20)
|
||||
self._write(0x00, 0x41)
|
||||
time.sleep_ms(100)
|
||||
|
||||
print("ES7210 initialization complete.")
|
||||
return True
|
||||
@@ -69,9 +115,6 @@ class ES7210:
|
||||
print(f"Failed to initialize ES7210: {e}")
|
||||
return False
|
||||
|
||||
def _write(self, reg, val):
|
||||
self.i2c.writeto_mem(self.ADDR, reg, bytes([val]))
|
||||
|
||||
|
||||
def record_audio(duration_seconds=10, filename='recording.pcm'):
|
||||
"""Records raw stereo PCM data from the dual microphones to a file.
|
||||
@@ -120,6 +163,7 @@ def record_audio(duration_seconds=10, filename='recording.pcm'):
|
||||
|
||||
# Create reading buffer (reads 100ms chunks: 16000 samples/sec * 2 channels * 2 bytes/sample * 0.1s = 6400 bytes)
|
||||
buffer = bytearray(6400)
|
||||
mono_buf = bytearray(3200) # Half size for mono extraction
|
||||
|
||||
start_time = time.time()
|
||||
total_bytes = 0
|
||||
@@ -130,8 +174,15 @@ def record_audio(duration_seconds=10, filename='recording.pcm'):
|
||||
# Read raw stereo PCM data from I2S
|
||||
bytes_read = i2s.readinto(buffer)
|
||||
if bytes_read > 0:
|
||||
f.write(buffer[:bytes_read])
|
||||
total_bytes += bytes_read
|
||||
# Stereo-to-mono: extract left channel (every other 16-bit sample)
|
||||
mono_len = bytes_read // 2
|
||||
j = 0
|
||||
for i in range(0, bytes_read, 4):
|
||||
mono_buf[j] = buffer[i]
|
||||
mono_buf[j + 1] = buffer[i + 1]
|
||||
j += 2
|
||||
f.write(mono_buf[:mono_len])
|
||||
total_bytes += mono_len
|
||||
|
||||
print(f"Recording saved successfully to '{filename}' ({total_bytes} bytes).")
|
||||
return True
|
||||
|
||||
@@ -34,6 +34,26 @@ audio_amp_pin = None
|
||||
audio_amp_active_level = 0 # 0 = Active Low, 1 = Active High
|
||||
audio_mic_codec = "ES8311" # "ES7210" or "ES8311"
|
||||
|
||||
def _i2c_recovery(sda_pin, scl_pin):
|
||||
import time
|
||||
scl = Pin(scl_pin, Pin.OUT)
|
||||
sda = Pin(sda_pin, Pin.OUT)
|
||||
scl.value(1)
|
||||
sda.value(1)
|
||||
time.sleep_ms(1)
|
||||
for _ in range(9):
|
||||
scl.value(0)
|
||||
time.sleep_ms(1)
|
||||
scl.value(1)
|
||||
time.sleep_ms(1)
|
||||
scl.value(0)
|
||||
sda.value(0)
|
||||
time.sleep_ms(1)
|
||||
scl.value(1)
|
||||
time.sleep_ms(1)
|
||||
sda.value(1)
|
||||
time.sleep_ms(1)
|
||||
|
||||
def detect_board():
|
||||
global BOARD_TYPE, DISPLAY_TYPE, DISPLAY_WIDTH, DISPLAY_HEIGHT
|
||||
global spi_bus, i2c_bus, display_instance, touch
|
||||
@@ -60,6 +80,7 @@ def detect_board():
|
||||
|
||||
# Setup Touch: FT6336U on I2C(1)
|
||||
try:
|
||||
_i2c_recovery(2, 3)
|
||||
touch_i2c = I2C(1, sda=Pin(2), scl=Pin(3), freq=400000)
|
||||
from ft6336u import FT6336U
|
||||
touch = FT6336U(touch_i2c, rst_pin=28, int_pin=25)
|
||||
@@ -73,6 +94,7 @@ def detect_board():
|
||||
|
||||
# Try scanning SDA=16, SCL=15 (Hosyond pins)
|
||||
try:
|
||||
_i2c_recovery(16, 15)
|
||||
test_i2c = SoftI2C(sda=Pin(16), scl=Pin(15))
|
||||
devices = test_i2c.scan()
|
||||
if 0x38 in devices:
|
||||
@@ -121,6 +143,7 @@ def detect_board():
|
||||
|
||||
# 3. Try scanning SDA=13, SCL=14 (Waveshare RLCD pins)
|
||||
try:
|
||||
_i2c_recovery(13, 14)
|
||||
test_i2c = SoftI2C(sda=Pin(13), scl=Pin(14))
|
||||
devices = test_i2c.scan()
|
||||
if 0x70 in devices or 0x51 in devices:
|
||||
|
||||
+179
@@ -2,6 +2,7 @@ import time
|
||||
from machine import Pin, SPI
|
||||
import framebuf
|
||||
import micropython
|
||||
import struct
|
||||
|
||||
class ILI9341:
|
||||
def __init__(self, spi, cs, dc, rst=None, bl=None, width=320, height=240, invert_color=True):
|
||||
@@ -89,6 +90,184 @@ class ILI9341:
|
||||
except OSError:
|
||||
print(f"Error: Could not open {filename}")
|
||||
|
||||
@micropython.native
|
||||
def _convert_bgr24_to_rgb565(self, bgr_buf, rgb565_buf, width, src_offset, num_pixels):
|
||||
idx = 0
|
||||
for i in range(src_offset, src_offset + num_pixels):
|
||||
b = bgr_buf[i * 3]
|
||||
g = bgr_buf[i * 3 + 1]
|
||||
r = bgr_buf[i * 3 + 2]
|
||||
r_5 = r >> 3
|
||||
g_6 = g >> 2
|
||||
b_5 = b >> 3
|
||||
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
|
||||
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
|
||||
idx += 2
|
||||
|
||||
@micropython.native
|
||||
def _convert_bgra32_to_rgb565(self, bgra_buf, rgb565_buf, width, src_offset, num_pixels):
|
||||
idx = 0
|
||||
for i in range(src_offset, src_offset + num_pixels):
|
||||
b = bgra_buf[i * 4]
|
||||
g = bgra_buf[i * 4 + 1]
|
||||
r = bgra_buf[i * 4 + 2]
|
||||
r_5 = r >> 3
|
||||
g_6 = g >> 2
|
||||
b_5 = b >> 3
|
||||
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
|
||||
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
|
||||
idx += 2
|
||||
|
||||
def draw_bmp(self, filename, x=0, y=0):
|
||||
"""Draw a 24-bit or 32-bit uncompressed color BMP image at (x, y) coordinates."""
|
||||
try:
|
||||
with open(filename, 'rb') as f:
|
||||
header = f.read(54)
|
||||
if len(header) < 54 or header[0:2] != b'BM':
|
||||
print("Err: Not a valid BMP file")
|
||||
return False
|
||||
|
||||
pixel_offset = struct.unpack('<I', header[10:14])[0]
|
||||
width, height = struct.unpack('<ii', header[18:26])
|
||||
planes, bpp = struct.unpack('<HH', header[26:30])
|
||||
compression = struct.unpack('<I', header[30:34])[0]
|
||||
|
||||
if bpp not in (24, 32):
|
||||
print("Err: Only 24-bit and 32-bit BMP formats supported")
|
||||
return False
|
||||
|
||||
if compression != 0:
|
||||
print("Err: Only uncompressed BMP supported")
|
||||
return False
|
||||
|
||||
f.seek(pixel_offset)
|
||||
bottom_up = True
|
||||
if height < 0:
|
||||
height = -height
|
||||
bottom_up = False
|
||||
|
||||
row_bytes = (width * bpp) // 8
|
||||
row_padded = ((width * bpp + 31) // 32) * 4
|
||||
|
||||
read_buf = bytearray(row_padded)
|
||||
rgb565_buf = bytearray(width * 2)
|
||||
|
||||
for row_idx in range(height):
|
||||
n = f.readinto(read_buf)
|
||||
if n < row_padded:
|
||||
break
|
||||
|
||||
screen_y = y + (height - 1 - row_idx) if bottom_up else y + row_idx
|
||||
if screen_y < 0 or screen_y >= self.height:
|
||||
continue
|
||||
|
||||
x_start = x
|
||||
x_end = x + width - 1
|
||||
|
||||
if x_start >= self.width or x_end < 0:
|
||||
continue
|
||||
|
||||
win_x0 = max(0, x_start)
|
||||
win_x1 = min(self.width - 1, x_end)
|
||||
|
||||
if win_x1 < win_x0:
|
||||
continue
|
||||
|
||||
src_offset_pixels = win_x0 - x_start
|
||||
win_w = win_x1 - win_x0 + 1
|
||||
|
||||
# Convert pixel data to RGB565 row buffer
|
||||
if bpp == 24:
|
||||
self._convert_bgr24_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
|
||||
elif bpp == 32:
|
||||
self._convert_bgra32_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
|
||||
|
||||
# Draw directly to the screen via SPI window
|
||||
self.set_window(win_x0, screen_y, win_x1, screen_y)
|
||||
self.dc(1)
|
||||
self.cs(0)
|
||||
self.spi.write(memoryview(rgb565_buf)[:win_w * 2])
|
||||
self.cs(1)
|
||||
|
||||
# Also update internal 1-bit canvas buffer for screenshots/refresh consistency
|
||||
for px in range(win_w):
|
||||
screen_x = win_x0 + px
|
||||
src_px = src_offset_pixels + px
|
||||
if bpp == 24:
|
||||
b = read_buf[src_px * 3]
|
||||
g = read_buf[src_px * 3 + 1]
|
||||
r = read_buf[src_px * 3 + 2]
|
||||
else:
|
||||
b = read_buf[src_px * 4]
|
||||
g = read_buf[src_px * 4 + 1]
|
||||
r = read_buf[src_px * 4 + 2]
|
||||
# 0 = Black, 1 = White in conversion for MONO_HLSB canvas
|
||||
lum = (r * 299 + g * 587 + b * 114) // 1000
|
||||
mono_c = 1 if lum >= 128 else 0
|
||||
self.canvas.pixel(screen_x, screen_y, mono_c)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print("Error drawing BMP:", e)
|
||||
return False
|
||||
|
||||
@micropython.native
|
||||
def _update_mono_canvas_rgb565(self, x, y, w, h, data):
|
||||
for cy in range(h):
|
||||
screen_y = y + cy
|
||||
if screen_y < 0 or screen_y >= self.height:
|
||||
continue
|
||||
for cx in range(w):
|
||||
screen_x = x + cx
|
||||
if screen_x < 0 or screen_x >= self.width:
|
||||
continue
|
||||
idx = (cy * w + cx) * 2
|
||||
h_byte = data[idx]
|
||||
l_byte = data[idx + 1]
|
||||
# Extract RGB from RGB565
|
||||
r = (h_byte & 0xF8)
|
||||
g = ((h_byte & 0x07) << 5) | ((l_byte & 0xE0) >> 3)
|
||||
b = (l_byte & 0x1F) << 3
|
||||
# Convert to luminance
|
||||
lum = (r * 299 + g * 587 + b * 114) // 1000
|
||||
mono = 1 if lum >= 128 else 0
|
||||
self.canvas.pixel(screen_x, screen_y, mono)
|
||||
|
||||
def draw_rgb565(self, x, y, w, h, data):
|
||||
"""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
|
||||
self._update_mono_canvas_rgb565(x, y, w, h, data)
|
||||
return True
|
||||
|
||||
# --- SCREENSHOT ---
|
||||
def save_screenshot(self, filename):
|
||||
print(f"Saving screenshot to {filename}...")
|
||||
|
||||
+92
@@ -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):
|
||||
"""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}...")
|
||||
|
||||
+179
@@ -2,6 +2,7 @@ import time
|
||||
from machine import Pin, SPI
|
||||
import framebuf
|
||||
import micropython
|
||||
import struct
|
||||
|
||||
class ST7796:
|
||||
def __init__(self, spi, cs, dc, rst, bl=None, width=480, height=320, invert_color=True):
|
||||
@@ -89,6 +90,184 @@ class ST7796:
|
||||
except OSError:
|
||||
print(f"Error: Could not open {filename}")
|
||||
|
||||
@micropython.native
|
||||
def _convert_bgr24_to_rgb565(self, bgr_buf, rgb565_buf, width, src_offset, num_pixels):
|
||||
idx = 0
|
||||
for i in range(src_offset, src_offset + num_pixels):
|
||||
b = bgr_buf[i * 3]
|
||||
g = bgr_buf[i * 3 + 1]
|
||||
r = bgr_buf[i * 3 + 2]
|
||||
r_5 = r >> 3
|
||||
g_6 = g >> 2
|
||||
b_5 = b >> 3
|
||||
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
|
||||
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
|
||||
idx += 2
|
||||
|
||||
@micropython.native
|
||||
def _convert_bgra32_to_rgb565(self, bgra_buf, rgb565_buf, width, src_offset, num_pixels):
|
||||
idx = 0
|
||||
for i in range(src_offset, src_offset + num_pixels):
|
||||
b = bgra_buf[i * 4]
|
||||
g = bgra_buf[i * 4 + 1]
|
||||
r = bgra_buf[i * 4 + 2]
|
||||
r_5 = r >> 3
|
||||
g_6 = g >> 2
|
||||
b_5 = b >> 3
|
||||
rgb565_buf[idx] = (r_5 << 3) | (g_6 >> 3)
|
||||
rgb565_buf[idx + 1] = ((g_6 & 0x07) << 5) | b_5
|
||||
idx += 2
|
||||
|
||||
def draw_bmp(self, filename, x=0, y=0):
|
||||
"""Draw a 24-bit or 32-bit uncompressed color BMP image at (x, y) coordinates."""
|
||||
try:
|
||||
with open(filename, 'rb') as f:
|
||||
header = f.read(54)
|
||||
if len(header) < 54 or header[0:2] != b'BM':
|
||||
print("Err: Not a valid BMP file")
|
||||
return False
|
||||
|
||||
pixel_offset = struct.unpack('<I', header[10:14])[0]
|
||||
width, height = struct.unpack('<ii', header[18:26])
|
||||
planes, bpp = struct.unpack('<HH', header[26:30])
|
||||
compression = struct.unpack('<I', header[30:34])[0]
|
||||
|
||||
if bpp not in (24, 32):
|
||||
print("Err: Only 24-bit and 32-bit BMP formats supported")
|
||||
return False
|
||||
|
||||
if compression != 0:
|
||||
print("Err: Only uncompressed BMP supported")
|
||||
return False
|
||||
|
||||
f.seek(pixel_offset)
|
||||
bottom_up = True
|
||||
if height < 0:
|
||||
height = -height
|
||||
bottom_up = False
|
||||
|
||||
row_bytes = (width * bpp) // 8
|
||||
row_padded = ((width * bpp + 31) // 32) * 4
|
||||
|
||||
read_buf = bytearray(row_padded)
|
||||
rgb565_buf = bytearray(width * 2)
|
||||
|
||||
for row_idx in range(height):
|
||||
n = f.readinto(read_buf)
|
||||
if n < row_padded:
|
||||
break
|
||||
|
||||
screen_y = y + (height - 1 - row_idx) if bottom_up else y + row_idx
|
||||
if screen_y < 0 or screen_y >= self.height:
|
||||
continue
|
||||
|
||||
x_start = x
|
||||
x_end = x + width - 1
|
||||
|
||||
if x_start >= self.width or x_end < 0:
|
||||
continue
|
||||
|
||||
win_x0 = max(0, x_start)
|
||||
win_x1 = min(self.width - 1, x_end)
|
||||
|
||||
if win_x1 < win_x0:
|
||||
continue
|
||||
|
||||
src_offset_pixels = win_x0 - x_start
|
||||
win_w = win_x1 - win_x0 + 1
|
||||
|
||||
# Convert pixel data to RGB565 row buffer
|
||||
if bpp == 24:
|
||||
self._convert_bgr24_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
|
||||
elif bpp == 32:
|
||||
self._convert_bgra32_to_rgb565(read_buf, rgb565_buf, width, src_offset_pixels, win_w)
|
||||
|
||||
# Draw directly to the screen via SPI window
|
||||
self.set_window(win_x0, screen_y, win_x1, screen_y)
|
||||
self.dc(1)
|
||||
self.cs(0)
|
||||
self.spi.write(memoryview(rgb565_buf)[:win_w * 2])
|
||||
self.cs(1)
|
||||
|
||||
# Also update internal 1-bit canvas buffer for screenshots/refresh consistency
|
||||
for px in range(win_w):
|
||||
screen_x = win_x0 + px
|
||||
src_px = src_offset_pixels + px
|
||||
if bpp == 24:
|
||||
b = read_buf[src_px * 3]
|
||||
g = read_buf[src_px * 3 + 1]
|
||||
r = read_buf[src_px * 3 + 2]
|
||||
else:
|
||||
b = read_buf[src_px * 4]
|
||||
g = read_buf[src_px * 4 + 1]
|
||||
r = read_buf[src_px * 4 + 2]
|
||||
# 0 = Black, 1 = White in conversion for MONO_HLSB canvas
|
||||
lum = (r * 299 + g * 587 + b * 114) // 1000
|
||||
mono_c = 1 if lum >= 128 else 0
|
||||
self.canvas.pixel(screen_x, screen_y, mono_c)
|
||||
|
||||
return True
|
||||
except Exception as e:
|
||||
print("Error drawing BMP:", e)
|
||||
return False
|
||||
|
||||
@micropython.native
|
||||
def _update_mono_canvas_rgb565(self, x, y, w, h, data):
|
||||
for cy in range(h):
|
||||
screen_y = y + cy
|
||||
if screen_y < 0 or screen_y >= self.height:
|
||||
continue
|
||||
for cx in range(w):
|
||||
screen_x = x + cx
|
||||
if screen_x < 0 or screen_x >= self.width:
|
||||
continue
|
||||
idx = (cy * w + cx) * 2
|
||||
h_byte = data[idx]
|
||||
l_byte = data[idx + 1]
|
||||
# Extract RGB from RGB565
|
||||
r = (h_byte & 0xF8)
|
||||
g = ((h_byte & 0x07) << 5) | ((l_byte & 0xE0) >> 3)
|
||||
b = (l_byte & 0x1F) << 3
|
||||
# Convert to luminance
|
||||
lum = (r * 299 + g * 587 + b * 114) // 1000
|
||||
mono = 1 if lum >= 128 else 0
|
||||
self.canvas.pixel(screen_x, screen_y, mono)
|
||||
|
||||
def draw_rgb565(self, x, y, w, h, data):
|
||||
"""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
|
||||
self._update_mono_canvas_rgb565(x, y, w, h, data)
|
||||
return True
|
||||
|
||||
# --- SCREENSHOT ---
|
||||
def save_screenshot(self, filename):
|
||||
print(f"Saving screenshot to {filename}...")
|
||||
|
||||
@@ -149,6 +149,33 @@ def stream_hermes_request(url, headers, filename):
|
||||
return status_code, resp_headers, s
|
||||
|
||||
|
||||
def sync_ntp_time(rtc_chip):
|
||||
if rtc_chip is None:
|
||||
return False
|
||||
import ntptime
|
||||
import wifi_config
|
||||
|
||||
tz_offset = getattr(wifi_config, 'TZ_OFFSET', 0)
|
||||
print(f"Syncing time from NTP server... (Timezone offset: {tz_offset} hours)")
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
utc_sec = ntptime.time()
|
||||
local_sec = utc_sec + int(tz_offset * 3600)
|
||||
t = time.localtime(local_sec)
|
||||
|
||||
dt = (t[0], t[1], t[2], t[6], t[3], t[4], t[5])
|
||||
rtc_chip.set_datetime(dt)
|
||||
rtc_chip.sync_to_system()
|
||||
t_str = f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d} {t[3]:02d}:{t[4]:02d}:{t[5]:02d}"
|
||||
print(f"Successfully synced RTC with NTP. Local time: {t_str}")
|
||||
return True
|
||||
except Exception as e:
|
||||
print(f"NTP sync attempt {attempt+1} failed: {e}")
|
||||
time.sleep_ms(200)
|
||||
return False
|
||||
|
||||
|
||||
# LED mode options for manual cycling
|
||||
led_modes = [
|
||||
("Red (Breathing)", lambda led: led.set_color(40, 0, 0), "breath"),
|
||||
@@ -240,6 +267,9 @@ def main():
|
||||
from mcp_server import MCPServer
|
||||
mcp = MCPServer(display, led, battery, sensor, rtc_chip, ble_uart, vstream=vstream, touch=touch)
|
||||
mcp.start(port=80)
|
||||
|
||||
if wlan.isconnected():
|
||||
sync_ntp_time(rtc_chip)
|
||||
except Exception as ne:
|
||||
print("Failed to start network services:", ne)
|
||||
|
||||
@@ -317,6 +347,7 @@ def main():
|
||||
print(f"Wi-Fi Connected! IP Address: {ip_addr}")
|
||||
last_action_str = f"Wi-Fi Connected: {ip_addr}"
|
||||
force_dashboard_redraw = True
|
||||
sync_ntp_time(rtc_chip)
|
||||
|
||||
# Check voice assistant trigger (touch screen or physical key button)
|
||||
if is_talk_trigger_active():
|
||||
@@ -366,19 +397,20 @@ def main():
|
||||
except Exception as e:
|
||||
print("Failed to set mic gain:", e)
|
||||
|
||||
# 2. Configure I2S RX for recording (Mono 16kHz)
|
||||
# 2. Configure I2S RX for recording (Stereo 16kHz — ES7210 outputs stereo)
|
||||
i2s_rx = I2S(1,
|
||||
sck=Pin(board_config.audio_i2s_sck),
|
||||
ws=Pin(board_config.audio_i2s_ws),
|
||||
sd=Pin(board_config.audio_i2s_rx_sd),
|
||||
mode=I2S.RX,
|
||||
ibuf=8000,
|
||||
ibuf=16000,
|
||||
rate=16000,
|
||||
bits=16,
|
||||
format=I2S.MONO)
|
||||
format=I2S.STEREO)
|
||||
|
||||
total_data_bytes = 0
|
||||
buffer = bytearray(1024)
|
||||
buffer = bytearray(2048)
|
||||
mono_buf = bytearray(1024) # Half size for mono extraction
|
||||
|
||||
rec_start_time = time.ticks_ms()
|
||||
max_rec_duration_ms = 10000 # 10 seconds max duration
|
||||
@@ -395,8 +427,15 @@ def main():
|
||||
# Read I2S chunk
|
||||
bytes_read = i2s_rx.readinto(buffer)
|
||||
if bytes_read > 0:
|
||||
f.write(buffer[:bytes_read])
|
||||
total_data_bytes += bytes_read
|
||||
# Stereo-to-mono: extract left channel (every other 16-bit sample)
|
||||
mono_len = bytes_read // 2
|
||||
j = 0
|
||||
for i in range(0, bytes_read, 4):
|
||||
mono_buf[j] = buffer[i]
|
||||
mono_buf[j + 1] = buffer[i + 1]
|
||||
j += 2
|
||||
f.write(mono_buf[:mono_len])
|
||||
total_data_bytes += mono_len
|
||||
except Exception as e:
|
||||
print("Error recording:", e)
|
||||
finally:
|
||||
@@ -489,7 +528,7 @@ def main():
|
||||
resp_text = ""
|
||||
if content_len > 0:
|
||||
try:
|
||||
resp_text = socket_read_exactly(s, content_len).decode('utf-8', errors='ignore')
|
||||
resp_text = socket_read_exactly(s, content_len).decode('utf-8', 'ignore')
|
||||
except:
|
||||
pass
|
||||
print("Non-WAV response received:", resp_text)
|
||||
@@ -503,7 +542,7 @@ def main():
|
||||
resp_text = "Unknown error"
|
||||
if content_len > 0:
|
||||
try:
|
||||
resp_text = socket_read_exactly(s, content_len).decode('utf-8', errors='ignore')
|
||||
resp_text = socket_read_exactly(s, content_len).decode('utf-8', 'ignore')
|
||||
except:
|
||||
pass
|
||||
print("Error response:", resp_text)
|
||||
|
||||
+222
-22
@@ -96,11 +96,21 @@ class MCPServer:
|
||||
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')
|
||||
if len(lines) == 0 or not lines[0]:
|
||||
client.close()
|
||||
return
|
||||
|
||||
@@ -120,17 +130,15 @@ class MCPServer:
|
||||
content_length = int(line.split(':')[1].strip())
|
||||
break
|
||||
|
||||
# Locate start of JSON body
|
||||
body = ""
|
||||
if '\r\n\r\n' in req:
|
||||
body = req.split('\r\n\r\n', 1)[1]
|
||||
body_bytes = bytearray(req_bytes[body_start_idx:])
|
||||
while len(body_bytes) < content_length:
|
||||
chunk = client.recv(min(1024, content_length - len(body_bytes)))
|
||||
if not chunk:
|
||||
break
|
||||
body_bytes.extend(chunk)
|
||||
|
||||
# Read remaining body if not fully received
|
||||
while len(body) < content_length:
|
||||
body += client.recv(1024).decode('utf-8')
|
||||
|
||||
# Parse JSON-RPC 2.0 Request
|
||||
rpc_req = json.loads(body)
|
||||
body_text = body_bytes.decode('utf-8', 'ignore')
|
||||
rpc_req = json.loads(body_text)
|
||||
rpc_resp = self._handle_rpc(rpc_req)
|
||||
|
||||
resp_body = json.dumps(rpc_resp)
|
||||
@@ -140,6 +148,56 @@ class MCPServer:
|
||||
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
|
||||
|
||||
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)
|
||||
|
||||
# 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)
|
||||
|
||||
# Draw directly using display raw RGB565 method
|
||||
self.override_active = True
|
||||
self.display.draw_rgb565(x, y, w, h, body_bytes)
|
||||
|
||||
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):
|
||||
@@ -151,7 +209,7 @@ class MCPServer:
|
||||
else:
|
||||
# Return 404
|
||||
resp = "HTTP/1.1 404 Not Found\r\nContent-Length: 0\r\nConnection: close\r\n\r\n"
|
||||
client.send(resp.encode('utf-8'))
|
||||
client.write(resp.encode('utf-8'))
|
||||
except Exception as e:
|
||||
print("Error handling client:", e)
|
||||
finally:
|
||||
@@ -162,6 +220,10 @@ class MCPServer:
|
||||
method = req.get('method')
|
||||
params = req.get('params', {})
|
||||
|
||||
# Get active display dimensions dynamically to report correct screen resolution
|
||||
width = getattr(self.display, 'width', 320)
|
||||
height = getattr(self.display, 'height', 240)
|
||||
|
||||
if method == 'tools/list':
|
||||
return {
|
||||
"jsonrpc": "2.0",
|
||||
@@ -169,7 +231,7 @@ class MCPServer:
|
||||
"tools": [
|
||||
{
|
||||
"name": "clear_screen",
|
||||
"description": "Clear the 480x320 screen to white (0) or black (1).",
|
||||
"description": f"Clear the {width}x{height} screen to white (0) or black (1).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -180,13 +242,13 @@ class MCPServer:
|
||||
},
|
||||
{
|
||||
"name": "draw_text",
|
||||
"description": "Draw text on the screen at specified (x,y) coordinates.",
|
||||
"description": f"Draw text on the screen at specified (x,y) coordinates. Screen resolution is {width}x{height}.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"text": {"type": "string", "description": "The message to display"},
|
||||
"x": {"type": "integer", "description": "X coordinate (0-470)"},
|
||||
"y": {"type": "integer", "description": "Y coordinate (0-310)"},
|
||||
"x": {"type": "integer", "description": f"X coordinate (0-{width-1})"},
|
||||
"y": {"type": "integer", "description": f"Y coordinate (0-{height-1})"},
|
||||
"size": {"type": "integer", "enum": [1, 2], "description": "Text scale (1=normal, 2=large)"}
|
||||
},
|
||||
"required": ["text", "x", "y"]
|
||||
@@ -233,12 +295,12 @@ class MCPServer:
|
||||
},
|
||||
{
|
||||
"name": "get_screenshot",
|
||||
"description": "Capture the current reflective LCD screen rendering as a PNG image.",
|
||||
"description": "Capture the current screen rendering as a PNG image.",
|
||||
"inputSchema": {"type": "object", "properties": {}}
|
||||
},
|
||||
{
|
||||
"name": "draw_image",
|
||||
"description": "Draw an image (PNG, JPEG, GIF, BMP, etc.) on the screen. The image will be converted to 1-bit monochrome and fit to display boundaries.",
|
||||
"description": f"Draw an image (PNG, JPEG, GIF, BMP, etc.) on the screen. The image will be converted to 1-bit monochrome and fit to display boundaries (max {width}x{height}).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
@@ -250,6 +312,44 @@ class MCPServer:
|
||||
"required": ["image_base64"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "draw_color_bmp",
|
||||
"description": f"Draw a color BMP image on the {width}x{height} color screen at specified (x,y) coordinates.",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"bmp_base64": {"type": "string", "description": "Base64 encoded BMP image file (uncompressed 24-bit or 32-bit format)"},
|
||||
"x": {"type": "integer", "description": "X coordinate to place the image (default 0)", "default": 0},
|
||||
"y": {"type": "integer", "description": "Y coordinate to place the image (default 0)", "default": 0}
|
||||
},
|
||||
"required": ["bmp_base64"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "get_capabilities",
|
||||
"description": "Get screen capabilities (resolution, color support, and supported formats).",
|
||||
"inputSchema": {"type": "object", "properties": {}}
|
||||
},
|
||||
{
|
||||
"name": "sync_time",
|
||||
"description": "Synchronize the hardware and system clock with an internet NTP server.",
|
||||
"inputSchema": {"type": "object", "properties": {}}
|
||||
},
|
||||
{
|
||||
"name": "draw_raw_rgb565",
|
||||
"description": f"Draw raw RGB565 pixel data on the {width}x{height} screen at specified (x,y) coordinates with width (w) and height (h).",
|
||||
"inputSchema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"rgb565_base64": {"type": "string", "description": "Base64 encoded raw RGB565 pixel data (Big-Endian, 2 bytes per pixel)"},
|
||||
"x": {"type": "integer", "description": "X coordinate to place the image"},
|
||||
"y": {"type": "integer", "description": "Y coordinate to place the image"},
|
||||
"w": {"type": "integer", "description": "Width of the raw pixel block"},
|
||||
"h": {"type": "integer", "description": "Height of the raw pixel block"}
|
||||
},
|
||||
"required": ["rgb565_base64", "x", "y", "w", "h"]
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "write_file",
|
||||
"description": "Write a file (e.g., python script or config) to the board's flash storage.",
|
||||
@@ -507,6 +607,106 @@ class MCPServer:
|
||||
pass
|
||||
return "Image displayed successfully."
|
||||
|
||||
elif name == "draw_color_bmp":
|
||||
self.override_active = True
|
||||
|
||||
bmp_b64 = args.get("bmp_base64")
|
||||
x = int(args.get("x", 0))
|
||||
y = int(args.get("y", 0))
|
||||
|
||||
if not bmp_b64:
|
||||
raise ValueError("Missing bmp_base64 parameter")
|
||||
|
||||
import binascii
|
||||
try:
|
||||
bmp_bytes = binascii.a2b_base64(bmp_b64)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to decode base64 BMP: {e}")
|
||||
|
||||
filename = 'temp_recv.bmp'
|
||||
with open(filename, 'wb') as f:
|
||||
f.write(bmp_bytes)
|
||||
|
||||
try:
|
||||
success = self.display.draw_bmp(filename, x, y)
|
||||
if not success:
|
||||
raise RuntimeError("Failed to parse or draw BMP image on screen.")
|
||||
finally:
|
||||
import os
|
||||
try:
|
||||
os.remove(filename)
|
||||
except:
|
||||
pass
|
||||
return "Color BMP image displayed successfully."
|
||||
|
||||
elif name == "get_capabilities":
|
||||
is_color = getattr(self.display, '__class__', None) is not None and self.display.__class__.__name__ != 'RLCD'
|
||||
formats = ["rgb565_base64", "bmp_base64", "pbm_base64"] if is_color else ["pbm_base64", "bmp_base64", "rgb565_base64"]
|
||||
|
||||
return json.dumps({
|
||||
"color": is_color,
|
||||
"width": width,
|
||||
"height": height,
|
||||
"formats": formats
|
||||
})
|
||||
|
||||
elif name == "sync_time":
|
||||
import ntptime
|
||||
import wifi_config
|
||||
|
||||
tz_offset = getattr(wifi_config, 'TZ_OFFSET', 0)
|
||||
success = False
|
||||
t = None
|
||||
|
||||
for attempt in range(3):
|
||||
try:
|
||||
utc_sec = ntptime.time()
|
||||
local_sec = utc_sec + int(tz_offset * 3600)
|
||||
t = time.localtime(local_sec)
|
||||
|
||||
dt = (t[0], t[1], t[2], t[6], t[3], t[4], t[5])
|
||||
if self.rtc:
|
||||
self.rtc.set_datetime(dt)
|
||||
self.rtc.sync_to_system()
|
||||
success = True
|
||||
break
|
||||
except Exception as e:
|
||||
time.sleep_ms(200)
|
||||
|
||||
if success and t is not None:
|
||||
t_str = f"{t[0]:04d}-{t[1]:02d}-{t[2]:02d} {t[3]:02d}:{t[4]:02d}:{t[5]:02d}"
|
||||
return f"Successfully synchronized board clock with NTP server. Local time: {t_str}"
|
||||
else:
|
||||
raise RuntimeError("Failed to sync clock with NTP server.")
|
||||
|
||||
elif name == "draw_raw_rgb565":
|
||||
self.override_active = True
|
||||
|
||||
rgb_b64 = args.get("rgb565_base64")
|
||||
x = int(args.get("x", 0))
|
||||
y = int(args.get("y", 0))
|
||||
w = int(args.get("w", 0))
|
||||
h = int(args.get("h", 0))
|
||||
|
||||
if not rgb_b64:
|
||||
raise ValueError("Missing rgb565_base64 parameter")
|
||||
|
||||
import binascii
|
||||
try:
|
||||
rgb_bytes = binascii.a2b_base64(rgb_b64)
|
||||
except Exception as e:
|
||||
raise ValueError(f"Failed to decode base64 RGB565: {e}")
|
||||
|
||||
if len(rgb_bytes) < w * h * 2:
|
||||
raise ValueError(f"RGB565 data size too small (expected {w * h * 2} bytes, got {len(rgb_bytes)} bytes)")
|
||||
|
||||
try:
|
||||
self.display.draw_rgb565(x, y, w, h, rgb_bytes)
|
||||
except Exception as e:
|
||||
raise RuntimeError(f"Failed to draw RGB565: {e}")
|
||||
|
||||
return "Raw RGB565 data displayed successfully."
|
||||
|
||||
elif name == "write_file":
|
||||
path = str(args.get("path"))
|
||||
content = str(args.get("content"))
|
||||
|
||||
@@ -62,6 +62,7 @@ def main():
|
||||
files_to_upload = [
|
||||
("lib/board_config.py", "lib/board_config.py"),
|
||||
("lib/st7796.py", "lib/st7796.py"),
|
||||
("lib/ili9341.py", "lib/ili9341.py"),
|
||||
("lib/ft6336u.py", "lib/ft6336u.py"),
|
||||
("lib/rlcd.py", "lib/rlcd.py"),
|
||||
("lib/battery_util.py", "lib/battery_util.py"),
|
||||
@@ -72,6 +73,7 @@ def main():
|
||||
("demo_audio_loopback.py", "demo_audio_loopback.py"),
|
||||
("video_stream.py", "video_stream.py"),
|
||||
("mcp_server.py", "mcp_server.py"),
|
||||
("wifi_config.py", "wifi_config.py"),
|
||||
("boot.py", "boot.py"),
|
||||
("main.py", "main.py")
|
||||
]
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
# WebSocket Audio Streaming Implementation Plan
|
||||
|
||||
Implement a WebSocket-based real-time voice integration between the ESP32 board and the Hermes Voice Gateway (Phase 1: WebSocket streaming with server-side batch fallback) to solve the RAM/Flash storage constraints and lower latency.
|
||||
|
||||
## User Review Required
|
||||
|
||||
> [!IMPORTANT]
|
||||
> - This plan adds a new route `/api/esp32/voice/ws` to the Hermes Gateway. The old POST route `/api/esp32/voice` will remain completely intact to act as a stable fallback.
|
||||
> - A lightweight WebSocket client `websocket_client.py` will be added to the MicroPython `lib/` directory since standard MicroPython doesn't bundle a WebSocket client by default.
|
||||
|
||||
## Open Questions
|
||||
|
||||
> [!NOTE]
|
||||
> None at the moment. The proposed wire protocol and incremental phased approach minimize risk.
|
||||
|
||||
---
|
||||
|
||||
## Proposed Changes
|
||||
|
||||
### 1. Hermes Voice Gateway
|
||||
|
||||
#### [MODIFY] [api_server_endpoint.py](file:///Users/adolforeyna/Projects/MicroPython/test1/Screen/hermes_voice_gateway/api_server_endpoint.py)
|
||||
* Add a `_handle_esp32_voice_websocket` handler method.
|
||||
* Listen for a JSON `start` frame to read parameters like `device_id`, `reply_mode`, and `screen_url`.
|
||||
* Accept incoming binary frames (raw PCM s16le mono 16 kHz chunks) and write them directly to a temporary WAV file on the host's disk.
|
||||
* Upon receiving the JSON `end` frame (or a disconnection/timeout):
|
||||
* Close the WAV file writer.
|
||||
* Invoke `transcribe_audio(wav_path)` using the local `faster-whisper` engine.
|
||||
* Run the agent turn via `_run_esp32_voice_turn`.
|
||||
* Synthesize TTS audio and send the resulting WAV file back as a binary frame to the client.
|
||||
* Update `register_esp32_voice_route` to bind the new WebSocket endpoint:
|
||||
```python
|
||||
app.router.add_get("/api/esp32/voice/ws", api_server._handle_esp32_voice_websocket)
|
||||
```
|
||||
|
||||
#### [MODIFY] [README.md](file:///Users/adolforeyna/Projects/MicroPython/test1/Screen/hermes_voice_gateway/README.md)
|
||||
* Update documentation to cover the new `/api/esp32/voice/ws` WebSocket client contract and protocol.
|
||||
|
||||
---
|
||||
|
||||
### 2. ESP32 MicroPython Firmware
|
||||
|
||||
#### [NEW] [websocket_client.py](file:///Users/adolforeyna/Projects/MicroPython/test1/Screen/lib/websocket_client.py)
|
||||
* Implement a minimal, robust, memory-efficient WebSocket client helper class (`WebSocketClient`) in MicroPython.
|
||||
* Support HTTP handshake, sending/receiving text frames (opcode 1), and binary frames (opcode 2) with proper masking (client-to-server requirement).
|
||||
|
||||
#### [NEW] [demo_websocket_voice.py](file:///Users/adolforeyna/Projects/MicroPython/test1/Screen/demo_websocket_voice.py)
|
||||
* Create a dedicated firmware script demonstrating how the board communicates with the new gateway:
|
||||
* Connect to `ws://<ip>:8642/api/esp32/voice/ws`.
|
||||
* Send the JSON start frame.
|
||||
* Record continuously from I2S and stream 100ms chunks (3200 bytes mono) over the socket.
|
||||
* Send the JSON end frame on screen release.
|
||||
* Listen for the server's response WAV frame and play it using the ES8311 speaker codec.
|
||||
|
||||
---
|
||||
|
||||
## Verification Plan
|
||||
|
||||
### Automated Tests
|
||||
* Run `python3 hermes_voice_gateway/smoke_test.py` to ensure local helper methods (normalization, MIME mapping) remain fully functional.
|
||||
|
||||
### Manual Verification
|
||||
1. Run the updated Hermes server with the new WebSocket route registered.
|
||||
2. Run `demo_websocket_voice.py` on the ESP32 board.
|
||||
3. Verify that the ESP32 connects, streams audio chunks on-the-fly without running out of RAM (SRAM memory checks via `gc.mem_free()`), transcribes the speech, and plays back the agent's voice response.
|
||||
@@ -3,3 +3,7 @@
|
||||
|
||||
WIFI_SSID = "FamReynaMesh"
|
||||
WIFI_PASS = "Gloria2020"
|
||||
|
||||
# Timezone offset in hours relative to UTC (e.g. -4 for EDT, -5 for EST)
|
||||
TZ_OFFSET = -4
|
||||
|
||||
|
||||
Reference in New Issue
Block a user