Compare commits

115 Commits

Author SHA1 Message Date
fc6302661d feat: support camera capture to livekit 2026-05-25 17:21:11 +08:00
4953244c7c fix: voice interrupt 2026-05-22 10:20:00 +08:00
5223333418 fix: voice interrupt 2026-05-22 10:10:16 +08:00
61ad9dafd9 fix: text display 2026-05-21 17:05:09 +08:00
928d40826f feat: ws connect 2026-05-18 15:56:50 +08:00
417f52d759 perf(websocket): switch WiFi to performance mode before connecting (#1985)
Some checks failed
Build Boards / Determine variants to build (push) Has been cancelled
Build Boards / Build ${{ matrix.full_name }} (push) Has been cancelled
* perf(websocket): switch WiFi to performance mode before connecting

Optimize WebSocket connection speed by switching WiFi to performance
mode before establishing the connection, instead of after.

This reduces network latency significantly:
- TCP connection: 1093ms → 88ms (92% faster)
- WebSocket handshake: 1035ms → 80ms (92% faster)
- Total network layer: 2128ms → 173ms (92% faster)

The issue was caused by WiFi power save mode (MAX_MODEM) which adds
significant latency to packet transmission.

* Adjust formatting
2026-05-14 14:36:14 +08:00
67bf599149 fix(otto): WebSocket direct clients not receiving MCP responses (#1992)
* Enhance Otto Robot camera support by adding configuration for OV3660. Updated config.h to define camera types and GPIO settings, modified config.json to include new camera options, and refactored otto_robot.cc for improved camera detection and initialization logic.

* fix: 移除 OttoEmojiDisplay 构造函数中的 SetTheme 调用以修复 LoadProhibited 崩溃

Made-with: Cursor

* refactor: improve audio service error handling and codec timeout management

- Updated AudioService to prevent input task termination on read timeout, introducing a delay instead.
- Enhanced NoAudioCodec to implement a read timeout for I2S channel reads.
- Adjusted WebSocketControlServer to set a control port for improved socket management.
- Added manufacturer information to the config.json for waveshare ESP32-Touch-LCD-3.5.

* fix(otto): WebSocket direct clients not receiving MCP responses

When a browser connects directly to the WebSocket control server (port
8080) and sends a JSON-RPC request, the MCP response was routed through
Application::SendMcpMessage -> protocol_->SendMcpMessage, which sends it
to the cloud protocol channel. As a result, the direct WebSocket client
never received the response, while the WeChat mini-program could because
it communicates via the cloud.

Fix:
- Add BroadcastMessage() to WebSocketControlServer, using
  httpd_queue_work + httpd_ws_send_frame_async to asynchronously
  send responses back to all connected clients on port 8080
- Add RegisterMcpBroadcastCallback() to Application, allowing an
  additional MCP send callback to be registered; SendMcpMessage()
  now invokes it alongside the cloud protocol
- Register the broadcast callback in OttoRobot after the WebSocket
  server starts successfully

Also add WebSocket direct-connect API documentation to README.md
with complete JSON-RPC 2.0 command examples.
2026-05-14 14:35:49 +08:00
ba27c12494 fix(no_audio_codec): replace kReadTimeoutTicks with kReadTimeoutMs for clarity and consistency 2026-05-07 22:11:42 +08:00
c1d520d700 Feat: Add battery support and small fixes for Freenove 2.8 board (#1976)
* feat(freenove-esp32s3): add battery level retrieval

* fix(freenove-esp32s3): add missing comma in config.json

* docs(freenove-esp32s3): note possible shared design with ES3C28P/ES3N28P
2026-05-07 20:51:58 +08:00
1847b58935 fix(mcp): remove unnecessary guard for self.assets.set_download_url tool registration
The guard around the registration of the self.assets.set_download_url tool has been removed, ensuring it is always available for configuration. This change addresses issues on 32MB flash devices where the tool was previously skipped due to partition validation checks.

Fixes #1962
2026-05-02 15:56:47 +08:00
2be3c2cb1a fix(mcp): always register self.assets.set_download_url tool for 32MB flash devices (#1971)
* fix(m5stack-tab5): remove stale esp_video==0.7.0 dependency instructions

The README previously instructed users to override esp_video to 0.7.0
and esp_ipa to 0.1.0, but this causes build failures because:
- esp_video 0.7.0 does not export esp_video_deinit(), resulting in
  linker errors ('MAP_FAILED' and 'esp_video_deinit' not declared)
- The project's main/idf_component.yml already pins the correct
  version (esp_video==1.3.1) that the source code expects

Users should now use the default dependency versions from idf_component.yml
without modification.

Fixes #1957

* fix(mcp): always register self.assets.set_download_url tool

On 32MB flash devices the assets partition layout differs from the
default, causing partition_valid() to return false and silently
skipping registration of the self.assets.set_download_url MCP tool.
Users see 'Unknown tool: self.assets.set_download_url' from their MCP
client.

The tool writes to Settings storage which works regardless of the
partition map, so the partition_valid() guard is unnecessary.
Move the AddUserOnlyTool call outside the guard so the tool is always
available for explicit configuration via MCP.

Fixes #1962

---------

Co-authored-by: Aayush Pratap Singh <aayushpratap.singh@gmail.com>
2026-05-02 06:23:25 +08:00
e12e7351d9 Merge pull request #1958 from rymcu/main
feat(board): add rymcu-bigsmart board support
2026-04-30 17:16:40 +08:00
20175fa059 Move RYMCU BigSmart under manufacturer directory
Create main/boards/rymcu/bigsmart so future RYMCU boards can live under the same manufacturer directory. Update CMake to set MANUFACTURER to rymcu while preserving BOARD_NAME as rymcu-bigsmart, and adjust config.json so release output remains rymcu-bigsmart.
2026-04-30 16:04:30 +08:00
8cbbf3f357 chore: update dependencies in idf_component.yml
- Bump esp-wifi-connect version from ~3.1.2 to ~3.1.3
- Update uart-eth-modem version from ~0.3.4 to ~0.4.0
2026-04-30 13:29:36 +08:00
79a482a09e fix(blufi): GET_WIFI_LIST triggers real-time scan with guaranteed response (#1964)
* fix(blufi): GET_WIFI_LIST triggers real-time scan with guaranteed response

Previously, ESP_BLUFI_EVENT_GET_WIFI_LIST waited for any in-progress scan
to finish and then returned the cached result. When the cache was empty
(e.g. after a config-mode transition that stopped the Wi-Fi driver),
_send_wifi_list() returned silently with no response frame, leaving the
App waiting until timeout.

Changes:
- GET_WIFI_LIST now clears the cache and starts a fresh scan immediately.
- _wifi_scan_event_handler calls _send_wifi_list() after every scan
  triggered by a GET_WIFI_LIST request.
- start_wifi_scan() calls esp_wifi_start() before esp_wifi_scan_start()
  to handle the case where the driver was stopped during a mode
  transition (ESP_ERR_WIFI_STATE is treated as already-started).
- _send_wifi_list() sends ESP_BLUFI_WIFI_SCAN_FAIL when no APs are
  found, so the App always receives a terminal response.
- Redundant static_cast in _wifi_scan_event_handler replaced with the
  existing local `self` pointer.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* fix(blufi): preserve cache fast-path, fall back to live scan only when needed

Address review feedback on always-rescan latency regression.

The GET_WIFI_LIST handler now distinguishes three cases:

1. Scan in flight: defer the response via m_send_list_after_scan; the
   scan-done handler dispatches when it fires. Removes the previous
   blocking `while (m_scan_in_progress) vTaskDelay(500)` which would
   stall the BluFi event task indefinitely if the scan never completed.

2. Cache populated: respond from cache immediately (~50 ms, no latency
   change vs original behavior). _send_wifi_list() still kicks off an
   async refresh scan as before to keep the cache fresh.

3. Cache empty and no scan running: trigger a live scan and dispatch
   from the scan-done handler. If start_wifi_scan() fails, send
   ESP_BLUFI_WIFI_SCAN_FAIL so the App exits its wait state.

State variables are also disentangled:

- m_scan_should_save_ssid keeps its original meaning (write scan results
  into m_ap_records). Cleared during connect-to-AP so the connect-time
  scan does not pollute the cache.
- m_send_list_after_scan is new and tracks "the next scan-done event
  should respond to a pending GET_WIFI_LIST request". The previous PR
  conflated these two responsibilities onto m_scan_should_save_ssid,
  which would have caused init-time scans to spuriously emit a wifi list
  to the App.

start_wifi_scan() now returns bool so the caller can distinguish
"scan started or already running" from "could not start a scan".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Yixin Shi <shiyixin@qiniu.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-30 09:27:49 +08:00
73ad50c732 feat: add M5Stack AtomS3R Echo Pyramid board (#1959)
Made-with: Cursor
2026-04-28 13:55:39 +08:00
07e2a11253 feat(board): add rymcu-bigsmart board support 2026-04-23 20:44:35 +08:00
8865950405 Merge pull request #1949 from Cosmic183123/main
content:add-waveshare-esp32-s3-touch-amoled-2.16
2026-04-21 16:37:33 +08:00
b72945a78a content:fix-ESP32-S3-Touch-AMOLED-2.16-readme.md 2026-04-21 14:41:09 +08:00
cde254cdf0 Merge pull request #1943 from zqyhimself/fix/waveshare-p4-7b-config
fix(waveshare-p4-7b): target v1.x chip rev and 32MB flash layout
2026-04-21 14:41:01 +08:00
b7dc88f6ab fix(esp32-p4): split each P4 build into rev<3 + -p4x twin variants
【中文】
针对 PR #1943 的评审建议(Y1hsiaochunnn + laride):当前市面的 ESP32-P4
几乎都是 rev v1.x / v2.x 硅片,IDF 默认 REV_MIN_301 会让 bootloader 被
bootloader.rev3.ld 链到高位 HP_SRAM,导致在存量硬件上第一条指令就 panic。
未来 ESP32-P4X(rev >= 3.x)又需要保留默认配置,因此不能在所有 P4 board
上一刀切地写死 CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y。

方案:对所有 P4 config.json 的每个现有变体,添加 REV_LESS_V3 + REV_MIN_100,
并复制一份加 `-p4x` 后缀的孪生变体(不加上述两行,保持 IDF 默认 REV_MIN_301
以支持未来 P4X 芯片)。

受影响的 6 份 config.json,共 14 个原变体,复制后新增 14 个 `-p4x` 变体:
- main/boards/waveshare/esp32-p4-wifi6-touch-lcd/config.json(8 -> 16 变体,
  其中 7b 变体保留 32MB flash + custom 分区配置)
- main/boards/waveshare/esp32-p4-wifi6-touch-lcd-3.5/config.json
- main/boards/waveshare/esp32-p4-nano/config.json
- main/boards/esp-p4-function-ev-board/config.json
- main/boards/wireless-tag-wtp4c5mp07s/config.json
- main/boards/m5stack-tab5/config.json

---

[English]
Per review on PR #1943 (Y1hsiaochunnn + laride): almost all ESP32-P4 silicon
on the market today is rev v1.x / v2.x. IDF's default REV_MIN_301 links the
bootloader via bootloader.rev3.ld into the upper HP_SRAM window, which those
chips cannot execute; the CPU panics on the first opcode. Future ESP32-P4X
(rev >= 3.x) still needs the IDF default, so we cannot hard-code
CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y across all P4 boards.

Strategy: for each existing variant in every P4 config.json, add REV_LESS_V3
+ REV_MIN_100 to target current silicon, and add a `-p4x` twin variant
without those two configs (keeping IDF default REV_MIN_301) for future P4X
chips.

Affects 6 config.json files, 14 original variants duplicated into 14 `-p4x`
twins:
- main/boards/waveshare/esp32-p4-wifi6-touch-lcd/config.json (8 -> 16
  variants; the 7b variant keeps its 32 MB flash + custom partition configs)
- main/boards/waveshare/esp32-p4-wifi6-touch-lcd-3.5/config.json
- main/boards/waveshare/esp32-p4-nano/config.json
- main/boards/esp-p4-function-ev-board/config.json
- main/boards/wireless-tag-wtp4c5mp07s/config.json
- main/boards/m5stack-tab5/config.json
2026-04-21 14:30:58 +08:00
f78c59a954 content:add-waveshare-esp32-s3-touch-amoled-2.16 2026-04-21 09:52:20 +08:00
49ac8a6da3 chore: upgrade version to 2.2.6 (#1944)
Agent-Logs-Url: https://github.com/78/xiaozhi-esp32/sessions/d669edcb-2b68-4b41-81d6-b542a11b92e9

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 78 <4488133+78@users.noreply.github.com>
2026-04-18 02:12:02 +08:00
2461efdc72 fix(waveshare-p4-7b): target v1.x chip rev and 32MB flash layout
【中文】
7B 变体使用 v1.x 版本的 ESP32-P4 芯片和 32MB flash,但 config.json 中
对应的 sdkconfig_append 既没声明芯片版本,也没声明 flash 大小。IDF 因此
默认按 rev v3.1 + 16MB flash 构建,bootloader 被链接脚本
bootloader.rev3.ld 放到 0x4ffa_xxxx 的高位 HP_SRAM,这个区域在 v1.x
硅上不可取指,ROM 加载完跳过去,CPU 在第一条指令就
"Guru Meditation Error: Core 0 panic'ed (Illegal instruction)",
bootloader log 一行都打不出来。

添加 CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y + CONFIG_ESP32P4_REV_MIN_100=y
让 bootloader.ld(低位 HP_SRAM 0x4ff2_xxxx)被选中,并声明
CONFIG_ESPTOOLPY_FLASHSIZE_32MB=y + partitions/v2/32m.csv,使 image
header 和分区表和板子实际硬件对齐;CONFIG_ESPTOOLPY_FLASH_MODE_AUTO_DETECT=n
与已知 32MB 板子 sensecap-watcher 保持一致。

在 v1.3 版本的 P4-WIFI6-Touch-LCD-7B 上验证通过:bootloader 段现在加载到
0x4ff33ce0 / 0x4ff29ed0 / 0x4ff2cbd0;启动过程中 MIPI-DSI LCD、GT911
触控、OV5647 摄像头、ES8311+ES7210 音频和 ESP32-C6 SDIO 协处理器
全部正常初始化;状态机进入 wifi_configuring,对外发出 Xiaozhi-XXXX 配网热点。

---

[English]
The 7B variant ships with a v1.x ESP32-P4 silicon and a 32 MB flash, but
the existing sdkconfig_append declared neither. IDF then defaults to rev
v3.1 + 16 MB, and the bootloader is linked via bootloader.rev3.ld into
the 0x4ffa_xxxx upper HP_SRAM window. That range is not fetchable on
v1.x silicon, so the ROM jumps to the loaded bootloader and the CPU
panics on the first opcode with "Guru Meditation Error: Core 0 panic'ed
(Illegal instruction)" before any bootloader log is printed.

Add CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y + CONFIG_ESP32P4_REV_MIN_100=y
so bootloader.ld (low HP_SRAM 0x4ff2_xxxx) is used, and declare
CONFIG_ESPTOOLPY_FLASHSIZE_32MB=y + partitions/v2/32m.csv so the image
header and partition offsets match the board. Disable flash-mode
auto-detect for parity with the other known 32 MB board (sensecap-watcher).

Verified on a v1.3 P4-WIFI6-Touch-LCD-7B: bootloader segments now load
at 0x4ff33ce0 / 0x4ff29ed0 / 0x4ff2cbd0; boot completes through MIPI DSI,
GT911 touch, OV5647 camera, ES8311+ES7210 audio and the ESP32-C6 SDIO
coprocessor; device reaches wifi_configuring and exposes the Xiaozhi-XXXX AP.
2026-04-18 01:38:06 +08:00
e5ebde454e Merge pull request #1937 from DrayxR3X/esp32-s3-touch-amoled-1.43c
add waveshare esp32-s3-touch-amoled-1.43c
2026-04-17 13:59:37 +08:00
d2687956cd resolve conflict 2026-04-17 10:48:13 +08:00
87f6faee79 Update Chinese and English documentation for custom board setup and MCP protocol
- Added a comprehensive guide for creating custom boards in the XiaoZhi AI project, detailing directory structure, configuration files, and initialization code.
- Introduced a new document explaining the MCP protocol for IoT control, including message formats and interaction flows.
- Updated existing documentation to reflect changes in tool registration and usage examples for the MCP protocol.
- Enhanced README files for better clarity and consistency across languages.
2026-04-17 03:36:37 +08:00
69b1a978e9 Add NULLLAB-AI-VOX3 Board (#1900) 2026-04-16 19:31:53 +08:00
6074fdeb71 feat(cardputer-adv): add TCA8418 keyboard and WiFi config UI (#1929)
Add full keyboard support and keyboard-based WiFi configuration for
M5Stack Cardputer Adv:

- TCA8418 I2C keyboard driver with 56-key matrix, interrupt-driven
  key events, and debounce handling
- Keyboard WiFi config UI: scan/select/input SSID and password
  directly on the device without needing a phone
- Volume control (up/down arrows) and brightness control (left/right)
  via keyboard with fine-step adjustment near bounds
- Enter key to toggle chat state
- Display offset and backlight fixes for ST7789V2
- README with flash parameters and hardware specs

Co-authored-by: bot <bot@localhost>
2026-04-14 17:02:00 +08:00
7dc61300d1 add waveshare esp32-s3-touch-amoled-1.43c 2026-04-14 15:52:40 +08:00
97c0e75eec feat:添加正点原子DNESP32S3B3开发板的小智AI例程 (#1931) 2026-04-13 21:09:11 +08:00
efeb3ad119 fix: remove submodule (#1925) 2026-04-10 17:09:39 +08:00
36d742e4d7 ESP-VoCat: BMI270 motion feedback, capacitive slider (v1.2), and single-pad button (v1.0) (#1902)
* Add support bmi270

* Add support slider

* Add support touch for v1.0
2026-04-08 10:12:56 +08:00
ab2cae5746 Fix crash when adjusting volume via button while Es8311 audio device is disabled (#1917)
* Initial plan

* Fix crash when adjusting volume via button while audio device is disabled

When both input and output are disabled, UpdateDeviceState() sets dev_ to
nullptr. Pressing a volume button calls SetOutputVolume() which previously
called esp_codec_dev_set_out_vol(dev_, volume) without null-checking dev_,
causing a crash via ESP_ERROR_CHECK.

Fix: Add null guard for dev_ and mutex lock for thread safety. The volume
is still saved via AudioCodec::SetOutputVolume() and will be applied when
the device is reopened by UpdateDeviceState().

Agent-Logs-Url: https://github.com/78/xiaozhi-esp32/sessions/945c653a-ed16-49af-aefe-5cfb473402c6

Co-authored-by: 78 <4488133+78@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 78 <4488133+78@users.noreply.github.com>
2026-04-07 18:35:06 +08:00
7a7c74a747 Add touch screen support to freenove 2.8 (#1901)
* Add touch screen support to freenove 2.8

Volume and brightness levels on vertical/horizontal swipe
Listening, chat state and wifi config on press/long press

* Simplified

Removed swipes. Touch quiet unresponsive on Freenove board.

* Remove unused parts

* Modify long tap duration in TouchTask

Updated touch driver logic to change long tap duration from 600ms to 3000ms.

* Cosmetic fix
2026-04-07 17:18:24 +08:00
cc7cbe78ba Merge pull request #1821 from ggc121238/main
feat: add waveshare esp32-s3-cam-xxxx support
2026-04-03 14:14:35 +08:00
ggc
314edc5c51 content: add ESP32-S3-CAM-OVxxxx 2026-04-02 16:37:24 +08:00
7e7890183e Content: fixed ESP_LCD_TOUCH_IO_I2C_FT5x06_CONFIG issue 2026-04-02 14:21:01 +08:00
2c5d7757e5 Content: Removed some irrelevant code 2026-04-02 14:21:01 +08:00
43b4d35b2e Content: Removed irrelevant code 2026-04-02 14:21:01 +08:00
33ed917172 content: fixed config.json and config.h 2026-04-02 14:21:01 +08:00
89d51fdc23 content: add ESP32-P4-WIFI6-Touch-LCD-3.5 2026-04-02 14:21:01 +08:00
022d9848de Add freenove-esp32s3-display-2.8-lcd (#1876)
* add freenove-esp32s3-display-2.8-lcd

from https://github.com/Freenove/xiaozhi-esp32 and updated to v2.2.4

* add Freenove_ESP32S3_DISPLAY_2_8_LCD to configs

* Update from manufacturer

https://github.com/Freenove/xiaozhi-esp32/tree/New

* Delete main/boards/freenove-esp32s3-display-2.8-lcd/_static/ReadMe/media-kit-2.8-lcd-top.jpg

orphan

* Delete main/boards/freenove-esp32s3-display-2.8-lcd/_static/ReadMe/media-kit-2.8-lcd-sideview.jpg

orphan

* Delete main/boards/freenove-esp32s3-display-2.8-lcd/_static/ReadMe directory

* readme update
2026-04-01 12:46:53 +08:00
8afebe560a Merge pull request #1895 from Y1hsiaochunnn/touchcompatible
fix: Default configuration of GT911
2026-03-31 10:22:59 +08:00
a63f8bc08b fix: Default configuration of GT911 2026-03-31 10:10:12 +08:00
4dc1a8c75f Merge pull request #1892 from DrayxR3X/esp32-c6-touch-amoled-2.16
add waveshare esp32-c6-touch-amoled-2.16
2026-03-31 09:16:16 +08:00
121a2d45e7 Merge branch '78:main' into esp32-c6-touch-amoled-2.16 2026-03-30 18:16:11 +08:00
06b3b7613c Merge pull request #1891 from Y1hsiaochunnn/gt911_err
fix: Compilation errors caused by default configuration changes of the touch chip
2026-03-30 16:17:58 +08:00
addf5fcc64 fix: touch configuration security risk fixed 2026-03-30 15:29:31 +08:00
a877d95f74 fix: touch_config errors 2026-03-30 15:12:22 +08:00
76a5c19000 add waveshare esp32-c6-touch-amoled-2.16 2026-03-30 15:07:45 +08:00
7d63797dfa fix: GT911 config errors 2026-03-30 15:05:20 +08:00
0f3199a812 feat: adapt to esp_codec_dev>=1.5.5 (#1878)
* feat: adapt to esp_codec_dev>=1.5.5

* fix: fix board config resolution in release script
2026-03-26 23:56:45 +08:00
6d51b9dbde Bump version to 2.2.5 (#1882)
* Initial plan

* bump version to 2.2.5

Co-authored-by: 78 <4488133+78@users.noreply.github.com>
Agent-Logs-Url: https://github.com/78/xiaozhi-esp32/sessions/841b9fce-b34b-4cbd-b46a-20b48fd35d4c

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 78 <4488133+78@users.noreply.github.com>
2026-03-26 23:52:38 +08:00
d340efe58d Remove unused flag for playing popup sound on listening state in ContinueWakeWordInvoke() function (#1881) 2026-03-26 13:12:02 +08:00
d9f0ef13aa Fix GIF emoji flickering by merging lock scopes in SetEmotion() (#1880)
* Initial plan

* Fix GIF emoji flickering by merging lock scopes in SetEmotion()

Remove the separate first lock scope that hid emoji_image_ before
destroying gif_controller_ (added by PR #1848). Instead, move the
gif_controller_ cleanup into each lock scope where new content is set,
so destruction and creation happen atomically. This prevents both the
original race condition crash AND the visible flickering.

Co-authored-by: 78 <4488133+78@users.noreply.github.com>
Agent-Logs-Url: https://github.com/78/xiaozhi-esp32/sessions/2dd0ff3a-4556-47a6-8d40-a30e18c01923

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 78 <4488133+78@users.noreply.github.com>
2026-03-26 12:53:06 +08:00
-10
358819bf2e change to use cJSON_ParseWithLength (#1873) 2026-03-23 21:05:06 +08:00
cef581d723 Update dependencies in idf_component.yml to latest versions: esp-ml30… (#1867)
* Update dependencies in idf_component.yml to latest versions: esp-ml307 to ~3.6.5, uart-eth-modem to ~0.3.4, esp32-camera to ^2.1.5, lvgl to ~9.5.0, esp_lvgl_port to ~2.7.2, esp_mmap_assets to ^1.3.2, and adc_battery_estimation to ^0.2.1.

* Update esp-wifi-connect version and adjust partition sizes in 4m.csv
2026-03-22 13:05:42 +08:00
dbb8e1d409 fix(eda-robot-pro): Optimize gait algorithm (#1871) 2026-03-22 07:03:10 +08:00
-10
78008ab9d3 add refresh display theme param for Apply function (#1865) 2026-03-22 06:23:58 +08:00
cab8a7cecf Merge pull request #1868 from shunian11/main
feat: add waveshare esp32-s3-touch-lcd-1.54 and waveshare esp32-s3-lcd-0.85
2026-03-20 19:00:43 +08:00
74fa5413fc update config.json 2026-03-20 16:48:06 +08:00
cc4e12fa74 add manufacturer in config.json 2026-03-20 15:10:46 +08:00
1c195fea4f Update config.json 2026-03-20 11:11:24 +08:00
a4bf13b30d add waveshare esp32-s3-lcd-0.85 2026-03-20 11:03:29 +08:00
619dd2801c add waveshare esp32-s3-touch-lcd-1.54 2026-03-20 10:49:58 +08:00
280b2ff856 Fix crash when LAMP_GPIO or BUILTIN_LED_GPIO is GPIO_NUM_NC (#1853)
* Initial plan

* Add GPIO_NUM_NC guards for LampController and SingleLed across all board files

Boards crash when LAMP_GPIO or BUILTIN_LED_GPIO is set to GPIO_NUM_NC because
LampController and SingleLed are instantiated unconditionally.

Fix: Guard LampController with `if (LAMP_GPIO != GPIO_NUM_NC)` in InitializeTools()
and guard SingleLed with `if (BUILTIN_LED_GPIO != GPIO_NUM_NC)` in GetLed(),
falling back to NoLed when GPIO is not connected.

Fixes the reported crash on bread-compact-wifi-lcd and hardens all similar boards.

Co-authored-by: 78 <4488133+78@users.noreply.github.com>

* Fix crash when LAMP_GPIO or BUILTIN_LED_GPIO is GPIO_NUM_NC (minimal 2-file fix)

Instead of adding guards in every board file (36 files), fix the root cause
in just 2 source files:

1. SingleLed constructor: replace assert(gpio != GPIO_NUM_NC) with graceful
   early return, leaving led_strip_ as nullptr (all methods already null-check it)
2. LampController constructor: add early return when gpio_num == GPIO_NUM_NC

This fixes the crash on bread-compact-wifi-lcd and any other board where
these GPIOs are set to GPIO_NUM_NC.

Co-authored-by: 78 <4488133+78@users.noreply.github.com>

* Remove esp_timer_delete from destructor to match original behavior

Co-authored-by: 78 <4488133+78@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 78 <4488133+78@users.noreply.github.com>
2026-03-20 07:02:03 +08:00
527cd1fd58 Restore README.md and update Discord links to https://discord.gg/C759fGMBcZ in all language versions (#1862)
* Initial plan

* Restore README.md and update Discord links to https://discord.gg/C759fGMBcZ in all language versions

Co-authored-by: 78 <4488133+78@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 78 <4488133+78@users.noreply.github.com>
2026-03-19 03:54:13 +08:00
55698b2d74 Update Discord link in README.md 2026-03-19 03:25:59 +08:00
e503a32d84 feat: Add Waevshare Product ESP32-P4-WIFI6-Touch-LCD-4.3 Support (#1858)
* feat: Add Waevshare Product ESP32-P4-WIFI6-Touch-LCD-4.3 Support

* feat: Supplement README

* feat: Remove redundant conf
2026-03-19 03:05:08 +08:00
564e929aea Fix race condition in GIF emotion switching causing crash (#1848)
* Initial plan

* Fix GIF emotion switching crash: hide emoji_image_ before destroying gif_controller_

The crash was caused by a race condition in SetEmotion() where two separate
DisplayLockGuard scopes allowed the LVGL render task to access freed GIF image
data between lock scopes. By hiding emoji_image_ before destroying
gif_controller_ in the same lock scope, LVGL won't try to render the image
with freed data during the gap.

Co-authored-by: 78 <4488133+78@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: 78 <4488133+78@users.noreply.github.com>
2026-03-15 22:14:02 +08:00
bbd5f70c3c fix: add explicit UTF-8 encoding to file operations for Windows compatibility (#1845)
On Windows, Python's default encoding is the system locale (e.g., cp1252,
gbk) rather than UTF-8. This causes UnicodeDecodeError when reading
sdkconfig files, CMakeLists.txt, or JSON configs that contain non-ASCII
characters (e.g., Chinese comments, UTF-8 BOM).

Fix: Add explicit encoding='utf-8' to all text file open() calls in:
- scripts/release.py (Path.open() for config.json, CMakeLists.txt, sdkconfig)
- scripts/build_default_assets.py (io.open() for sdkconfig, open() for headers/configs)
- scripts/versions.py (open() for info.json read/write)

Relates to #1792

Co-authored-by: Nicola Spieser <redbasecap@users.noreply.github.com>
2026-03-15 18:49:49 +08:00
d35f03134f Add support for waveshare ESP32-S3-ePaper-3.97 (#1808) 2026-03-14 23:37:25 +08:00
d71841d248 Rename EchoEar to ESP-VoCat (#1827) 2026-03-11 21:20:43 +08:00
e77dedb130 Fix/echoear memory leak (#1817)
* fix(echoear): Fix spelling errors in PCB version detection

- Fixed 'pcb_verison' -> 'pcb_version' (variable and log messages)
- Fixed 'Initializest77916Display' -> 'InitializeSt77916Display' (function name)
- Fixed 'PCB verison' -> 'PCB version' in log messages

These are cosmetic fixes that improve code readability and maintainability.

* fix(echoear): Add destructor for resource cleanup

- Add destructor to clean up tasks, objects, GPIO ISR, and temperature sensor
- Store task handles to allow proper task deletion
- Skip deleting backlight_ and camera_ as their base classes lack virtual destructors
- Improves code robustness without altering functional behavior
2026-03-09 19:33:01 +08:00
1f37f7806e Bump to 2.2.4 (#1816) 2026-03-07 18:52:47 +08:00
322466d6c1 新增无名科技ABS 2.0开发板 (#1812)
Co-authored-by: Limh2017 <wenwen19115>
2026-03-07 17:56:47 +08:00
MOV
1d2b71f27e fix(esp32c5): correct PSRAM configuration (#1813)
* add: Moji 2 has built-in ESP32-C5 dual band Wi-Fi

* feat: PowerSaveTimer 160 >> 240

* fix(audio): add frame buffering to NoAudioProcessor

On chips where CONFIG_USE_AUDIO_PROCESSOR is unavailable (e.g., ESP32-C5,
which is excluded by Kconfig despite having PSRAM), NoAudioProcessor is used.
The original implementation passed through 160-sample chunks directly, but
the Opus encoder expects 960 samples (60ms @ 16kHz), resulting in frame size
mismatch errors, high CPU usage, and watchdog resets.

Added output_buffer_ to accumulate audio samples until reaching
frame_samples_ (960) before outputting, matching AfeAudioProcessor behavior.

other:
Turn off sleep mode (PowerSaveTimer)

* fix(config): enable SPIRAM for ESP32-C5 build
2026-03-06 18:35:47 +08:00
443a3ba120 Updated AudioService to prevent input task termination on read timeout, introducing a delay instead. (#1810)
* Enhance Otto Robot camera support by adding configuration for OV3660. Updated config.h to define camera types and GPIO settings, modified config.json to include new camera options, and refactored otto_robot.cc for improved camera detection and initialization logic.

* fix: 移除 OttoEmojiDisplay 构造函数中的 SetTheme 调用以修复 LoadProhibited 崩溃

Made-with: Cursor

* refactor: improve audio service error handling and codec timeout management

- Updated AudioService to prevent input task termination on read timeout, introducing a delay instead.
- Enhanced NoAudioCodec to implement a read timeout for I2S channel reads.
- Adjusted WebSocketControlServer to set a control port for improved socket management.
- Added manufacturer information to the config.json for waveshare ESP32-Touch-LCD-3.5.
2026-03-06 06:50:03 +08:00
e37a24bea8 fix: 小智云聊修正AEC记忆+适配UI setup调整 (#1809)
* fix: 小智云聊修正AEC记忆+适配UI setup调整

* feat: 小智云聊增加双击切换AEC功能
2026-03-05 19:34:16 +08:00
05f1a034ce add waveshre ESP32-Touch-LCD-3.5 (#1794)
Co-authored-by: ggc <1648371663@q.com>
2026-03-02 18:11:30 +08:00
feb61d4e45 Update README with IDF version and ESP32-P4 notes (#1801)
Add IDF version and configuration instructions for ESP32-P4 users.
2026-03-02 18:10:33 +08:00
332c65238d fix: 移除 OttoEmojiDisplay 构造函数中的 SetTheme 调用以修复 LoadProhibited 崩溃 (#1803)
* Enhance Otto Robot camera support by adding configuration for OV3660. Updated config.h to define camera types and GPIO settings, modified config.json to include new camera options, and refactored otto_robot.cc for improved camera detection and initialization logic.

* fix: 移除 OttoEmojiDisplay 构造函数中的 SetTheme 调用以修复 LoadProhibited 崩溃

Made-with: Cursor
2026-03-02 18:10:00 +08:00
dbecf229af feat:MagiClick-2p5增加硬件版本识别,增加对新屏幕的支持 (#1797)
* 增加硬件版本识别,开机自动切换不同的显示驱动

1、增加PCB版本识别
2、增加对ST7735屏幕的支持
3、删除sdk多余配置
4、增加电池均值次数

* fix lcd info
2026-03-01 04:18:51 +08:00
2542074545 fix: fixed ESP32-C6-Touch-AMOLED-2.06 target error (#1789) 2026-03-01 04:09:35 +08:00
2576801a89 add esp32-s3-touch-lcd-1.85c v2.0 (#1782)
Co-authored-by: ggc <1648371663@q.com>
2026-02-26 20:51:41 +08:00
025059aadf feat: add waveshare ESP32-S3-Touch-AMOLED-1.75C support (#1786)
* feat: add waveshare ESP32-S3-Touch-AMOLED-1.75C support

* fix: fixed ESP32-S3-Touch-AMOLED-1.75 config.json
2026-02-26 20:50:32 +08:00
b778d2f0cd fix: waveshare ESP32-S3-Touch-AMOLED-1.75 Touch bugs (#1784) 2026-02-26 01:36:34 +08:00
71c86ab62b Fix setupui (#1777)
* Enhance GitHub Actions artifact download script

- Updated the output directory structure to save downloaded files in a version-specific subdirectory (releases/<version>).
- Added a new function to determine the default releases directory path relative to the script's location.
- Improved artifact renaming logic to handle known extensions more robustly and ensure compatibility with filenames containing dots.

* Refactor UI setup in ElectronEmojiDisplay and OttoEmojiDisplay classes

- Moved SetupChatLabel call in ElectronEmojiDisplay to ensure it is executed after the parent UI is initialized, preventing potential issues with container validity.
- Updated SetupUI in OttoEmojiDisplay to release the display lock before calling SetEmotion, avoiding deadlock scenarios during UI setup.

* Add multiline chat message support in display configuration

- Introduced a new Kconfig option to enable multiline chat message display in the default mode.
- Updated the LCD display setup to accommodate a dynamic height bottom bar for multiline messages.
- Modified the configuration files for the waveshare-esp32-s3-epaper-1.54 board to include the new chat message setting.

* Update font and emoji settings for Magiclick boards; enhance bottom bar visibility logic in LCD display

- Changed the default text and emoji fonts for Magiclick S3 2P4 and S3 2P5 boards to Noto fonts.
- Improved bottom bar visibility logic in LcdDisplay to hide when there is no content, ensuring a cleaner UI experience.
2026-02-19 20:10:27 +08:00
4666ecef82 Add support for both hardware versions of waveshare-s3-epaper-1.54 (#1583)
* Add support for both hardware versions of waveshare-s3-epaper-1.54

* use v2 partition, reduced assets partition by 300k so that the app would fit

* add manufacturer

* updated readme

* CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS=n to save space

* no custom font
2026-02-19 16:52:47 +08:00
b34a9b19ba Bump project version to 2.2.3 and fix release.py (#1771)
* chore: Bump project version to 2.2.3 and refactor board configuration parsing in release script

- Updated PROJECT_VER in CMakeLists.txt from 2.2.2 to 2.2.3.
- Refactored the board configuration parsing logic in release.py to improve clarity and efficiency by eliminating the mapping function and directly searching for the board type in the CMakeLists.txt file.

* refactor: Remove unused system_info.h include and delete obsolete source root file

- Removed the inclusion of system_info.h in main.cc as it was no longer needed.
- Deleted the _codeql_detected_source_root file, which was obsolete.

* feat: Add lcd_init_cmds.h include for display initialization in esp32-p4-nano board

- Included the lcd_init_cmds.h header file in esp32-p4-nano.cc to support display initialization commands, enhancing the board's functionality.

* chore: Update build workflow and board configuration files

- Changed the build job name in the GitHub Actions workflow to use the full name of the matrix variant for better clarity.
- Refactored CMakeLists.txt to improve the formatting of the BOARD_SOURCES file globbing.
- Added manufacturer information to the config.json files for the eda-robot-pro, eda-super-bear, and eda-tv-pro boards to ensure consistency in configuration.
2026-02-17 15:54:22 +08:00
cf59430991 chore: Update esp-wifi-connect version in idf_component.yml and refac… (#1769)
* chore: Update esp-wifi-connect version in idf_component.yml and refactor event callback in WifiBoard class

- Bumped esp-wifi-connect version from ~3.0.2 to ~3.1.1 in idf_component.yml.
- Refactored the event callback in WifiBoard to use a string parameter for SSID data, improving clarity and consistency in network event handling.

* refactor: Change logging level for OpusHead and OpusTags detection in OggDemuxer

- Updated log statements from ESP_LOGI to ESP_LOGD for OpusHead and OpusTags found events, improving log verbosity control.
2026-02-16 10:10:39 +08:00
MOV
8e34995944 fix: add frame buffering to NoAudioProcessor for ESP32-C5 (#1766)
* add: Moji 2 has built-in ESP32-C5 dual band Wi-Fi

* feat: PowerSaveTimer 160 >> 240

* fix(audio): add frame buffering to NoAudioProcessor

On chips where CONFIG_USE_AUDIO_PROCESSOR is unavailable (e.g., ESP32-C5,
which is excluded by Kconfig despite having PSRAM), NoAudioProcessor is used.
The original implementation passed through 160-sample chunks directly, but
the Opus encoder expects 960 samples (60ms @ 16kHz), resulting in frame size
mismatch errors, high CPU usage, and watchdog resets.

Added output_buffer_ to accumulate audio samples until reaching
frame_samples_ (960) before outputting, matching AfeAudioProcessor behavior.

other:
Turn off sleep mode (PowerSaveTimer)
2026-02-13 22:50:58 +08:00
fe66f39ecc Update low battery popup handling in LvglDisplay class (#1765)
- Adjusted the logic to check for low battery popup visibility only during clock tick events, ensuring it does not trigger during initialization when battery level is not ready.
- Implemented a scheduled task to play the low battery sound, improving responsiveness and avoiding immediate playback during UI updates.
2026-02-13 16:18:05 +08:00
87cff80098 M5Stack Tab5's backlight doesn't need PWM inversion - the official demo uses direct duty cycle without any inversion flag. (#1764)
uint32_t duty_cycle = (4095 * brightness_percent) / 100;
ledc_set_duty(LEDC_LOW_SPEED_MODE, LCD_LEDC_CH, duty_cycle);

https://github.com/m5stack/M5Tab5-UserDemo/blob/main/platforms/tab5/components/m5stack_tab5/m5stack_tab5.c#L1088

Tested on M5Stack Tab5, the screen brightness is working correctly now, previously it's reversed.
2026-02-13 13:10:19 +08:00
d545f746bc feat: Add project support for the EDA course case team (#1758)
* Add EDA Education Board Configuration

- Add eda-tv-pro Board Configuration
- Add eda-robot-pro Board Configuration
- Add eda-super-bear Board Configuration

* docs(oscillator): Add file headers with copyright and license information

- Add comprehensive file headers to oscillator.cc and oscillator.h in eda-robot-pro board
- Add comprehensive file headers to oscillator.cc and oscillator.h in eda-super-bear board
- Include original author attribution (Juan Gonzalez-Gomez/Obijuan) and ESP32 port credit (txp666)
- Include GPL license notice and file descriptions for clarity and compliance

* fix: Move the eda* boards to the lceda-course-examples folder.

- Move eda-robot-pro, eda-super-bear, and eda-tv-pro boards to lceda-course-examples subdirectory
- Update CMakeLists.txt to set MANUFACTURER variable for each board configuration
2026-02-13 04:17:38 +08:00
6be351b5a0 Adjust jiuchuan-s3 UI (#1747)
* 修复SetupUI接口引入导致的屏幕适配失败问题

* 修正board中屏幕高度错误,调整偏移量

---------

Co-authored-by: smalllin0 <aslinqf@163.com>
Co-authored-by: Xiaoxia <terrence@tenclass.com>
2026-02-09 19:16:49 +08:00
d9447ad060 fix: Enhance UI setup across multiple boards (#1753)
* chore: Update component versions and enhance UI setup across multiple boards

- Bumped uart-eth-modem version from ~0.3.2 to ~0.3.3 in idf_component.yml.
- Added SetupUI method to various display classes to ensure proper UI initialization before usage.
- Improved error handling in display classes to prevent issues when UI is not set up.
- Ensured UI customization is performed in SetupUI rather than constructors for better reliability.

* remove pm config code
2026-02-09 19:13:14 +08:00
9215a04a7e Delay init success sound playback and remove gif playback delay (#1748)
* refactor: Remove hardcoded loop delay for GIF playback in LcdDisplay class

* chore: Update esp-ml307 and uart-eth-modem component versions in idf_component.yml

- Bump esp-ml307 version from ~3.6.3 to ~3.6.4
- Update uart-eth-modem version from ~0.3.1 to ~0.3.2

* feat: Add PrintPmLocks method to SystemInfo class

- Introduced PrintPmLocks method to display power management locks using esp_pm_dump_locks.
- Updated system_info.h to declare the new method.

* refactor: Streamline audio codec initialization and enablement

- Removed redundant channel enable checks from AudioCodec::Start.
- Added channel enablement in CreateDuplexChannels for various audio codecs.
- Implemented EnableInput and EnableOutput methods in NoAudioCodec for better control over input/output states.

* refactor: Delay audio success sound playback until after activation completion

- Moved the success sound playback to a scheduled task to ensure it occurs after the activation process is complete.
- This change improves the responsiveness of the application during activation events.

* refactor: Update camera integration from EspVideo to Esp32Camera

- Replaced EspVideo with Esp32Camera for improved camera configuration and initialization.
- Streamlined camera setup by utilizing a new configuration structure for better clarity and maintainability.
- Updated README.md to remove outdated camera sensor configuration instructions.

* refactor: Update audio demuxing process in AudioService

- Replaced the existing demuxer instance with a local unique pointer in the PlaySound method for better memory management.
- Moved the OnDemuxerFinished callback setup into the PlaySound method to ensure it is correctly associated with the new demuxer instance.
- Removed the member variable demuxer_ from AudioService to streamline the class structure.
2026-02-08 22:09:45 +08:00
7b7d22c495 feat: modify CircularStrip constructor parameter types and add SetMultiColors method (#1750) 2026-02-08 11:17:12 +08:00
b4eada876a feat&fix: 小智云聊增加蓝牙功能 (#1732) 2026-02-07 11:02:03 +08:00
49cd6625f4 增加流式ogg解封装支持 (#1705)
* 增加流式ogg解封装支持

* 增加TF卡引脚连接说明

* 修复圆角图标屏幕导致的显示问题

* 优化聊天消息显示

* 修改解封装实现

---------

Co-authored-by: smalllin0 <aslinqf@163.com>
2026-02-05 00:12:40 +08:00
6f71868bad fix: Categorizing waveshare products (#1734)
content: Update the README.md

ci: Adapt to the lower directory

ci: the lower directory support

ci: board type errors fixed.

ci: board_type errors fixed to board_leaf.

fix: A naming error problem

fix: jd9365 member error

fix: Update product naming

feat: Add manufacturer parameters to the firmware name for 'release'

fix: Verification logic

fix: Verification logic
2026-02-04 21:03:23 +08:00
173eaa7463 在Display基类中添加SetupUI虚函数 (#1742)
Signed-off-by: jeck.chen <jeck.chen@dbappsecurity.com.cn>
2026-02-04 21:03:08 +08:00
2b025c4ea6 Enhance audio processing and wake word detection (#1739)
* Enhance audio processing and wake word detection

- Set task priority in Application::Run to improve responsiveness.
- Log detected wake words with their state in HandleWakeWordDetectedEvent.
- Streamline audio feeding in AudioService to handle both wake word and audio processor events.
- Implement input buffering in AfeAudioProcessor, AfeWakeWord, CustomWakeWord, and EspWakeWord to manage audio data more efficiently.
- Clear input buffers on stop to prevent residual data issues.

* Refactor audio processing to enhance thread safety and state management

- Implement early return checks in Feed methods of AfeAudioProcessor, AfeWakeWord, CustomWakeWord, and EspWakeWord to prevent processing when not running.
- Introduce std::atomic for running state in CustomWakeWord and EspWakeWord to ensure thread-safe access.
- Consolidate input buffer management with mutex locks to avoid race conditions during Stop and Feed operations.

* Refactor listening mode handling and wake word detection configuration

- Replace direct mode setting logic with a new GetDefaultListeningMode method for improved clarity and maintainability.
- Update HandleToggleChatEvent, HandleWakeWordDetectedEvent, and ContinueWakeWordInvoke to utilize the new method for determining listening mode.
- Introduce Kconfig option WAKE_WORD_DETECTION_IN_LISTENING to enable or disable wake word detection during listening mode, enhancing configurability.
2026-02-04 14:28:21 +08:00
37110a9d05 Fix: esp32camera pixel byte order and uart-uhci compiling error (#1728)
* Fix: uart-uhci compiling errors

* Enhance Esp32Camera functionality by adding optional byte swapping for RGB565 format. Introduce SetSwapBytes method to enable/disable byte order swapping, and update Capture method to utilize an encode buffer for improved memory management and performance during image processing.
2026-02-02 15:33:32 +08:00
796312db4c Enhance Otto Robot camera support by adding configuration for OV3660. (#1726) 2026-02-02 10:22:53 +08:00
9e1724e892 feat: add M5Stack Cardputer Adv board support (#1718)
Add support for M5Stack Cardputer Adv, a card-sized computer based on
ESP32-S3FN8 (Stamp-S3A) with the following features:

Hardware:
- MCU: ESP32-S3FN8 @ 240MHz, 8MB Flash (no PSRAM)
- Display: ST7789V2 1.14" 240x135
- Audio: ES8311 codec + NS4150B amplifier
- Keyboard: 56-key via TCA8418
- IMU: BMI270

Key configurations:
- SPI3_HOST with 3-wire SPI mode for display
- 256Hz PWM frequency for backlight (matching M5GFX)
- ES8311 with use_mclk=false (no MCLK pin)
- Display offset X=40, Y=52 for correct alignment

新增 M5Stack Cardputer Adv 开发板支持

支持基于 ESP32-S3FN8 (Stamp-S3A) 的卡片式电脑 M5Stack Cardputer Adv:

硬件规格:
- MCU: ESP32-S3FN8 @ 240MHz, 8MB Flash (无 PSRAM)
- 显示屏: ST7789V2 1.14" 240x135
- 音频: ES8311 编解码器 + NS4150B 功放
- 键盘: 56键 (TCA8418)
- IMU: BMI270

关键配置:
- 显示使用 SPI3_HOST 和 3-wire SPI 模式
- 背光 PWM 频率 256Hz (与 M5GFX 一致)
- ES8311 设置 use_mclk=false (无 MCLK 引脚)
- 显示偏移 X=40, Y=52 以正确对齐
2026-02-02 10:01:36 +08:00
0b3b98eca7 Update esp-ml307 component version to 3.6.2 to support UART DMA (#1724)
* Update esp-ml307 dependency version to ~3.6.0 in idf_component.yml

* Update .gitignore to include 'dist/' directory, add ml307 and dual_network_board source files to CMakeLists.txt, and update esp-ml307 dependency version to ~3.6.2 in idf_component.yml. Refactor CompactWifiBoard and CompactWifiBoardLCD classes to inherit from WifiBoard instead of DualNetworkBoard, simplifying network handling logic.
2026-02-02 09:53:06 +08:00
abd62648cb Implement early return in AfeWakeWord::Feed to prevent processing when detection is not running. This enhances the robustness of the wake word detection logic. (#1723) 2026-02-01 17:42:34 +08:00
0883a36537 Refactor audio channel handling and wake word detection in Application class (#1722)
- Introduced ContinueOpenAudioChannel and ContinueWakeWordInvoke methods to streamline audio channel management and wake word processing.
- Updated HandleToggleChatEvent and HandleWakeWordDetectedEvent to utilize scheduling for state changes, improving UI responsiveness.
- Simplified logic for setting listening modes based on audio channel state, enhancing code clarity and maintainability.
2026-02-01 14:55:47 +08:00
b6c61fe390 Update project version to 2.2.2, Noto fonts and emoji support. (#1720) 2026-02-01 01:04:24 +08:00
f7284a57df Enhance memory management in asset download and OTA processes by repl… (#1716)
* Enhance memory management in asset download and OTA processes by replacing static buffer allocations with dynamic memory allocation using heap capabilities. Update SPIRAM configuration values for improved memory usage. Add logging for error handling in buffer allocation failures. Introduce a new parameter in CloseAudioChannel to control goodbye message sending in MQTT and WebSocket protocols.

* Update component versions in idf_component.yml and refactor GIF decoder functions for improved performance. Bump versions for audio effects, audio codec, LED strip, and other dependencies. Change GIF read and seek functions to inline for optimization.

* Update language files to include new phrases for flight mode and connection status across multiple locales. Added translations for "FLIGHT_MODE_ON", "FLIGHT_MODE_OFF", "CONNECTION_SUCCESSFUL", and "MODEM_INIT_ERROR" in various languages, enhancing user experience and localization support.

* fix wechat display
2026-01-31 22:58:08 +08:00
96f34ec70f Refactor emoji initialization for Electron and Otto boards to use Assets system (#1704)
* otto v1.4.0 MCP

1.使用MCP协议控制机器人
2.gif继承lcdDisplay,避免修改lcdDisplay

* otto v1.4.1 gif as components

gif as components

* electronBot v1.1.0 mcp

1.增加electronBot支持
2.mcp协议
3.gif 作为组件
4.display子类

* 规范代码

1.规范代码
2.修复切换主题死机bug

* fix(ota): 修复 ottoRobot和electronBot OTA 升级崩溃问题 bug

* 1.增加robot舵机初始位置校准
2.fix(mcp_sever) 超出范围异常捕获类型  bug

* refactor: Update Electron and Otto emoji display implementations

- Removed GIF selection from Kconfig for Electron and Otto boards.
- Updated Electron and Otto bot versions to 2.0.4 in their respective config files.
- Refactored emoji display classes to utilize EmojiCollection for managing emojis.
- Enhanced chat label setup and status display functionality in both classes.
- Cleaned up unused code and improved initialization logging for emoji displays.

* Rename OTTO_ICON_FONT.c to otto_icon_font.c

* Rename OTTO_ICON_FONT.c to otto_icon_font.c

* refactor: Update Otto emoji display configurations and functionalities

- Changed chat label text mode to circular scrolling for both Otto and Electron emoji displays.
- Bumped Otto robot version to 2.0.5 in the configuration file.
- Added new actions for Otto robot including Sit, WhirlwindLeg, Fitness, Greeting, Shy, RadioCalisthenics, MagicCircle, and Showcase.
- Enhanced servo sequence handling and added support for executing custom servo sequences.
- Improved logging and error handling for servo sequence execution.

* refactor: Update chat label long mode for Electron and Otto emoji displays

- Changed chat label text mode from wrap to circular scrolling for both Electron and Otto emoji displays.
- Improved consistency in chat label setup across both implementations.

* Update Otto robot README with new actions and parameters

* Update Otto controller parameters for oscillation settings

- Changed default oscillation period from 500ms to 300ms.
- Increased default steps from 5.0 to 8.0.
- Updated default amplitude from 20 degrees to 0 degrees.
- Enhanced documentation with new examples for oscillation modes and sequences.

* Fix default amplitude initialization in Otto controller to use a single zero instead of two digits.

* chore: update txp666/otto-emoji-gif-component version to 1.0.3 in idf_component.yml

* Refactor Otto controller
- Consolidated movement actions into a unified tool for the Otto robot, allowing for a single action command with various parameters.
- Removed individual movement tools (walk, turn, jump, etc.) and replaced them with a more flexible action system.

* Enhance Otto robot functionality by adding WebSocket control server and IP address retrieval feature. Updated config to support WebSocket, and revised README to include new control options and usage examples.

* Add camera support for Otto Robot board

- Introduced configuration option to enable the Otto Robot camera in Kconfig.
- Updated config.h to define camera-related GPIO pins and settings.
- Modified config.json to include camera configuration.
- Enhanced otto_robot.cc to initialize I2C and camera components when the camera is enabled.
- Adjusted power_manager.h to manage battery updates during camera operations.
- Removed unused SetupChatLabel method from OttoEmojiDisplay class.

* Refactor Otto Robot configuration and initialization

- Removed the camera configuration option from Kconfig and related code.
- Introduced a new HardwareConfig struct to encapsulate hardware pin definitions and settings.
- Updated config.h to define camera and non-camera configurations using the new struct.
- Refactored otto_controller.cc and otto_robot.cc to utilize the HardwareConfig struct for initialization.
- Enhanced camera detection and initialization logic based on hardware version.
- Improved audio codec initialization based on configuration settings.

* Refactor emoji initialization for Electron and Otto boards to use Assets system

- Removed direct emoji initialization from `InitializeElectronEmojis` and `InitializeOttoEmojis` methods, delegating the responsibility to the Assets system.
- Updated `CMakeLists.txt` to set `DEFAULT_EMOJI_COLLECTION` to `otto-gif` for both boards.
- Enhanced `build_default_assets.py` to support alias mapping for Otto GIF emojis.
- Updated `idf_component.yml` to bump `otto-emoji-gif-component` version to `^1.0.5` for improved functionality.
2026-01-31 18:13:15 +08:00
aad2f60b87 fix: reset esp-box-3 display to lvgl (#1715) 2026-01-31 03:14:50 +08:00
414 changed files with 24544 additions and 3754 deletions

View File

@ -20,7 +20,7 @@ jobs:
variants: ${{ steps.select.outputs.variants }}
steps:
- name: Checkout
uses: actions/checkout@v4
uses: actions/checkout@v6
with:
fetch-depth: 0
@ -83,7 +83,7 @@ jobs:
fi
build:
name: Build ${{ matrix.name }}
name: Build ${{ matrix.full_name }}
needs: prepare
if: ${{ needs.prepare.outputs.variants != '[]' }}
strategy:
@ -106,6 +106,6 @@ jobs:
- name: Upload artifacts
uses: actions/upload-artifact@v4
with:
name: xiaozhi_${{ matrix.name }}_${{ github.sha }}.bin
name: xiaozhi_${{ matrix.full_name }}_${{ github.sha }}
path: build/merged-binary.bin
if-no-files-found: error

2
.gitignore vendored
View File

@ -2,6 +2,7 @@ tmp/
components/
managed_components/
build/
dist/
.vscode/
.devcontainer/
sdkconfig.old
@ -9,6 +10,7 @@ sdkconfig
dependencies.lock
.env
releases/
vision_frames/
main/assets/lang_config.h
main/mmap_generate_emoji.h
.DS_Store

View File

@ -9,5 +9,5 @@ include($ENV{IDF_PATH}/tools/cmake/project.cmake)
# "Trim" the build. Include the minimal set of components, main, and anything it depends on.
idf_build_set_property(MINIMAL_BUILD ON)
set(PROJECT_VER "2.2.1")
set(PROJECT_VER "2.2.6")
project(xiaozhi)

View File

@ -160,7 +160,7 @@ This is an open-source ESP32 project, released under the MIT license, allowing a
We hope this project helps everyone understand AI hardware development and apply rapidly evolving large language models to real hardware devices.
If you have any ideas or suggestions, please feel free to raise Issues or join our [Discord](https://discord.gg/bXqgAfRm) or QQ group: 994694848
If you have any ideas or suggestions, please feel free to raise Issues or join our [Discord](https://discord.gg/C759fGMBcZ) or QQ group: 994694848
## Star History
@ -170,4 +170,4 @@ If you have any ideas or suggestions, please feel free to raise Issues or join o
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/svg?repos=78/xiaozhi-esp32&type=Date" />
<img alt="Star History Chart" src="https://api.star-history.com/svg?repos=78/xiaozhi-esp32&type=Date" />
</picture>
</a>
</a>

View File

@ -156,7 +156,7 @@ Feishuドキュメントチュートリアルをご覧ください
このプロジェクトを通じて、AIハードウェア開発を理解し、急速に進化する大規模言語モデルを実際のハードウェアデバイスに応用できるようになることを目指しています。
ご意見やご提案があれば、いつでもIssueを提出するか、[Discord](https://discord.gg/bXqgAfRm) または QQグループ1011329060 にご参加ください。
ご意見やご提案があれば、いつでもIssueを提出するか、[Discord](https://discord.gg/C759fGMBcZ) または QQグループ1011329060 にご参加ください。
## スター履歴

View File

@ -24,7 +24,7 @@ v1 的稳定版本为 1.9.2,可以通过 `git checkout v1` 来切换到 v1 版
- Wi-Fi / ML307 Cat.1 4G
- 离线语音唤醒 [ESP-SR](https://github.com/espressif/esp-sr)
- 支持两种通信协议([Websocket](docs/websocket.md) 或 MQTT+UDP
- 支持两种通信协议([Websocket](docs/websocket_zh.md) 或 MQTT+UDP
- 采用 OPUS 音频编解码
- 基于流式 ASR + LLM + TTS 架构的语音交互
- 声纹识别,识别当前说话人的身份 [3D Speaker](https://github.com/modelscope/3D-Speaker)
@ -121,11 +121,11 @@ v1 的稳定版本为 1.9.2,可以通过 `git checkout v1` 来切换到 v1 版
### 开发者文档
- [自定义开发板指南](docs/custom-board.md) - 学习如何为小智 AI 创建自定义开发板
- [MCP 协议物联网控制用法说明](docs/mcp-usage.md) - 了解如何通过 MCP 协议控制物联网设备
- [MCP 协议交互流程](docs/mcp-protocol.md) - 设备端 MCP 协议的实现方式
- [MQTT + UDP 混合通信协议文档](docs/mqtt-udp.md)
- [一份详细的 WebSocket 通信协议文档](docs/websocket.md)
- [自定义开发板指南](docs/custom-board_zh.md) - 学习如何为小智 AI 创建自定义开发板
- [MCP 协议物联网控制用法说明](docs/mcp-usage_zh.md) - 了解如何通过 MCP 协议控制物联网设备
- [MCP 协议交互流程](docs/mcp-protocol_zh.md) - 设备端 MCP 协议的实现方式
- [MQTT + UDP 混合通信协议文档](docs/mqtt-udp_zh.md)
- [一份详细的 WebSocket 通信协议文档](docs/websocket_zh.md)
## 大模型配置
@ -156,7 +156,7 @@ v1 的稳定版本为 1.9.2,可以通过 `git checkout v1` 来切换到 v1 版
我们希望通过这个项目,能够帮助大家了解 AI 硬件开发,将当下飞速发展的大语言模型应用到实际的硬件设备中。
如果你有任何想法或建议,请随时提出 Issues 或加入 [Discord](https://discord.gg/bXqgAfRm) 或 QQ 群1011329060
如果你有任何想法或建议,请随时提出 Issues 或加入 [Discord](https://discord.gg/C759fGMBcZ) 或 QQ 群1011329060
## Star History

View File

@ -1 +0,0 @@
.

View File

@ -1,37 +1,34 @@
# BluFi 配网(集成 esp-wifi-connect
# BluFi Provisioning (with `esp-wifi-connect`)
本文档说明如何在小智固件中启用和使用 BluFiBLE WiFi 配网),并结合项目内置的 `esp-wifi-connect` 组件完成 WiFi 连接与存储。官方
BluFi
协议说明请参考 [Espressif 文档](https://docs.espressif.com/projects/esp-idf/zh_CN/stable/esp32/api-guides/ble/blufi.html)。
This document explains how to enable and use BluFi (BLE-based WiFi provisioning) in the XiaoZhi firmware, together with the in-tree `esp-wifi-connect` component that handles WiFi connection and credential storage. See the official [Espressif BluFi documentation](https://docs.espressif.com/projects/esp-idf/en/stable/esp32/api-guides/ble/blufi.html) for the protocol details.
## 前置条件
## Prerequisites
- 需要支持 BLE 的芯片与固件配置。
- `idf.py menuconfig` 中启用 `WiFi Configuration Method -> Esp Blufi``CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING=y`
)。如果想使用 BluFi必须关闭同一菜单下的 Hotspot 选项,否则默认使用 Hotspot 配网模式。
- A chip and firmware configuration that support BLE.
- In `idf.py menuconfig`, enable `WiFi Configuration Method -> Esp Blufi` (`CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING=y`). If you want to use BluFi, disable the Hotspot option in the same menu; otherwise hotspot provisioning wins by default.
- Keep the default NVS and event-loop initialization provided by the project's `app_main`.
- Exactly one of `CONFIG_BT_BLUEDROID_ENABLED` / `CONFIG_BT_NIMBLE_ENABLED` must be selected; they are mutually exclusive.
- 保持默认的 NVS 与事件循环初始化(项目的 `app_main` 已处理)。
- CONFIG_BT_BLUEDROID_ENABLED、CONFIG_BT_NIMBLE_ENABLED这两个宏应二选一不能同时启用。
## 工作流程
## Workflow
1) 手机端通过 BluFi如官方 EspBlufi App 或自研客户端)连接设备,发送 WiFi SSID/密码手机端可以通过blufi协议获取设备端扫描到的WiFi列表。
2) 设备侧在 `ESP_BLUFI_EVENT_REQ_CONNECT_TO_AP` 中将凭据写入 `SsidManager`(存储到 NVS属于 `esp-wifi-connect` 组件)。
3) 随后启动 `WifiStation` 扫描并连接;状态通过 BluFi 返回。
4) 配网成功后设备会自动连接新 WiFi;失败则返回失败状态。
1. A phone (using the official EspBlufi app or another BluFi client) connects to the device over BLE and sends the target WiFi SSID / password. The phone can also request the list of WiFi networks scanned by the device through the BluFi protocol.
2. In `ESP_BLUFI_EVENT_REQ_CONNECT_TO_AP`, the device stores the credentials into `SsidManager` (persisted in NVS by the `esp-wifi-connect` component).
3. The device then launches `WifiStation` to scan and connect; progress is reported back over BluFi.
4. If provisioning succeeds, the device connects to the new WiFi automatically. If it fails, an error status is sent back.
## 使用步骤
## Steps
1. 配置:在 menuconfig 开启 `Esp Blufi`。编译并烧录固件。
2. 触发配网:设备首次启动且没有已保存的 WiFi 时会自动进入配网。
3. 手机端操作:打开 EspBlufi App(或其他 BluFi 客户端),搜索并连接设备,可以选择是否加密,按提示输入 WiFi SSID/密码并发送。
4. 观察结果:
- 成功BluFi 报告连接成功,设备自动连接 WiFi
- 失败BluFi 返回失败状态,可重新发送或检查路由器。
1. **Configure**: turn on `Esp Blufi` in menuconfig, then build and flash the firmware.
2. **Trigger provisioning**: at first boot with no stored WiFi credentials the device enters provisioning automatically.
3. **Phone side**: open the EspBlufi app (or another BluFi client), scan and connect to the device, optionally enable encryption, then enter the WiFi SSID / password and send them.
4. **Observe the result**:
- Success: BluFi reports success and the device connects to WiFi.
- Failure: BluFi reports failure; retry or check the router.
## 注意事项
## Notes
- BluFi 配网不支持与热点配网同时开启。如果热点配网已经启动,则默认使用热点配网。请在 menuconfig 中只保留一种配网方式。
- 若多次测试,建议清除或覆盖存储的 SSID`wifi` 命名空间),避免旧配置干扰。
- 如果使用自定义 BluFi 客户端,需遵循官方协议帧格式,参考上文官方文档链接。
- 官方文档中已提供EspBlufi APP下载地址
- 由于IDF5.5.2的blufi接口发生变化,5.5.2版本编译后蓝牙名称为"Xiaozhi-Blufi",5.5.1版本中蓝牙名称为"BLUFI_DEVICE"
- BluFi cannot be used at the same time as hotspot provisioning. If hotspot provisioning is already enabled, the device will use it. Keep only one provisioning method in menuconfig.
- When running repeated tests, clear or overwrite the stored SSID (`wifi` NVS namespace) to avoid stale credentials interfering with the next run.
- If you write your own BluFi client, follow the official protocol frame format linked above.
- The EspBlufi app download links are listed in the official documentation.
- Because the BluFi API changed in IDF 5.5.2, firmware built with 5.5.2 advertises the Bluetooth name as `"Xiaozhi-Blufi"`, while 5.5.1 uses `"BLUFI_DEVICE"`.

37
docs/blufi_zh.md Normal file
View File

@ -0,0 +1,37 @@
# BluFi 配网(集成 esp-wifi-connect
本文档说明如何在小智固件中启用和使用 BluFiBLE WiFi 配网),并结合项目内置的 `esp-wifi-connect` 组件完成 WiFi 连接与存储。官方
BluFi
协议说明请参考 [Espressif 文档](https://docs.espressif.com/projects/esp-idf/zh_CN/stable/esp32/api-guides/ble/blufi.html)。
## 前置条件
- 需要支持 BLE 的芯片与固件配置。
-`idf.py menuconfig` 中启用 `WiFi Configuration Method -> Esp Blufi``CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING=y`
)。如果想使用 BluFi必须关闭同一菜单下的 Hotspot 选项,否则默认使用 Hotspot 配网模式。
- 保持默认的 NVS 与事件循环初始化(项目的 `app_main` 已处理)。
- CONFIG_BT_BLUEDROID_ENABLED、CONFIG_BT_NIMBLE_ENABLED这两个宏应二选一不能同时启用。
## 工作流程
1) 手机端通过 BluFi如官方 EspBlufi App 或自研客户端)连接设备,发送 WiFi SSID/密码手机端可以通过blufi协议获取设备端扫描到的WiFi列表。
2) 设备侧在 `ESP_BLUFI_EVENT_REQ_CONNECT_TO_AP` 中将凭据写入 `SsidManager`(存储到 NVS属于 `esp-wifi-connect` 组件)。
3) 随后启动 `WifiStation` 扫描并连接;状态通过 BluFi 返回。
4) 配网成功后设备会自动连接新 WiFi失败则返回失败状态。
## 使用步骤
1. 配置:在 menuconfig 开启 `Esp Blufi`。编译并烧录固件。
2. 触发配网:设备首次启动且没有已保存的 WiFi 时会自动进入配网。
3. 手机端操作:打开 EspBlufi App或其他 BluFi 客户端),搜索并连接设备,可以选择是否加密,按提示输入 WiFi SSID/密码并发送。
4. 观察结果:
- 成功BluFi 报告连接成功,设备自动连接 WiFi。
- 失败BluFi 返回失败状态,可重新发送或检查路由器。
## 注意事项
- BluFi 配网不支持与热点配网同时开启。如果热点配网已经启动,则默认使用热点配网。请在 menuconfig 中只保留一种配网方式。
- 若多次测试,建议清除或覆盖存储的 SSID`wifi` 命名空间),避免旧配置干扰。
- 如果使用自定义 BluFi 客户端,需遵循官方协议帧格式,参考上文官方文档链接。
- 官方文档中已提供EspBlufi APP下载地址
- 由于IDF5.5.2的blufi接口发生变化,5.5.2版本编译后蓝牙名称为"Xiaozhi-Blufi",5.5.1版本中蓝牙名称为"BLUFI_DEVICE"

View File

@ -1,91 +1,90 @@
# 代码风格指南
# Code Style Guide
## 代码格式化工具
## Formatting Tool
本项目使用 clang-format 工具来统一代码风格。我们已经在项目根目录下提供了 `.clang-format` 配置文件,该配置基于 Google C++ 风格指南,并做了一些自定义调整。
This project uses `clang-format` to keep the code style consistent. The `.clang-format` file in the project root is based on the Google C++ style guide with a few project-specific tweaks.
### 安装 clang-format
### Installing clang-format
在使用之前,请确保你已经安装了 clang-format 工具:
Make sure `clang-format` is available before you use it:
- **Windows**
- **Windows**:
```powershell
winget install LLVM
# 或者使用 Chocolatey
# or with Chocolatey
choco install llvm
```
- **Linux**
- **Linux**:
```bash
sudo apt install clang-format # Ubuntu/Debian
sudo dnf install clang-tools-extra # Fedora
sudo apt install clang-format # Ubuntu/Debian
sudo dnf install clang-tools-extra # Fedora
```
- **macOS**
- **macOS**:
```bash
brew install clang-format
```
### 使用方法
### Usage
1. **格式化单个文件**
1. **Format a single file**:
```bash
clang-format -i path/to/your/file.cpp
```
2. **格式化整个项目**
2. **Format the entire project**:
```bash
# 在项目根目录下执行
find main -iname *.h -o -iname *.cc | xargs clang-format -i
# Run from the project root
find main -iname '*.h' -o -iname '*.cc' | xargs clang-format -i
```
3. **在提交代码前检查格式**
3. **Check formatting without modifying files (useful in CI / pre-commit)**:
```bash
# 检查文件格式是否符合规范(不修改文件)
clang-format --dry-run -Werror path/to/your/file.cpp
```
### IDE 集成
### IDE Integration
- **Visual Studio Code**
1. 安装 C/C++ 扩展
2. 在设置中启用 `C_Cpp.formatting` `clang-format`
3. 可以设置保存时自动格式化:`editor.formatOnSave: true`
- **Visual Studio Code**:
1. Install the C/C++ extension.
2. Set `C_Cpp.formatting` to `clangFormat` in settings.
3. Optionally enable `editor.formatOnSave`.
- **CLion**
1. 在设置中选择 `Editor > Code Style > C/C++`
2. `Formatter` 设置为 `clang-format`
3. 选择使用项目中的 `.clang-format` 配置文件
- **CLion**:
1. Open `Editor > Code Style > C/C++` in the settings.
2. Set `Formatter` to `clang-format`.
3. Choose "use the .clang-format file in the project".
### 主要格式规则
### Main Rules
- 缩进使用 4 个空格
- 行宽限制为 100 字符
- 大括号采用 Attach 风格(与控制语句在同一行)
- 指针和引用符号靠左对齐
- 自动排序头文件包含
- 类访问修饰符缩进为 -4 空格
- Indent with 4 spaces.
- Line width capped at 100 characters.
- Attach-style braces (`{` on the same line as the control statement).
- Pointers and references bind to the type (left alignment).
- Includes are sorted automatically.
- Access specifiers are indented by -4 spaces.
### 注意事项
### Notes
1. 提交代码前请确保代码已经过格式化
2. 不要手动调整已格式化的代码对齐
3. 如果某段代码不希望被格式化,可以使用以下注释包围:
1. Make sure the code has been formatted before committing.
2. Do not fix up alignment by hand after running clang-format.
3. To exclude a block from formatting, wrap it with:
```cpp
// clang-format off
// 你的代码
your code
// clang-format on
```
### 常见问题
### FAQ
1. **格式化失败**
- 检查 clang-format 版本是否过低
- 确认文件编码为 UTF-8
- 验证 .clang-format 文件语法是否正确
1. **Formatting fails**:
- Check whether `clang-format` is too old.
- Make sure the file is UTF-8 encoded.
- Validate the syntax of your `.clang-format` file.
2. **与期望格式不符**
- 检查是否使用了项目根目录下的 .clang-format 配置
- 确认没有其他位置的 .clang-format 文件被优先使用
2. **Output differs from what you expected**:
- Verify that the `.clang-format` in the project root is actually picked up.
- Make sure no other `.clang-format` higher in the tree is winning.
如有任何问题或建议,欢迎提出 issue pull request
Questions and suggestions are welcome - please open an issue or a pull request.

91
docs/code_style_zh.md Normal file
View File

@ -0,0 +1,91 @@
# 代码风格指南
## 代码格式化工具
本项目使用 clang-format 工具来统一代码风格。我们已经在项目根目录下提供了 `.clang-format` 配置文件,该配置基于 Google C++ 风格指南,并做了一些自定义调整。
### 安装 clang-format
在使用之前,请确保你已经安装了 clang-format 工具:
- **Windows**
```powershell
winget install LLVM
# 或者使用 Chocolatey
choco install llvm
```
- **Linux**
```bash
sudo apt install clang-format # Ubuntu/Debian
sudo dnf install clang-tools-extra # Fedora
```
- **macOS**
```bash
brew install clang-format
```
### 使用方法
1. **格式化单个文件**
```bash
clang-format -i path/to/your/file.cpp
```
2. **格式化整个项目**
```bash
# 在项目根目录下执行
find main -iname *.h -o -iname *.cc | xargs clang-format -i
```
3. **在提交代码前检查格式**
```bash
# 检查文件格式是否符合规范(不修改文件)
clang-format --dry-run -Werror path/to/your/file.cpp
```
### IDE 集成
- **Visual Studio Code**
1. 安装 C/C++ 扩展
2. 在设置中启用 `C_Cpp.formatting` 为 `clang-format`
3. 可以设置保存时自动格式化:`editor.formatOnSave: true`
- **CLion**
1. 在设置中选择 `Editor > Code Style > C/C++`
2. 将 `Formatter` 设置为 `clang-format`
3. 选择使用项目中的 `.clang-format` 配置文件
### 主要格式规则
- 缩进使用 4 个空格
- 行宽限制为 100 字符
- 大括号采用 Attach 风格(与控制语句在同一行)
- 指针和引用符号靠左对齐
- 自动排序头文件包含
- 类访问修饰符缩进为 -4 空格
### 注意事项
1. 提交代码前请确保代码已经过格式化
2. 不要手动调整已格式化的代码对齐
3. 如果某段代码不希望被格式化,可以使用以下注释包围:
```cpp
// clang-format off
// 你的代码
// clang-format on
```
### 常见问题
1. **格式化失败**
- 检查 clang-format 版本是否过低
- 确认文件编码为 UTF-8
- 验证 .clang-format 文件语法是否正确
2. **与期望格式不符**
- 检查是否使用了项目根目录下的 .clang-format 配置
- 确认没有其他位置的 .clang-format 文件被优先使用
如有任何问题或建议,欢迎提出 issue 或 pull request。

View File

@ -1,44 +1,46 @@
# 自定义开发板指南
# Custom Board Guide
本指南介绍如何为小智AI语音聊天机器人项目定制一个新的开发板初始化程序。小智AI支持70多种ESP32系列开发板每个开发板的初始化代码都放在对应的目录下。
This guide describes how to add a new board to the XiaoZhi AI voice assistant project. XiaoZhi AI supports 70+ ESP32-series boards; each one lives in its own directory under `main/boards/`.
## 重要提示
## Important
> **警告**: 对于自定义开发板当IO配置与原有开发板不同时切勿直接覆盖原有开发板的配置编译固件。必须创建新的开发板类型或者通过config.json文件中的builds配置不同的name和sdkconfig宏定义来区分。使用 `python scripts/release.py [开发板目录名字]` 来编译打包固件。
> **Warning**: for a custom board whose IO configuration differs from an existing board, never overwrite the original board's configuration. Always create a new board type - or use the `builds` array in `config.json` to produce a distinct firmware name with different `sdkconfig` macros. Use `python scripts/release.py [board-directory]` to build and package the firmware.
>
> 如果直接覆盖原有配置将来OTA升级时您的自定义固件可能会被原有开发板的标准固件覆盖导致您的设备无法正常工作。每个开发板有唯一的标识和对应的固件升级通道保持开发板标识的唯一性非常重要。
> Overwriting an existing board's configuration is dangerous because OTA updates may replace your custom firmware with the stock firmware for the original board. Every board must have a unique identity and its own firmware update channel.
## 目录结构
## Directory Layout
每个开发板的目录结构通常包含以下文件:
A board directory typically contains:
- `xxx_board.cc` - 主要的板级初始化代码,实现了板子相关的初始化和功能
- `config.h` - 板级配置文件,定义了硬件管脚映射和其他配置项
- `config.json` - 编译配置,指定目标芯片和特殊的编译选项
- `README.md` - 开发板相关的说明文档
- `xxx_board.cc` - board-level initialization and glue code.
- `config.h` - pin assignments and board-level settings.
- `config.json` - build configuration consumed by `scripts/release.py`.
- `README.md` - board-specific notes.
## 定制开发板步骤
Boards can live directly under `main/boards/` or be grouped by manufacturer under `main/boards/<manufacturer>/<board>/` (see [Manufacturer Sub-directories](#manufacturer-sub-directories) below).
### 1. 创建新的开发板目录
## Steps
首先在`boards/`目录下创建一个新的目录,命名方式应使用 `[品牌名]-[开发板类型]` 的形式,例如 `m5stack-tab5`
### 1. Create the Board Directory
Create a new directory under `main/boards/` using the `[vendor]-[model]` naming style (e.g. `m5stack-tab5`):
```bash
mkdir main/boards/my-custom-board
```
### 2. 创建配置文件
### 2. Create the Configuration Files
#### config.h
`config.h`中定义所有的硬件配置,包括:
Define all hardware settings in `config.h`:
- 音频采样率和I2S引脚配置
- 音频编解码芯片地址和I2C引脚配置
- 按钮和LED引脚配置
- 显示屏参数和引脚配置
- Audio sample rates and I2S pin mapping.
- Audio codec I2C address and pins.
- Button and LED pins.
- Display parameters and pins.
参考示例(来自lichuang-c3-dev
Example (from `lichuang-c3-dev`):
```c
#ifndef _BOARD_CONFIG_H_
@ -46,7 +48,7 @@ mkdir main/boards/my-custom-board
#include <driver/gpio.h>
// 音频配置
// Audio
#define AUDIO_INPUT_SAMPLE_RATE 24000
#define AUDIO_OUTPUT_SAMPLE_RATE 24000
@ -61,10 +63,10 @@ mkdir main/boards/my-custom-board
#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_1
#define AUDIO_CODEC_ES8311_ADDR ES8311_CODEC_DEFAULT_ADDR
// 按钮配置
// Buttons
#define BOOT_BUTTON_GPIO GPIO_NUM_9
// 显示屏配置
// Display
#define DISPLAY_SPI_SCK_PIN GPIO_NUM_3
#define DISPLAY_SPI_MOSI_PIN GPIO_NUM_5
#define DISPLAY_DC_PIN GPIO_NUM_6
@ -87,18 +89,16 @@ mkdir main/boards/my-custom-board
#### config.json
`config.json`中定义编译配置,这个文件用于 `scripts/release.py` 脚本自动化编译:
`config.json` drives `scripts/release.py`:
```json
{
"target": "esp32s3", // 目标芯片型号: esp32, esp32s3, esp32c3, esp32c6, esp32p4等
"target": "esp32s3",
"builds": [
{
"name": "my-custom-board", // 开发板名称,用于生成固件包
"name": "my-custom-board",
"sdkconfig_append": [
// 特别 Flash 大小配置
"CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y",
// 特别分区表配置
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\""
]
}
@ -106,42 +106,43 @@ mkdir main/boards/my-custom-board
}
```
**配置项说明:**
- `target`: 目标芯片型号,必须与硬件匹配
- `name`: 编译输出的固件包名称,建议与目录名一致
- `sdkconfig_append`: 额外的 sdkconfig 配置项数组,会追加到默认配置中
**Fields**:
- `target`: target chip, must match the real hardware (`esp32`, `esp32s3`, `esp32c3`, `esp32c6`, `esp32p4`, ...).
- `name`: firmware package name; typically matches the directory name.
- `sdkconfig_append`: extra sdkconfig lines merged into the defaults.
**Common `sdkconfig_append` entries**:
**常用的 sdkconfig_append 配置:**
```json
// Flash 大小
"CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y" // 4MB Flash
"CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y" // 8MB Flash
"CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y" // 16MB Flash
// Flash size
"CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y"
"CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y"
"CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y"
// 分区表
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/4m.csv\"" // 4MB 分区表
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\"" // 8MB 分区表
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/16m.csv\"" // 16MB 分区表
// Partition table
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/4m.csv\""
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\""
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/16m.csv\""
// 语言配置
"CONFIG_LANGUAGE_EN_US=y" // 英语
"CONFIG_LANGUAGE_ZH_CN=y" // 简体中文
// Language
"CONFIG_LANGUAGE_EN_US=y"
"CONFIG_LANGUAGE_ZH_CN=y"
// 唤醒词配置
"CONFIG_USE_DEVICE_AEC=y" // 启用设备端 AEC
"CONFIG_WAKE_WORD_DISABLED=y" // 禁用唤醒词
// Wake word configuration
"CONFIG_USE_DEVICE_AEC=y" // enable on-device AEC
"CONFIG_WAKE_WORD_DISABLED=y" // disable wake word detection
```
### 3. 编写板级初始化代码
### 3. Implement the Board Class
创建一个`my_custom_board.cc`文件,实现开发板的所有初始化逻辑。
Create `my_custom_board.cc` containing the board-level implementation.
一个基本的开发板类定义包含以下几个部分:
A basic board class has:
1. **类定义**:继承自`WifiBoard``Ml307Board`
2. **初始化函数**包括I2C、显示屏、按钮、IoT等组件的初始化
3. **虚函数重写**:如`GetAudioCodec()``GetDisplay()``GetBacklight()`
4. **注册开发板**:使用`DECLARE_BOARD`宏注册开发板
1. **Class declaration**: derive from `WifiBoard` or `Ml307Board`.
2. **Initialization helpers**: I2C, display, buttons, IoT/MCP tools, etc.
3. **Virtual overrides**: `GetAudioCodec()`, `GetDisplay()`, `GetBacklight()`, ...
4. **Board registration**: `DECLARE_BOARD(ClassName)`.
```cpp
#include "wifi_board.h"
@ -164,7 +165,6 @@ private:
Button boot_button_;
LcdDisplay* display_;
// I2C初始化
void InitializeI2c() {
i2c_master_bus_config_t i2c_bus_cfg = {
.i2c_port = I2C_NUM_0,
@ -181,7 +181,6 @@ private:
ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &codec_i2c_bus_));
}
// SPI初始化用于显示屏
void InitializeSpi() {
spi_bus_config_t buscfg = {};
buscfg.mosi_io_num = DISPLAY_SPI_MOSI_PIN;
@ -193,7 +192,6 @@ private:
ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO));
}
// 按钮初始化
void InitializeButtons() {
boot_button_.OnClick([this]() {
auto& app = Application::GetInstance();
@ -205,11 +203,10 @@ private:
});
}
// 显示屏初始化以ST7789为例
void InitializeDisplay() {
esp_lcd_panel_io_handle_t panel_io = nullptr;
esp_lcd_panel_handle_t panel = nullptr;
esp_lcd_panel_io_spi_config_t io_config = {};
io_config.cs_gpio_num = DISPLAY_SPI_CS_PIN;
io_config.dc_gpio_num = DISPLAY_DC_PIN;
@ -225,27 +222,24 @@ private:
panel_config.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB;
panel_config.bits_per_pixel = 16;
ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel));
esp_lcd_panel_reset(panel);
esp_lcd_panel_init(panel);
esp_lcd_panel_invert_color(panel, true);
esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY);
esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y);
// 创建显示屏对象
display_ = new SpiLcdDisplay(panel_io, panel,
DISPLAY_WIDTH, DISPLAY_HEIGHT,
DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y,
DISPLAY_WIDTH, DISPLAY_HEIGHT,
DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y,
DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY);
}
// MCP Tools 初始化
void InitializeTools() {
// 参考 MCP 文档
// Register MCP tools here; see docs/mcp-usage.md.
}
public:
// 构造函数
MyCustomBoard() : boot_button_(BOOT_BUTTON_GPIO) {
InitializeI2c();
InitializeSpi();
@ -255,199 +249,225 @@ public:
GetBacklight()->SetBrightness(100);
}
// 获取音频编解码器
virtual AudioCodec* GetAudioCodec() override {
static Es8311AudioCodec audio_codec(
codec_i2c_bus_,
I2C_NUM_0,
AUDIO_INPUT_SAMPLE_RATE,
codec_i2c_bus_,
I2C_NUM_0,
AUDIO_INPUT_SAMPLE_RATE,
AUDIO_OUTPUT_SAMPLE_RATE,
AUDIO_I2S_GPIO_MCLK,
AUDIO_I2S_GPIO_BCLK,
AUDIO_I2S_GPIO_WS,
AUDIO_I2S_GPIO_DOUT,
AUDIO_I2S_GPIO_MCLK,
AUDIO_I2S_GPIO_BCLK,
AUDIO_I2S_GPIO_WS,
AUDIO_I2S_GPIO_DOUT,
AUDIO_I2S_GPIO_DIN,
AUDIO_CODEC_PA_PIN,
AUDIO_CODEC_PA_PIN,
AUDIO_CODEC_ES8311_ADDR);
return &audio_codec;
}
// 获取显示屏
virtual Display* GetDisplay() override {
return display_;
}
// 获取背光控制
virtual Backlight* GetBacklight() override {
static PwmBacklight backlight(DISPLAY_BACKLIGHT_PIN, DISPLAY_BACKLIGHT_OUTPUT_INVERT);
return &backlight;
}
};
// 注册开发板
DECLARE_BOARD(MyCustomBoard);
```
### 4. 添加构建系统配置
### 4. Hook Up the Build System
#### Kconfig.projbuild 中添加开发板选项
#### Add a Kconfig entry
打开 `main/Kconfig.projbuild` 文件,在 `choice BOARD_TYPE` 部分添加新的开发板配置项:
In `main/Kconfig.projbuild`, add an entry to the `choice BOARD_TYPE` block:
```kconfig
choice BOARD_TYPE
prompt "Board Type"
default BOARD_TYPE_BREAD_COMPACT_WIFI
help
Board type. 开发板类型
# ... 其他开发板选项 ...
Board type.
# ... other entries ...
config BOARD_TYPE_MY_CUSTOM_BOARD
bool "My Custom Board (我的自定义开发板)"
depends on IDF_TARGET_ESP32S3 # 根据你的目标芯片修改
bool "My Custom Board"
depends on IDF_TARGET_ESP32S3 # pick the matching target
endchoice
```
**注意事项:**
- `BOARD_TYPE_MY_CUSTOM_BOARD` 是配置项名称,需要全大写,使用下划线分隔
- `depends on` 指定了目标芯片类型(如 `IDF_TARGET_ESP32S3``IDF_TARGET_ESP32C3` 等)
- 描述文字可以使用中英文
Notes:
- The identifier must be uppercase and underscore-separated.
- `depends on` restricts the entry to the correct target (`IDF_TARGET_ESP32S3`, `IDF_TARGET_ESP32C3`, ...).
- The label can be localized.
#### CMakeLists.txt 中添加开发板配置
#### Add a branch in CMakeLists.txt
打开 `main/CMakeLists.txt` 文件,在开发板类型判断部分添加新的配置:
Open `main/CMakeLists.txt` and extend the board-type chain:
```cmake
# 在 elseif 链中添加你的开发板配置
elseif(CONFIG_BOARD_TYPE_MY_CUSTOM_BOARD)
set(BOARD_TYPE "my-custom-board") # 与目录名一致
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) # 根据屏幕大小选择合适的字体
set(BOARD_TYPE "my-custom-board") # must match the directory name
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) # pick a font for the display
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64) # 可选,如果需要表情显示
endif()
set(DEFAULT_EMOJI_COLLECTION twemoji_64) // optional, for emoji display
```
**字体和表情配置说明:**
**Font and emoji guidance**:
根据屏幕分辨率选择合适的字体大小:
- 小屏幕(128x64 OLED`font_puhui_basic_14_1` / `font_awesome_14_1`
- 中小屏幕(240x240`font_puhui_basic_16_4` / `font_awesome_16_4`
- 中等屏幕(240x320`font_puhui_basic_20_4` / `font_awesome_20_4`
- 大屏幕(480x320+`font_puhui_basic_30_4` / `font_awesome_30_4`
Pick a font size that matches the display resolution:
- Small (128x64 OLED): `font_puhui_basic_14_1` / `font_awesome_14_1`
- Small-medium (240x240): `font_puhui_basic_16_4` / `font_awesome_16_4`
- Medium (240x320): `font_puhui_basic_20_4` / `font_awesome_20_4`
- Large (480x320+): `font_puhui_basic_30_4` / `font_awesome_30_4`
表情集合选项:
- `twemoji_32` - 32x32 像素表情(小屏幕)
- `twemoji_64` - 64x64 像素表情(大屏幕)
Emoji collections:
- `twemoji_32` - 32x32 pixels (small screens).
- `twemoji_64` - 64x64 pixels (large screens).
### 5. 配置和编译
### 5. Build and Flash
#### 方法一:使用 idf.py 手动配置
#### Option A - use `idf.py` manually
1. **设置目标芯片**(首次配置或更换芯片时):
1. Set the target chip (first time, or when switching targets):
```bash
# 对于 ESP32-S3
idf.py set-target esp32s3
# 对于 ESP32-C3
idf.py set-target esp32c3
# 对于 ESP32
idf.py set-target esp32
idf.py set-target esp32s3 # ESP32-S3
idf.py set-target esp32c3 # ESP32-C3
idf.py set-target esp32 # ESP32
```
2. **清理旧配置**
2. Clean stale configuration:
```bash
idf.py fullclean
```
3. **进入配置菜单**
3. Select the board via menuconfig:
```bash
idf.py menuconfig
```
在菜单中导航到:`Xiaozhi Assistant` -> `Board Type`,选择你的自定义开发板。
Navigate to `Xiaozhi Assistant -> Board Type` and choose your board.
4. **编译和烧录**
4. Build and flash:
```bash
idf.py build
idf.py flash monitor
```
#### 方法二:使用 release.py 脚本(推荐)
#### Option B - use `release.py` (recommended)
如果你的开发板目录下有 `config.json` 文件,可以使用此脚本自动完成配置和编译:
If the board directory contains a `config.json`, you can build and package automatically:
```bash
python scripts/release.py my-custom-board
```
此脚本会自动:
- 读取 `config.json` 中的 `target` 配置并设置目标芯片
- 应用 `sdkconfig_append` 中的编译选项
- 完成编译并打包固件
The script:
- Reads `target` from `config.json` and calls `idf.py set-target`.
- Appends the entries listed in `sdkconfig_append`.
- Builds and packages the firmware.
### 6. 创建README.md
### 6. Write the README
README.md中说明开发板的特性、硬件要求、编译和烧录步骤:
In `README.md`, describe the board, hardware requirements, build instructions, and any special notes.
## Manufacturer Sub-directories
## 常见开发板组件
Boards can be grouped by manufacturer under `main/boards/<manufacturer>/<board>/`. This is the recommended layout when a single vendor ships several variants - for example `main/boards/waveshare/esp32-p4-nano/` or `main/boards/lceda-course-examples/eda-tv-pro/`.
### 1. 显示屏
To enable the layout, set the `MANUFACTURER` variable in `main/CMakeLists.txt` for your board:
项目支持多种显示屏驱动,包括:
```cmake
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_NANO)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-nano")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
```
When `MANUFACTURER` is set, the build system globs source files from `main/boards/${MANUFACTURER}/${BOARD_TYPE}/`. When it is empty, it falls back to the flat `main/boards/${BOARD_TYPE}/` layout.
Rules of thumb:
- Use the manufacturer layout when you have two or more boards from the same vendor that share drivers, assets, or documentation.
- Use the flat layout for one-off boards and community examples.
- Directory names use lowercase with dashes (e.g. `waveshare`, `lceda-course-examples`).
## Common Board Components
Several reusable components live in `main/boards/common/`. You can include them directly from your board class:
### Display drivers
Supported LCD families include:
- ST7789 (SPI)
- ILI9341 (SPI)
- SH8601 (QSPI)
- 等...
- and many more.
### 2. 音频编解码器
### Audio codecs
支持的编解码器包括:
- ES8311 (常用)
- ES7210 (麦克风阵列)
- AW88298 (功放)
- 等...
- `Es8311AudioCodec` (most common)
- `Es8374AudioCodec`
- `Es8388AudioCodec`
- `Es8389AudioCodec`
- `BoxAudioCodec` (ES7210 mic array + codec combo used on ESP-Box boards)
- `NoAudioCodec` (direct I2S without external codec)
- `DummyAudioCodec` (placeholder for boards without audio)
### 3. 电源管理
### Power management
一些开发板使用电源管理芯片:
- AXP2101
- 其他可用的PMIC
- `Axp2101` power management IC helpers.
- `Sy6970` battery charger helpers.
- `AdcBatteryMonitor` - simple ADC-based battery voltage monitor.
- `PowerSaveTimer` / `SleepTimer` - helpers for light-sleep scheduling.
### 4. MCP设备控制
### Networking
可以添加各种MCP工具让AI能够使用:
- Speaker (扬声器控制)
- Screen (屏幕亮度调节)
- Battery (电池电量读取)
- Light (灯光控制)
- 等...
- `WifiBoard` - WiFi-only base class.
- `Ml307Board` / `Nt26Board` - 4G modem base classes.
- `DualNetworkBoard` - switchable WiFi / 4G base class.
- `RndisBoard` - RNDIS-over-USB networking (ESP32-S3 / ESP32-P4).
- `EspVideo` helpers for ESP-Video on ESP32-S3 / ESP32-P4.
## 开发板类继承关系
### Input helpers
- `Board` - 基础板级类
- `WifiBoard` - Wi-Fi连接的开发板
- `Ml307Board` - 使用4G模块的开发板
- `DualNetworkBoard` - 支持Wi-Fi与4G网络切换的开发板
- `Button` - standard push buttons (click, long-press, multi-click).
- `Knob` - rotary encoder wrapper.
- `PressToTalkMcpTool` - push-to-talk tool that registers itself through MCP.
- `AfskDemod` - AFSK demodulator used by some acoustic provisioning flows.
- `SystemReset` - helper that performs a safe factory reset when a button is held at boot.
## 开发技巧
### MCP integration
1. **参考相似的开发板**:如果您的新开发板与现有开发板有相似之处,可以参考现有实现
2. **分步调试**:先实现基础功能(如显示),再添加更复杂的功能(如音频)
3. **管脚映射**确保在config.h中正确配置所有管脚映射
4. **检查硬件兼容性**:确认所有芯片和驱动程序的兼容性
Any board can register custom tools - speaker control, screen brightness, battery readout, light control, etc. See [MCP IoT control usage](./mcp-usage.md).
## 可能遇到的问题
## Board Class Hierarchy
1. **显示屏不正常**检查SPI配置、镜像设置和颜色反转设置
2. **音频无输出**检查I2S配置、PA使能引脚和编解码器地址
3. **无法连接网络**检查Wi-Fi凭据和网络配置
4. **无法与服务器通信**检查MQTT或WebSocket配置
- `Board` - base class
- `WifiBoard` - WiFi-connected board
- `Ml307Board` / `Nt26Board` - 4G modem boards
- `DualNetworkBoard` - WiFi + 4G switchable board
- `RndisBoard` - RNDIS-over-USB board
## 参考资料
## Tips
- ESP-IDF 文档: https://docs.espressif.com/projects/esp-idf/
- LVGL 文档: https://docs.lvgl.io/
- ESP-SR 文档: https://github.com/espressif/esp-sr
1. **Start from a similar board** - copying and tweaking an existing board is usually faster than starting from scratch.
2. **Bring up incrementally** - get the display up first, then audio, then the full stack.
3. **Double check pin assignments** - every pin defined in `config.h` must match your schematic.
4. **Check hardware compatibility** - especially codec / PMIC / touch controller combinations.
## Troubleshooting
1. **Display looks wrong** - verify SPI configuration, mirroring, and color inversion.
2. **No audio** - check I2S wiring, PA enable pin, and codec I2C address.
3. **Cannot connect to WiFi** - re-check WiFi credentials and provisioning method.
4. **Cannot reach the server** - verify the WebSocket / MQTT endpoint configuration.
## References
- ESP-IDF documentation: https://docs.espressif.com/projects/esp-idf/
- LVGL documentation: https://docs.lvgl.io/
- ESP-SR documentation: https://github.com/espressif/esp-sr

453
docs/custom-board_zh.md Normal file
View File

@ -0,0 +1,453 @@
# 自定义开发板指南
本指南介绍如何为小智AI语音聊天机器人项目定制一个新的开发板初始化程序。小智AI支持70多种ESP32系列开发板每个开发板的初始化代码都放在对应的目录下。
## 重要提示
> **警告**: 对于自定义开发板当IO配置与原有开发板不同时切勿直接覆盖原有开发板的配置编译固件。必须创建新的开发板类型或者通过config.json文件中的builds配置不同的name和sdkconfig宏定义来区分。使用 `python scripts/release.py [开发板目录名字]` 来编译打包固件。
>
> 如果直接覆盖原有配置将来OTA升级时您的自定义固件可能会被原有开发板的标准固件覆盖导致您的设备无法正常工作。每个开发板有唯一的标识和对应的固件升级通道保持开发板标识的唯一性非常重要。
## 目录结构
每个开发板的目录结构通常包含以下文件:
- `xxx_board.cc` - 主要的板级初始化代码,实现了板子相关的初始化和功能
- `config.h` - 板级配置文件,定义了硬件管脚映射和其他配置项
- `config.json` - 编译配置,指定目标芯片和特殊的编译选项
- `README.md` - 开发板相关的说明文档
## 定制开发板步骤
### 1. 创建新的开发板目录
首先在`boards/`目录下创建一个新的目录,命名方式应使用 `[品牌名]-[开发板类型]` 的形式,例如 `m5stack-tab5`
```bash
mkdir main/boards/my-custom-board
```
### 2. 创建配置文件
#### config.h
`config.h`中定义所有的硬件配置,包括:
- 音频采样率和I2S引脚配置
- 音频编解码芯片地址和I2C引脚配置
- 按钮和LED引脚配置
- 显示屏参数和引脚配置
参考示例来自lichuang-c3-dev
```c
#ifndef _BOARD_CONFIG_H_
#define _BOARD_CONFIG_H_
#include <driver/gpio.h>
// 音频配置
#define AUDIO_INPUT_SAMPLE_RATE 24000
#define AUDIO_OUTPUT_SAMPLE_RATE 24000
#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_10
#define AUDIO_I2S_GPIO_WS GPIO_NUM_12
#define AUDIO_I2S_GPIO_BCLK GPIO_NUM_8
#define AUDIO_I2S_GPIO_DIN GPIO_NUM_7
#define AUDIO_I2S_GPIO_DOUT GPIO_NUM_11
#define AUDIO_CODEC_PA_PIN GPIO_NUM_13
#define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_0
#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_1
#define AUDIO_CODEC_ES8311_ADDR ES8311_CODEC_DEFAULT_ADDR
// 按钮配置
#define BOOT_BUTTON_GPIO GPIO_NUM_9
// 显示屏配置
#define DISPLAY_SPI_SCK_PIN GPIO_NUM_3
#define DISPLAY_SPI_MOSI_PIN GPIO_NUM_5
#define DISPLAY_DC_PIN GPIO_NUM_6
#define DISPLAY_SPI_CS_PIN GPIO_NUM_4
#define DISPLAY_WIDTH 320
#define DISPLAY_HEIGHT 240
#define DISPLAY_MIRROR_X true
#define DISPLAY_MIRROR_Y false
#define DISPLAY_SWAP_XY true
#define DISPLAY_OFFSET_X 0
#define DISPLAY_OFFSET_Y 0
#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_2
#define DISPLAY_BACKLIGHT_OUTPUT_INVERT true
#endif // _BOARD_CONFIG_H_
```
#### config.json
`config.json`中定义编译配置,这个文件用于 `scripts/release.py` 脚本自动化编译:
```json
{
"target": "esp32s3", // 目标芯片型号: esp32, esp32s3, esp32c3, esp32c6, esp32p4等
"builds": [
{
"name": "my-custom-board", // 开发板名称,用于生成固件包
"sdkconfig_append": [
// 特别 Flash 大小配置
"CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y",
// 特别分区表配置
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\""
]
}
]
}
```
**配置项说明:**
- `target`: 目标芯片型号,必须与硬件匹配
- `name`: 编译输出的固件包名称,建议与目录名一致
- `sdkconfig_append`: 额外的 sdkconfig 配置项数组,会追加到默认配置中
**常用的 sdkconfig_append 配置:**
```json
// Flash 大小
"CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y" // 4MB Flash
"CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y" // 8MB Flash
"CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y" // 16MB Flash
// 分区表
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/4m.csv\"" // 4MB 分区表
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\"" // 8MB 分区表
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/16m.csv\"" // 16MB 分区表
// 语言配置
"CONFIG_LANGUAGE_EN_US=y" // 英语
"CONFIG_LANGUAGE_ZH_CN=y" // 简体中文
// 唤醒词配置
"CONFIG_USE_DEVICE_AEC=y" // 启用设备端 AEC
"CONFIG_WAKE_WORD_DISABLED=y" // 禁用唤醒词
```
### 3. 编写板级初始化代码
创建一个`my_custom_board.cc`文件,实现开发板的所有初始化逻辑。
一个基本的开发板类定义包含以下几个部分:
1. **类定义**:继承自`WifiBoard``Ml307Board`
2. **初始化函数**包括I2C、显示屏、按钮、IoT等组件的初始化
3. **虚函数重写**:如`GetAudioCodec()``GetDisplay()``GetBacklight()`
4. **注册开发板**:使用`DECLARE_BOARD`宏注册开发板
```cpp
#include "wifi_board.h"
#include "codecs/es8311_audio_codec.h"
#include "display/lcd_display.h"
#include "application.h"
#include "button.h"
#include "config.h"
#include "mcp_server.h"
#include <esp_log.h>
#include <driver/i2c_master.h>
#include <driver/spi_common.h>
#define TAG "MyCustomBoard"
class MyCustomBoard : public WifiBoard {
private:
i2c_master_bus_handle_t codec_i2c_bus_;
Button boot_button_;
LcdDisplay* display_;
// I2C初始化
void InitializeI2c() {
i2c_master_bus_config_t i2c_bus_cfg = {
.i2c_port = I2C_NUM_0,
.sda_io_num = AUDIO_CODEC_I2C_SDA_PIN,
.scl_io_num = AUDIO_CODEC_I2C_SCL_PIN,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.intr_priority = 0,
.trans_queue_depth = 0,
.flags = {
.enable_internal_pullup = 1,
},
};
ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &codec_i2c_bus_));
}
// SPI初始化用于显示屏
void InitializeSpi() {
spi_bus_config_t buscfg = {};
buscfg.mosi_io_num = DISPLAY_SPI_MOSI_PIN;
buscfg.miso_io_num = GPIO_NUM_NC;
buscfg.sclk_io_num = DISPLAY_SPI_SCK_PIN;
buscfg.quadwp_io_num = GPIO_NUM_NC;
buscfg.quadhd_io_num = GPIO_NUM_NC;
buscfg.max_transfer_sz = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t);
ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO));
}
// 按钮初始化
void InitializeButtons() {
boot_button_.OnClick([this]() {
auto& app = Application::GetInstance();
if (app.GetDeviceState() == kDeviceStateStarting) {
EnterWifiConfigMode();
return;
}
app.ToggleChatState();
});
}
// 显示屏初始化以ST7789为例
void InitializeDisplay() {
esp_lcd_panel_io_handle_t panel_io = nullptr;
esp_lcd_panel_handle_t panel = nullptr;
esp_lcd_panel_io_spi_config_t io_config = {};
io_config.cs_gpio_num = DISPLAY_SPI_CS_PIN;
io_config.dc_gpio_num = DISPLAY_DC_PIN;
io_config.spi_mode = 2;
io_config.pclk_hz = 80 * 1000 * 1000;
io_config.trans_queue_depth = 10;
io_config.lcd_cmd_bits = 8;
io_config.lcd_param_bits = 8;
ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI2_HOST, &io_config, &panel_io));
esp_lcd_panel_dev_config_t panel_config = {};
panel_config.reset_gpio_num = GPIO_NUM_NC;
panel_config.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB;
panel_config.bits_per_pixel = 16;
ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel));
esp_lcd_panel_reset(panel);
esp_lcd_panel_init(panel);
esp_lcd_panel_invert_color(panel, true);
esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY);
esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y);
// 创建显示屏对象
display_ = new SpiLcdDisplay(panel_io, panel,
DISPLAY_WIDTH, DISPLAY_HEIGHT,
DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y,
DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY);
}
// MCP Tools 初始化
void InitializeTools() {
// 参考 MCP 文档
}
public:
// 构造函数
MyCustomBoard() : boot_button_(BOOT_BUTTON_GPIO) {
InitializeI2c();
InitializeSpi();
InitializeDisplay();
InitializeButtons();
InitializeTools();
GetBacklight()->SetBrightness(100);
}
// 获取音频编解码器
virtual AudioCodec* GetAudioCodec() override {
static Es8311AudioCodec audio_codec(
codec_i2c_bus_,
I2C_NUM_0,
AUDIO_INPUT_SAMPLE_RATE,
AUDIO_OUTPUT_SAMPLE_RATE,
AUDIO_I2S_GPIO_MCLK,
AUDIO_I2S_GPIO_BCLK,
AUDIO_I2S_GPIO_WS,
AUDIO_I2S_GPIO_DOUT,
AUDIO_I2S_GPIO_DIN,
AUDIO_CODEC_PA_PIN,
AUDIO_CODEC_ES8311_ADDR);
return &audio_codec;
}
// 获取显示屏
virtual Display* GetDisplay() override {
return display_;
}
// 获取背光控制
virtual Backlight* GetBacklight() override {
static PwmBacklight backlight(DISPLAY_BACKLIGHT_PIN, DISPLAY_BACKLIGHT_OUTPUT_INVERT);
return &backlight;
}
};
// 注册开发板
DECLARE_BOARD(MyCustomBoard);
```
### 4. 添加构建系统配置
#### 在 Kconfig.projbuild 中添加开发板选项
打开 `main/Kconfig.projbuild` 文件,在 `choice BOARD_TYPE` 部分添加新的开发板配置项:
```kconfig
choice BOARD_TYPE
prompt "Board Type"
default BOARD_TYPE_BREAD_COMPACT_WIFI
help
Board type. 开发板类型
# ... 其他开发板选项 ...
config BOARD_TYPE_MY_CUSTOM_BOARD
bool "My Custom Board (我的自定义开发板)"
depends on IDF_TARGET_ESP32S3 # 根据你的目标芯片修改
endchoice
```
**注意事项:**
- `BOARD_TYPE_MY_CUSTOM_BOARD` 是配置项名称,需要全大写,使用下划线分隔
- `depends on` 指定了目标芯片类型(如 `IDF_TARGET_ESP32S3``IDF_TARGET_ESP32C3` 等)
- 描述文字可以使用中英文
#### 在 CMakeLists.txt 中添加开发板配置
打开 `main/CMakeLists.txt` 文件,在开发板类型判断部分添加新的配置:
```cmake
# 在 elseif 链中添加你的开发板配置
elseif(CONFIG_BOARD_TYPE_MY_CUSTOM_BOARD)
set(BOARD_TYPE "my-custom-board") # 与目录名一致
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) # 根据屏幕大小选择合适的字体
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64) # 可选,如果需要表情显示
endif()
```
**字体和表情配置说明:**
根据屏幕分辨率选择合适的字体大小:
- 小屏幕128x64 OLED`font_puhui_basic_14_1` / `font_awesome_14_1`
- 中小屏幕240x240`font_puhui_basic_16_4` / `font_awesome_16_4`
- 中等屏幕240x320`font_puhui_basic_20_4` / `font_awesome_20_4`
- 大屏幕480x320+`font_puhui_basic_30_4` / `font_awesome_30_4`
表情集合选项:
- `twemoji_32` - 32x32 像素表情(小屏幕)
- `twemoji_64` - 64x64 像素表情(大屏幕)
### 5. 配置和编译
#### 方法一:使用 idf.py 手动配置
1. **设置目标芯片**(首次配置或更换芯片时):
```bash
# 对于 ESP32-S3
idf.py set-target esp32s3
# 对于 ESP32-C3
idf.py set-target esp32c3
# 对于 ESP32
idf.py set-target esp32
```
2. **清理旧配置**
```bash
idf.py fullclean
```
3. **进入配置菜单**
```bash
idf.py menuconfig
```
在菜单中导航到:`Xiaozhi Assistant` -> `Board Type`,选择你的自定义开发板。
4. **编译和烧录**
```bash
idf.py build
idf.py flash monitor
```
#### 方法二:使用 release.py 脚本(推荐)
如果你的开发板目录下有 `config.json` 文件,可以使用此脚本自动完成配置和编译:
```bash
python scripts/release.py my-custom-board
```
此脚本会自动:
- 读取 `config.json` 中的 `target` 配置并设置目标芯片
- 应用 `sdkconfig_append` 中的编译选项
- 完成编译并打包固件
### 6. 创建README.md
在README.md中说明开发板的特性、硬件要求、编译和烧录步骤
## 常见开发板组件
### 1. 显示屏
项目支持多种显示屏驱动,包括:
- ST7789 (SPI)
- ILI9341 (SPI)
- SH8601 (QSPI)
- 等...
### 2. 音频编解码器
支持的编解码器包括:
- ES8311 (常用)
- ES7210 (麦克风阵列)
- AW88298 (功放)
- 等...
### 3. 电源管理
一些开发板使用电源管理芯片:
- AXP2101
- 其他可用的PMIC
### 4. MCP设备控制
可以添加各种MCP工具让AI能够使用:
- Speaker (扬声器控制)
- Screen (屏幕亮度调节)
- Battery (电池电量读取)
- Light (灯光控制)
- 等...
## 开发板类继承关系
- `Board` - 基础板级类
- `WifiBoard` - Wi-Fi连接的开发板
- `Ml307Board` - 使用4G模块的开发板
- `DualNetworkBoard` - 支持Wi-Fi与4G网络切换的开发板
## 开发技巧
1. **参考相似的开发板**:如果您的新开发板与现有开发板有相似之处,可以参考现有实现
2. **分步调试**:先实现基础功能(如显示),再添加更复杂的功能(如音频)
3. **管脚映射**确保在config.h中正确配置所有管脚映射
4. **检查硬件兼容性**:确认所有芯片和驱动程序的兼容性
## 可能遇到的问题
1. **显示屏不正常**检查SPI配置、镜像设置和颜色反转设置
2. **音频无输出**检查I2S配置、PA使能引脚和编解码器地址
3. **无法连接网络**检查Wi-Fi凭据和网络配置
4. **无法与服务器通信**检查MQTT或WebSocket配置
## 参考资料
- ESP-IDF 文档: https://docs.espressif.com/projects/esp-idf/
- LVGL 文档: https://docs.lvgl.io/
- ESP-SR 文档: https://github.com/espressif/esp-sr

View File

@ -1,269 +1,270 @@
# MCP (Model Context Protocol) 交互流程
# MCP (Model Context Protocol) Interaction Flow
NOTICE: AI 辅助生成, 在实现后台服务时, 请参照代码确认细节!!
NOTICE: This document was AI-assisted; when implementing a backend, always cross-check the details against the code.
本项目中的 MCP 协议用于后台 APIMCP 客户端)与 ESP32 设备MCP 服务器)之间的通信,以便后台能够发现和调用设备提供的功能(工具)。
In this project, MCP is used between the backend API (MCP client) and the ESP32 device (MCP server) to let the backend discover and invoke the device's capabilities (tools).
## 协议格式
## Message Format
根据代码 (`main/protocols/protocol.cc`, `main/mcp_server.cc`)MCP 消息是封装在基础通信协议(如 WebSocket MQTT)的消息体中的。其内部结构遵循 [JSON-RPC 2.0](https://www.jsonrpc.org/specification) 规范。
From `main/protocols/protocol.cc` and `main/mcp_server.cc`, MCP messages are wrapped inside the underlying transport (WebSocket or MQTT). The inner payload follows the [JSON-RPC 2.0](https://www.jsonrpc.org/specification) specification.
整体消息结构示例:
Overall message layout:
```json
{
"session_id": "...", // 会话 ID
"type": "mcp", // 消息类型,固定为 "mcp"
"payload": { // JSON-RPC 2.0 负载
"session_id": "...", // session id
"type": "mcp", // fixed value "mcp"
"payload": { // JSON-RPC 2.0 payload
"jsonrpc": "2.0",
"method": "...", // 方法名 (如 "initialize", "tools/list", "tools/call")
"params": { ... }, // 方法参数 (对于 request)
"id": ..., // 请求 ID (对于 request response)
"result": { ... }, // 方法执行结果 (对于 success response)
"error": { ... } // 错误信息 (对于 error response)
"method": "...", // method name ("initialize", "tools/list", "tools/call", ...)
"params": { ... }, // arguments (for requests)
"id": ..., // request id (for requests and responses)
"result": { ... }, // success result (response)
"error": { ... } // error (response)
}
}
```
其中,`payload` 部分是标准的 JSON-RPC 2.0 消息:
The `payload` follows standard JSON-RPC 2.0:
- `jsonrpc`: 固定的字符串 "2.0"
- `method`: 要调用的方法名称 (对于 Request)。
- `params`: 方法的参数,一个结构化值,通常为对象 (对于 Request)。
- `id`: 请求的标识符,客户端发送请求时提供,服务器响应时原样返回。用于匹配请求和响应。
- `result`: 方法成功执行时的结果 (对于 Success Response)。
- `error`: 方法执行失败时的错误信息 (对于 Error Response)。
- `jsonrpc`: always `"2.0"`.
- `method`: the method name (requests).
- `params`: structured parameters, usually an object (requests).
- `id`: request identifier; echoed back in responses.
- `result`: success value (responses).
- `error`: error information (responses).
## 交互流程及发送时机
## Interaction Flow
MCP 的交互主要围绕客户端(后台 API发现和调用设备上的“工具”Tool进行。
MCP interactions are driven by the client (backend) discovering and invoking tools on the device.
1. **连接建立与能力通告**
1. **Connection and capability announcement**
- **时机:** 设备启动并成功连接到后台 API 后。
- **发送方:** 设备。
- **消息:** 设备发送基础协议的 "hello" 消息给后台 API消息中包含设备支持的能力列表例如通过支持 MCP 协议 (`"mcp": true`)。
- **示例 (非 MCP 负载,而是基础协议消息):**
```json
{
"type": "hello",
"version": ...,
"features": {
"mcp": true,
...
},
"transport": "websocket", // 或 "mqtt"
"audio_params": { ... },
"session_id": "..." // 设备收到服务器hello后可能设置
}
```
- **When**: after the device boots and connects to the backend.
- **Direction**: device -> backend.
- **Message**: the device sends the transport hello, advertising supported capabilities. MCP support is signaled via `"mcp": true` in the `features` map.
- **Example (transport hello, not an MCP payload):**
```json
{
"type": "hello",
"version": 1,
"features": {
"mcp": true
},
"transport": "websocket",
"audio_params": { ... },
"session_id": "..."
}
```
2. **初始化 MCP 会话**
2. **Initialize the MCP session**
- **时机:** 后台 API 收到设备 "hello" 消息,确认设备支持 MCP 后,通常作为 MCP 会话的第一个请求发送。
- **发送方:** 后台 API (客户端)。
- **方法:** `initialize`
- **消息 (MCP payload):**
- **When**: after the backend sees that the device supports MCP. Usually the first MCP request.
- **Direction**: backend -> device.
- **Method**: `initialize`
- **Message (MCP payload):**
```json
{
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"capabilities": {
// optional client capabilities
"vision": {
"url": "...", // camera image upload endpoint (must be an http URL, not a websocket URL)
"token": "..." // token for the upload URL
}
// ... other client capabilities
}
},
"id": 1
}
```
```json
{
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"capabilities": {
// 客户端能力,可选
- **Device response:**
```json
{
"jsonrpc": "2.0",
"id": 1,
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {}
},
"serverInfo": {
"name": "...", // device name (BOARD_NAME)
"version": "..." // firmware version
}
}
}
```
// 摄像头视觉相关
"vision": {
"url": "...", //摄像头: 图片处理地址(必须是http地址, 不是websocket地址)
"token": "..." // url token
}
3. **Discover the tools**
// ... 其他客户端能力
}
},
"id": 1 // 请求 ID
}
```
- **When**: whenever the backend needs the list of callable tools and their signatures.
- **Direction**: backend -> device.
- **Method**: `tools/list`
- **Request parameters**:
- `cursor` (string, optional): pagination cursor. Empty on the first request.
- `withUserTools` (boolean, optional, default `false`): if `true`, the device also includes "user-only" tools (see "User-only tools" below) in the listing. This is typically used by a companion app that lets the user trigger privileged actions directly.
- **Message (MCP payload):**
```json
{
"jsonrpc": "2.0",
"method": "tools/list",
"params": {
"cursor": "",
"withUserTools": false
},
"id": 2
}
```
- **Device response:**
```json
{
"jsonrpc": "2.0",
"id": 2,
"result": {
"tools": [
{
"name": "self.get_device_status",
"description": "...",
"inputSchema": { ... }
},
{
"name": "self.audio_speaker.set_volume",
"description": "...",
"inputSchema": { ... }
}
// ... more tools
],
"nextCursor": "..."
}
}
```
- **Pagination**: when `nextCursor` is non-empty, the backend must send another `tools/list` request with that cursor to fetch the next page.
- **设备响应时机:** 设备收到 `initialize` 请求并处理后。
- **设备响应消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"id": 1, // 匹配请求 ID
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {} // 这里的 tools 似乎不列出详细信息,需要 tools/list
},
"serverInfo": {
"name": "...", // 设备名称 (BOARD_NAME)
"version": "..." // 设备固件版本
}
}
}
```
4. **Call a tool**
3. **发现设备工具列表**
- **When**: the backend wants to execute a specific device function.
- **Direction**: backend -> device.
- **Method**: `tools/call`
- **Message (MCP payload):**
```json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.audio_speaker.set_volume",
"arguments": {
"volume": 50
}
},
"id": 3
}
```
- **Successful response:**
```json
{
"jsonrpc": "2.0",
"id": 3,
"result": {
"content": [
{ "type": "text", "text": "true" }
],
"isError": false
}
}
```
- **Error response:**
```json
{
"jsonrpc": "2.0",
"id": 3,
"error": {
"code": -32601,
"message": "Unknown tool: self.non_existent_tool"
}
}
```
- **时机:** 后台 API 需要获取设备当前支持的具体功能(工具)列表及其调用方式时。
- **发送方:** 后台 API (客户端)。
- **方法:** `tools/list`
- **消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"method": "tools/list",
"params": {
"cursor": "" // 用于分页,首次请求为空字符串
},
"id": 2 // 请求 ID
}
```
- **设备响应时机:** 设备收到 `tools/list` 请求并生成工具列表后。
- **设备响应消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"id": 2, // 匹配请求 ID
"result": {
"tools": [ // 工具对象列表
{
"name": "self.get_device_status",
"description": "...",
"inputSchema": { ... } // 参数 schema
},
{
"name": "self.audio_speaker.set_volume",
"description": "...",
"inputSchema": { ... } // 参数 schema
}
// ... 更多工具
],
"nextCursor": "..." // 如果列表很大需要分页,这里会包含下一个请求的 cursor 值
}
}
```
- **分页处理:** 如果 `nextCursor` 字段非空,客户端需要再次发送 `tools/list` 请求,并在 `params` 中带上这个 `cursor` 值以获取下一页工具。
5. **Device-initiated notifications**
4. **调用设备工具**
- **When**: the device wants to inform the backend of internal events (e.g. state transitions). `Application::SendMcpMessage` is the outbound entry point.
- **Direction**: device -> backend.
- **Method**: conventionally `notifications/...` or any custom method.
- **Message (MCP payload)**: JSON-RPC notifications have no `id`.
```json
{
"jsonrpc": "2.0",
"method": "notifications/state_changed",
"params": {
"newState": "idle",
"oldState": "connecting"
}
}
```
- **Backend handling**: process the notification without replying.
- **时机:** 后台 API 需要执行设备上的某个具体功能时。
- **发送方:** 后台 API (客户端)。
- **方法:** `tools/call`
- **消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.audio_speaker.set_volume", // 要调用的工具名称
"arguments": {
// 工具参数,对象格式
"volume": 50 // 参数名及其值
}
},
"id": 3 // 请求 ID
}
```
- **设备响应时机:** 设备收到 `tools/call` 请求,执行相应的工具函数后。
- **设备成功响应消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"id": 3, // 匹配请求 ID
"result": {
"content": [
// 工具执行结果内容
{ "type": "text", "text": "true" } // 示例set_volume 返回 bool
],
"isError": false // 表示成功
}
}
```
- **设备失败响应消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"id": 3, // 匹配请求 ID
"error": {
"code": -32601, // JSON-RPC 错误码,例如 Method not found (-32601)
"message": "Unknown tool: self.non_existent_tool" // 错误描述
}
}
```
## User-only Tools
5. **设备主动发送消息 (Notifications)**
- **时机:** 设备内部发生需要通知后台 API 的事件时(例如,状态变化,虽然代码示例中没有明确的工具发送此类消息,但 `Application::SendMcpMessage` 的存在暗示了设备可能主动发送 MCP 消息)。
- **发送方:** 设备 (服务器)。
- **方法:** 可能是以 `notifications/` 开头的方法名,或者其他自定义方法。
- **消息 (MCP payload):** 遵循 JSON-RPC Notification 格式,没有 `id` 字段。
```json
{
"jsonrpc": "2.0",
"method": "notifications/state_changed", // 示例方法名
"params": {
"newState": "idle",
"oldState": "connecting"
}
// 没有 id 字段
}
```
- **后台 API 处理:** 接收到 Notification 后,后台 API 进行相应的处理,但不回复。
The MCP server on the device maintains two kinds of tools:
## 交互图
- **Regular tools** - registered via `McpServer::AddTool`. Exposed to the backend (and hence the AI model) by default.
- **User-only tools** - registered via `McpServer::AddUserOnlyTool`. These are hidden from standard `tools/list` results, because they are privileged or user-facing actions that should not be invoked autonomously by the AI. Examples include system reboot, firmware upgrade, and screen snapshot upload.
下面是一个简化的交互序列图,展示了主要的 MCP 消息流程:
The backend opts in to user-only tools by sending `tools/list` with `params.withUserTools = true`. Typical usage: a companion app screen that exposes these actions to the end user.
See [MCP IoT control usage](./mcp-usage.md) for how to register either kind of tool on the device side.
## Sequence Diagram
A simplified diagram of the main MCP message flow:
```mermaid
sequenceDiagram
participant Device as ESP32 Device
participant BackendAPI as 后台 API (Client)
participant BackendAPI as Backend API (Client)
Note over Device, BackendAPI: 建立 WebSocket / MQTT 连接
Note over Device, BackendAPI: Establish WebSocket / MQTT
Device->>BackendAPI: Hello Message (包含 "mcp": true)
Device->>BackendAPI: Hello (features.mcp = true)
BackendAPI->>Device: MCP Initialize Request
BackendAPI->>Device: MCP Initialize request
Note over BackendAPI: method: initialize
Note over BackendAPI: params: { capabilities: ... }
Device->>BackendAPI: MCP Initialize Response
Note over Device: result: { protocolVersion: ..., serverInfo: ... }
Device->>BackendAPI: MCP Initialize response
Note over Device: result: { protocolVersion, serverInfo, ... }
BackendAPI->>Device: MCP Get Tools List Request
Note over BackendAPI: method: tools/list
Note over BackendAPI: params: { cursor: "" }
BackendAPI->>Device: MCP tools/list request
Note over BackendAPI: params: { cursor: "", withUserTools: false }
Device->>BackendAPI: MCP Get Tools List Response
Device->>BackendAPI: MCP tools/list response
Note over Device: result: { tools: [...], nextCursor: ... }
loop Optional Pagination
BackendAPI->>Device: MCP Get Tools List Request
Note over BackendAPI: method: tools/list
loop Optional pagination
BackendAPI->>Device: MCP tools/list request
Note over BackendAPI: params: { cursor: "..." }
Device->>BackendAPI: MCP Get Tools List Response
Device->>BackendAPI: MCP tools/list response
Note over Device: result: { tools: [...], nextCursor: "" }
end
BackendAPI->>Device: MCP Call Tool Request
Note over BackendAPI: method: tools/call
Note over BackendAPI: params: { name: "...", arguments: { ... } }
BackendAPI->>Device: MCP tools/call request
Note over BackendAPI: params: { name, arguments }
alt Tool Call Successful
Device->>BackendAPI: MCP Tool Call Success Response
Note over Device: result: { content: [...], isError: false }
else Tool Call Failed
Device->>BackendAPI: MCP Tool Call Error Response
Note over Device: error: { code: ..., message: ... }
alt Call succeeds
Device->>BackendAPI: MCP tools/call success response
Note over Device: result: { content, isError: false }
else Call fails
Device->>BackendAPI: MCP tools/call error response
Note over Device: error: { code, message }
end
opt Device Notification
Device->>BackendAPI: MCP Notification
opt Device notification
Device->>BackendAPI: MCP notification
Note over Device: method: notifications/...
Note over Device: params: { ... }
end
```
这份文档概述了该项目中 MCP 协议的主要交互流程。具体的参数细节和工具功能需要参考 `main/mcp_server.cc` 中 `McpServer::AddCommonTools` 以及各个工具的实现。
This document summarizes the MCP interaction flow in this project. For exact parameter shapes, behavior, and available tools, refer to `McpServer::AddCommonTools` / `AddUserOnlyTools` in `main/mcp_server.cc` and the per-board `InitializeTools` implementations.

269
docs/mcp-protocol_zh.md Normal file
View File

@ -0,0 +1,269 @@
# MCP (Model Context Protocol) 交互流程
NOTICE: AI 辅助生成, 在实现后台服务时, 请参照代码确认细节!!
本项目中的 MCP 协议用于后台 APIMCP 客户端)与 ESP32 设备MCP 服务器)之间的通信,以便后台能够发现和调用设备提供的功能(工具)。
## 协议格式
根据代码 (`main/protocols/protocol.cc`, `main/mcp_server.cc`)MCP 消息是封装在基础通信协议(如 WebSocket 或 MQTT的消息体中的。其内部结构遵循 [JSON-RPC 2.0](https://www.jsonrpc.org/specification) 规范。
整体消息结构示例:
```json
{
"session_id": "...", // 会话 ID
"type": "mcp", // 消息类型,固定为 "mcp"
"payload": { // JSON-RPC 2.0 负载
"jsonrpc": "2.0",
"method": "...", // 方法名 (如 "initialize", "tools/list", "tools/call")
"params": { ... }, // 方法参数 (对于 request)
"id": ..., // 请求 ID (对于 request 和 response)
"result": { ... }, // 方法执行结果 (对于 success response)
"error": { ... } // 错误信息 (对于 error response)
}
}
```
其中,`payload` 部分是标准的 JSON-RPC 2.0 消息:
- `jsonrpc`: 固定的字符串 "2.0"。
- `method`: 要调用的方法名称 (对于 Request)。
- `params`: 方法的参数,一个结构化值,通常为对象 (对于 Request)。
- `id`: 请求的标识符,客户端发送请求时提供,服务器响应时原样返回。用于匹配请求和响应。
- `result`: 方法成功执行时的结果 (对于 Success Response)。
- `error`: 方法执行失败时的错误信息 (对于 Error Response)。
## 交互流程及发送时机
MCP 的交互主要围绕客户端(后台 API发现和调用设备上的“工具”Tool进行。
1. **连接建立与能力通告**
- **时机:** 设备启动并成功连接到后台 API 后。
- **发送方:** 设备。
- **消息:** 设备发送基础协议的 "hello" 消息给后台 API消息中包含设备支持的能力列表例如通过支持 MCP 协议 (`"mcp": true`)。
- **示例 (非 MCP 负载,而是基础协议消息):**
```json
{
"type": "hello",
"version": ...,
"features": {
"mcp": true,
...
},
"transport": "websocket", // 或 "mqtt"
"audio_params": { ... },
"session_id": "..." // 设备收到服务器hello后可能设置
}
```
2. **初始化 MCP 会话**
- **时机:** 后台 API 收到设备 "hello" 消息,确认设备支持 MCP 后,通常作为 MCP 会话的第一个请求发送。
- **发送方:** 后台 API (客户端)。
- **方法:** `initialize`
- **消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"method": "initialize",
"params": {
"capabilities": {
// 客户端能力,可选
// 摄像头视觉相关
"vision": {
"url": "...", //摄像头: 图片处理地址(必须是http地址, 不是websocket地址)
"token": "..." // url token
}
// ... 其他客户端能力
}
},
"id": 1 // 请求 ID
}
```
- **设备响应时机:** 设备收到 `initialize` 请求并处理后。
- **设备响应消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"id": 1, // 匹配请求 ID
"result": {
"protocolVersion": "2024-11-05",
"capabilities": {
"tools": {} // 这里的 tools 似乎不列出详细信息,需要 tools/list
},
"serverInfo": {
"name": "...", // 设备名称 (BOARD_NAME)
"version": "..." // 设备固件版本
}
}
}
```
3. **发现设备工具列表**
- **时机:** 后台 API 需要获取设备当前支持的具体功能(工具)列表及其调用方式时。
- **发送方:** 后台 API (客户端)。
- **方法:** `tools/list`
- **消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"method": "tools/list",
"params": {
"cursor": "" // 用于分页,首次请求为空字符串
},
"id": 2 // 请求 ID
}
```
- **设备响应时机:** 设备收到 `tools/list` 请求并生成工具列表后。
- **设备响应消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"id": 2, // 匹配请求 ID
"result": {
"tools": [ // 工具对象列表
{
"name": "self.get_device_status",
"description": "...",
"inputSchema": { ... } // 参数 schema
},
{
"name": "self.audio_speaker.set_volume",
"description": "...",
"inputSchema": { ... } // 参数 schema
}
// ... 更多工具
],
"nextCursor": "..." // 如果列表很大需要分页,这里会包含下一个请求的 cursor 值
}
}
```
- **分页处理:** 如果 `nextCursor` 字段非空,客户端需要再次发送 `tools/list` 请求,并在 `params` 中带上这个 `cursor` 值以获取下一页工具。
4. **调用设备工具**
- **时机:** 后台 API 需要执行设备上的某个具体功能时。
- **发送方:** 后台 API (客户端)。
- **方法:** `tools/call`
- **消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.audio_speaker.set_volume", // 要调用的工具名称
"arguments": {
// 工具参数,对象格式
"volume": 50 // 参数名及其值
}
},
"id": 3 // 请求 ID
}
```
- **设备响应时机:** 设备收到 `tools/call` 请求,执行相应的工具函数后。
- **设备成功响应消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"id": 3, // 匹配请求 ID
"result": {
"content": [
// 工具执行结果内容
{ "type": "text", "text": "true" } // 示例set_volume 返回 bool
],
"isError": false // 表示成功
}
}
```
- **设备失败响应消息 (MCP payload):**
```json
{
"jsonrpc": "2.0",
"id": 3, // 匹配请求 ID
"error": {
"code": -32601, // JSON-RPC 错误码,例如 Method not found (-32601)
"message": "Unknown tool: self.non_existent_tool" // 错误描述
}
}
```
5. **设备主动发送消息 (Notifications)**
- **时机:** 设备内部发生需要通知后台 API 的事件时(例如,状态变化,虽然代码示例中没有明确的工具发送此类消息,但 `Application::SendMcpMessage` 的存在暗示了设备可能主动发送 MCP 消息)。
- **发送方:** 设备 (服务器)。
- **方法:** 可能是以 `notifications/` 开头的方法名,或者其他自定义方法。
- **消息 (MCP payload):** 遵循 JSON-RPC Notification 格式,没有 `id` 字段。
```json
{
"jsonrpc": "2.0",
"method": "notifications/state_changed", // 示例方法名
"params": {
"newState": "idle",
"oldState": "connecting"
}
// 没有 id 字段
}
```
- **后台 API 处理:** 接收到 Notification 后,后台 API 进行相应的处理,但不回复。
## 交互图
下面是一个简化的交互序列图,展示了主要的 MCP 消息流程:
```mermaid
sequenceDiagram
participant Device as ESP32 Device
participant BackendAPI as 后台 API (Client)
Note over Device, BackendAPI: 建立 WebSocket / MQTT 连接
Device->>BackendAPI: Hello Message (包含 "mcp": true)
BackendAPI->>Device: MCP Initialize Request
Note over BackendAPI: method: initialize
Note over BackendAPI: params: { capabilities: ... }
Device->>BackendAPI: MCP Initialize Response
Note over Device: result: { protocolVersion: ..., serverInfo: ... }
BackendAPI->>Device: MCP Get Tools List Request
Note over BackendAPI: method: tools/list
Note over BackendAPI: params: { cursor: "" }
Device->>BackendAPI: MCP Get Tools List Response
Note over Device: result: { tools: [...], nextCursor: ... }
loop Optional Pagination
BackendAPI->>Device: MCP Get Tools List Request
Note over BackendAPI: method: tools/list
Note over BackendAPI: params: { cursor: "..." }
Device->>BackendAPI: MCP Get Tools List Response
Note over Device: result: { tools: [...], nextCursor: "" }
end
BackendAPI->>Device: MCP Call Tool Request
Note over BackendAPI: method: tools/call
Note over BackendAPI: params: { name: "...", arguments: { ... } }
alt Tool Call Successful
Device->>BackendAPI: MCP Tool Call Success Response
Note over Device: result: { content: [...], isError: false }
else Tool Call Failed
Device->>BackendAPI: MCP Tool Call Error Response
Note over Device: error: { code: ..., message: ... }
end
opt Device Notification
Device->>BackendAPI: MCP Notification
Note over Device: method: notifications/...
Note over Device: params: { ... }
end
```
这份文档概述了该项目中 MCP 协议的主要交互流程。具体的参数细节和工具功能需要参考 `main/mcp_server.cc` 中 `McpServer::AddCommonTools` 以及各个工具的实现。

View File

@ -1,76 +1,143 @@
# MCP 协议物联网控制用法说明
# MCP IoT Control Usage
> 本文档介绍如何基于 MCP 协议实现 ESP32 设备的物联网控制。详细协议流程请参考 [`mcp-protocol.md`](./mcp-protocol.md)
> This document describes how to implement IoT control for ESP32 devices using the MCP protocol. For the detailed wire protocol, see [`mcp-protocol.md`](./mcp-protocol.md).
## 简介
## Introduction
MCPModel Context Protocol)是新一代推荐用于物联网控制的协议,通过标准 JSON-RPC 2.0 格式在后台与设备间发现和调用"工具"Tool实现灵活的设备控制。
MCP (Model Context Protocol) is the recommended protocol for IoT control in this project. It uses JSON-RPC 2.0 to let the backend discover and invoke "tools" registered by the device, giving you a flexible way to expose device functionality.
## 典型使用流程
## Typical Flow
1. 设备启动后通过基础协议(如 WebSocket/MQTT与后台建立连接。
2. 后台通过 MCP 协议的 `initialize` 方法初始化会话。
3. 后台通过 `tools/list` 获取设备支持的所有工具(功能)及参数说明。
4. 后台通过 `tools/call` 调用具体工具,实现对设备的控制。
1. The device boots and connects to the backend over WebSocket or MQTT.
2. The backend sends an `initialize` call to start the MCP session.
3. The backend issues `tools/list` to discover available tools and their input schemas.
4. The backend calls individual tools with `tools/call` to control the device.
详细协议格式与交互请见 [`mcp-protocol.md`](./mcp-protocol.md)
See [`mcp-protocol.md`](./mcp-protocol.md) for the exact message format.
## 设备端工具注册方法说明
## Registering Tools on the Device
设备通过 `McpServer::AddTool` 方法注册可被后台调用的"工具"。其常用函数签名如下:
Tools are registered through the `McpServer` singleton. There are two registration APIs:
- `McpServer::AddTool` - regular tool, visible in the default `tools/list` response and callable by the AI model.
- `McpServer::AddUserOnlyTool` - hidden tool, only returned when the backend lists tools with `withUserTools=true`. Use this for privileged or user-initiated actions (reboot, firmware upgrade, snapshots, etc.) that must not be invoked autonomously by the model.
Both APIs share the same signature:
```cpp
void AddTool(
const std::string& name, // 工具名称,建议唯一且有层次感,如 self.dog.forward
const std::string& description, // 工具描述,简明说明功能,便于大模型理解
const PropertyList& properties, // 输入参数列表(可为空),支持类型:布尔、整数、字符串
std::function<ReturnValue(const PropertyList&)> callback // 工具被调用时的回调实现
const std::string& name, // unique tool name, e.g. self.dog.forward
const std::string& description, // short description for the model
const PropertyList& properties, // input parameters (may be empty); supported types: bool, int, string
std::function<ReturnValue(const PropertyList&)> callback // implementation
);
void AddUserOnlyTool(
const std::string& name,
const std::string& description,
const PropertyList& properties,
std::function<ReturnValue(const PropertyList&)> callback
);
```
- name工具唯一标识建议用"模块.功能"命名风格。
- description自然语言描述便于 AI/用户理解。
- properties参数列表支持类型有布尔、整数、字符串可指定范围和默认值。
- callback收到调用请求时的实际执行逻辑返回值可为 bool/int/string。
## 典型注册示例(以 ESP-Hi 为例)
- `name` - unique identifier. A `module.action` naming style works well.
- `description` - natural-language description; used by the AI to decide when to call the tool.
- `properties` - input parameters. Supported property types are boolean, integer, and string, with optional min/max and default values.
- `callback` - implementation. Return values may be `bool`, `int`, or `std::string`.
## Example (ESP-Hi)
```cpp
void InitializeTools() {
auto& mcp_server = McpServer::GetInstance();
// 例1无参数控制机器人前进
mcp_server.AddTool("self.dog.forward", "机器人向前移动", PropertyList(), [this](const PropertyList&) -> ReturnValue {
servo_dog_ctrl_send(DOG_STATE_FORWARD, NULL);
return true;
});
// 例2带参数设置灯光 RGB 颜色
mcp_server.AddTool("self.light.set_rgb", "设置RGB颜色", PropertyList({
Property("r", kPropertyTypeInteger, 0, 255),
Property("g", kPropertyTypeInteger, 0, 255),
Property("b", kPropertyTypeInteger, 0, 255)
}), [this](const PropertyList& properties) -> ReturnValue {
int r = properties["r"].value<int>();
int g = properties["g"].value<int>();
int b = properties["b"].value<int>();
led_on_ = true;
SetLedColor(r, g, b);
return true;
});
// Example 1: no arguments - move the robot forward
mcp_server.AddTool("self.dog.forward",
"Move the robot forward",
PropertyList(),
[this](const PropertyList&) -> ReturnValue {
servo_dog_ctrl_send(DOG_STATE_FORWARD, NULL);
return true;
});
// Example 2: with arguments - set RGB light color
mcp_server.AddTool("self.light.set_rgb",
"Set the RGB color of the light",
PropertyList({
Property("r", kPropertyTypeInteger, 0, 255),
Property("g", kPropertyTypeInteger, 0, 255),
Property("b", kPropertyTypeInteger, 0, 255)
}),
[this](const PropertyList& properties) -> ReturnValue {
int r = properties["r"].value<int>();
int g = properties["g"].value<int>();
int b = properties["b"].value<int>();
led_on_ = true;
SetLedColor(r, g, b);
return true;
});
}
```
## 常见工具调用 JSON-RPC 示例
## Example - Registering a User-only Tool
```cpp
mcp_server.AddUserOnlyTool("self.display.clear_cache",
"Clear locally cached images. User-only action.",
PropertyList(),
[](const PropertyList&) -> ReturnValue {
ClearLocalCache();
return true;
});
```
A tool registered this way will not appear in a regular `tools/list` response. The backend must set `params.withUserTools = true` to see it.
## Built-in Tools
`McpServer::AddCommonTools` and `McpServer::AddUserOnlyTools` register a number of tools automatically:
### Default (AI-callable) tools - from `AddCommonTools`
| Tool | Description |
|------|-------------|
| `self.get_device_status` | Returns the current volume, screen, battery, network, etc. |
| `self.audio_speaker.set_volume` | Set speaker volume (`volume`: 0-100). |
| `self.screen.set_brightness` | Set screen brightness when a backlight is available (`brightness`: 0-100). |
| `self.screen.set_theme` | Switch UI theme (`theme`: `"light"` or `"dark"`), when LVGL is enabled. |
| `self.camera.take_photo` | Take a picture with the on-board camera (when the board has one) and answer the given `question` about it. |
Board-specific tools are appended after these by each board's `InitializeTools()`.
### User-only tools - from `AddUserOnlyTools`
These tools are hidden by default. The backend must pass `withUserTools=true` to `tools/list` to see them. They are intended for companion apps / end users rather than the AI model.
| Tool | Description |
|------|-------------|
| `self.get_system_info` | Return a JSON blob describing the system. |
| `self.reboot` | Reboot the device after a short delay. |
| `self.upgrade_firmware` | Download firmware from `url` and install it, then reboot. |
| `self.screen.get_info` | Return the current screen width, height, and whether it is monochrome (LVGL boards only). |
| `self.screen.snapshot` | Snapshot the screen as JPEG and upload it to `url` (LVGL boards, when `CONFIG_LV_USE_SNAPSHOT=y`). |
| `self.screen.preview_image` | Download and display an image from `url` on the screen. |
| `self.assets.set_download_url` | Set the download URL for the assets partition. |
## JSON-RPC Examples
### 1. Get the tools list
### 1. 获取工具列表
```json
{
"jsonrpc": "2.0",
"method": "tools/list",
"params": { "cursor": "" },
"params": { "cursor": "", "withUserTools": false },
"id": 1
}
```
### 2. 控制底盘前进
### 2. Move the chassis forward
```json
{
"jsonrpc": "2.0",
@ -83,7 +150,8 @@ void InitializeTools() {
}
```
### 3. 切换灯光模式
### 3. Switch the light mode
```json
{
"jsonrpc": "2.0",
@ -96,20 +164,22 @@ void InitializeTools() {
}
```
### 4. 摄像头翻转
### 4. Reboot the device (user-only)
```json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.camera.set_camera_flipped",
"name": "self.reboot",
"arguments": {}
},
"id": 4
}
```
## 备注
- 工具名称、参数及返回值请以设备端 `AddTool` 注册为准。
- 推荐所有新项目统一采用 MCP 协议进行物联网控制。
- 详细协议与进阶用法请查阅 [`mcp-protocol.md`](./mcp-protocol.md)。
## Notes
- Tool names, parameters, and return values must match what the device registers via `AddTool` / `AddUserOnlyTool`.
- Prefer MCP for any new IoT control.
- For the wire protocol and advanced topics, see [`mcp-protocol.md`](./mcp-protocol.md).

115
docs/mcp-usage_zh.md Normal file
View File

@ -0,0 +1,115 @@
# MCP 协议物联网控制用法说明
> 本文档介绍如何基于 MCP 协议实现 ESP32 设备的物联网控制。详细协议流程请参考 [`mcp-protocol_zh.md`](./mcp-protocol_zh.md)。
## 简介
MCPModel Context Protocol是新一代推荐用于物联网控制的协议通过标准 JSON-RPC 2.0 格式在后台与设备间发现和调用"工具"Tool实现灵活的设备控制。
## 典型使用流程
1. 设备启动后通过基础协议(如 WebSocket/MQTT与后台建立连接。
2. 后台通过 MCP 协议的 `initialize` 方法初始化会话。
3. 后台通过 `tools/list` 获取设备支持的所有工具(功能)及参数说明。
4. 后台通过 `tools/call` 调用具体工具,实现对设备的控制。
详细协议格式与交互请见 [`mcp-protocol_zh.md`](./mcp-protocol_zh.md)。
## 设备端工具注册方法说明
设备通过 `McpServer::AddTool` 方法注册可被后台调用的"工具"。其常用函数签名如下:
```cpp
void AddTool(
const std::string& name, // 工具名称,建议唯一且有层次感,如 self.dog.forward
const std::string& description, // 工具描述,简明说明功能,便于大模型理解
const PropertyList& properties, // 输入参数列表(可为空),支持类型:布尔、整数、字符串
std::function<ReturnValue(const PropertyList&)> callback // 工具被调用时的回调实现
);
```
- name工具唯一标识建议用"模块.功能"命名风格。
- description自然语言描述便于 AI/用户理解。
- properties参数列表支持类型有布尔、整数、字符串可指定范围和默认值。
- callback收到调用请求时的实际执行逻辑返回值可为 bool/int/string。
## 典型注册示例(以 ESP-Hi 为例)
```cpp
void InitializeTools() {
auto& mcp_server = McpServer::GetInstance();
// 例1无参数控制机器人前进
mcp_server.AddTool("self.dog.forward", "机器人向前移动", PropertyList(), [this](const PropertyList&) -> ReturnValue {
servo_dog_ctrl_send(DOG_STATE_FORWARD, NULL);
return true;
});
// 例2带参数设置灯光 RGB 颜色
mcp_server.AddTool("self.light.set_rgb", "设置RGB颜色", PropertyList({
Property("r", kPropertyTypeInteger, 0, 255),
Property("g", kPropertyTypeInteger, 0, 255),
Property("b", kPropertyTypeInteger, 0, 255)
}), [this](const PropertyList& properties) -> ReturnValue {
int r = properties["r"].value<int>();
int g = properties["g"].value<int>();
int b = properties["b"].value<int>();
led_on_ = true;
SetLedColor(r, g, b);
return true;
});
}
```
## 常见工具调用 JSON-RPC 示例
### 1. 获取工具列表
```json
{
"jsonrpc": "2.0",
"method": "tools/list",
"params": { "cursor": "" },
"id": 1
}
```
### 2. 控制底盘前进
```json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.chassis.go_forward",
"arguments": {}
},
"id": 2
}
```
### 3. 切换灯光模式
```json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.chassis.switch_light_mode",
"arguments": { "light_mode": 3 }
},
"id": 3
}
```
### 4. 摄像头翻转
```json
{
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.camera.set_camera_flipped",
"arguments": {}
},
"id": 4
}
```
## 备注
- 工具名称、参数及返回值请以设备端 `AddTool` 注册为准。
- 推荐所有新项目统一采用 MCP 协议进行物联网控制。
- 详细协议与进阶用法请查阅 [`mcp-protocol_zh.md`](./mcp-protocol_zh.md)。

View File

@ -1,76 +1,77 @@
# MQTT + UDP 混合通信协议文档
# MQTT + UDP Hybrid Communication Protocol
基于代码实现整理的 MQTT + UDP 混合通信协议文档,概述设备端与服务器之间如何通过 MQTT 进行控制消息传输,通过 UDP 进行音频数据传输的交互方式。
This document describes the MQTT + UDP hybrid protocol used between the device and the server, based on the current implementation: MQTT carries control messages, UDP carries real-time audio.
---
## 1. 协议概览
## 1. Overview
本协议采用混合传输方式:
- **MQTT**用于控制消息、状态同步、JSON 数据交换
- **UDP**:用于实时音频数据传输,支持加密
The protocol uses two channels:
### 1.1 协议特点
- **MQTT** - control messages, state synchronization, JSON payloads.
- **UDP** - real-time audio, encrypted.
- **双通道设计**:控制与数据分离,确保实时性
- **加密传输**UDP 音频数据使用 AES-CTR 加密
- **序列号保护**:防止数据包重放和乱序
- **自动重连**MQTT 连接断开时自动重连
### 1.1 Key characteristics
- **Dual channel design** - control is separated from data so audio has low latency.
- **Encrypted transport** - UDP audio is encrypted with AES-CTR.
- **Sequence numbers** - guard against replay and reordering.
- **Automatic reconnect** - MQTT reconnects on disconnect.
---
## 2. 总体流程概览
## 2. End-to-end Flow
```mermaid
sequenceDiagram
participant Device as ESP32 设备
participant MQTT as MQTT 服务器
participant UDP as UDP 服务器
participant Device as ESP32 device
participant MQTT as MQTT broker
participant UDP as UDP server
Note over Device, UDP: 1. 建立 MQTT 连接
Note over Device, UDP: 1. Establish MQTT connection
Device->>MQTT: MQTT Connect
MQTT->>Device: Connected
Note over Device, UDP: 2. 请求音频通道
Device->>MQTT: Hello Message (type: "hello", transport: "udp")
MQTT->>Device: Hello Response (UDP 连接信息 + 加密密钥)
Note over Device, UDP: 2. Request audio channel
Device->>MQTT: Hello message (type: "hello", transport: "udp")
MQTT->>Device: Hello response (UDP endpoint + encryption keys)
Note over Device, UDP: 3. 建立 UDP 连接
Note over Device, UDP: 3. Establish UDP connection
Device->>UDP: UDP Connect
UDP->>Device: Connected
Note over Device, UDP: 4. 音频数据传输
loop 音频流传输
Device->>UDP: 加密音频数据 (Opus)
UDP->>Device: 加密音频数据 (Opus)
Note over Device, UDP: 4. Audio streaming
loop Audio stream
Device->>UDP: Encrypted audio (Opus)
UDP->>Device: Encrypted audio (Opus)
end
Note over Device, UDP: 5. 控制消息交换
par 控制消息
Device->>MQTT: Listen/TTS/MCP 消息
MQTT->>Device: STT/TTS/MCP 响应
Note over Device, UDP: 5. Control messages
par Control
Device->>MQTT: Listen / TTS / MCP messages
MQTT->>Device: STT / TTS / MCP / Alert responses
end
Note over Device, UDP: 6. 关闭连接
Device->>MQTT: Goodbye Message
Note over Device, UDP: 6. Teardown
Device->>MQTT: Goodbye
Device->>UDP: Disconnect
```
---
## 3. MQTT 控制通道
## 3. MQTT Control Channel
### 3.1 连接建立
### 3.1 Connection
设备通过 MQTT 连接到服务器,连接参数包括:
- **Endpoint**MQTT 服务器地址和端口
- **Client ID**:设备唯一标识符
- **Username/Password**:认证凭据
- **Keep Alive**心跳间隔默认240秒
The device connects to the broker using:
- **Endpoint** - broker host and port.
- **Client ID** - device identifier.
- **Username / Password** - credentials.
- **Keep Alive** - heartbeat interval (default 240 s).
### 3.2 Hello 消息交换
### 3.2 Hello exchange
#### 3.2.1 设备端发送 Hello
#### 3.2.1 Device -> Server
```json
{
@ -78,7 +79,8 @@ sequenceDiagram
"version": 3,
"transport": "udp",
"features": {
"mcp": true
"mcp": true,
"aec": true
},
"audio_params": {
"format": "opus",
@ -89,7 +91,9 @@ sequenceDiagram
}
```
#### 3.2.2 服务器响应 Hello
`features.mcp` is always set; `features.aec` is set when `CONFIG_USE_SERVER_AEC` is enabled.
#### 3.2.2 Server -> Device
```json
{
@ -111,17 +115,17 @@ sequenceDiagram
}
```
**字段说明:**
- `udp.server`UDP 服务器地址
- `udp.port`UDP 服务器端口
- `udp.key`AES 加密密钥(十六进制字符串)
- `udp.nonce`AES 加密随机数(十六进制字符串)
Field reference:
- `udp.server` - UDP server address.
- `udp.port` - UDP server port.
- `udp.key` - AES key, hex-encoded.
- `udp.nonce` - AES nonce, hex-encoded.
### 3.3 JSON 消息类型
### 3.3 JSON message types
#### 3.3.1 设备端→服务器
#### 3.3.1 Device -> Server
1. **Listen 消息**
1. **Listen**
```json
{
"session_id": "xxx",
@ -131,7 +135,7 @@ sequenceDiagram
}
```
2. **Abort 消息**
2. **Abort**
```json
{
"session_id": "xxx",
@ -140,7 +144,7 @@ sequenceDiagram
}
```
3. **MCP 消息**
3. **MCP**
```json
{
"session_id": "xxx",
@ -148,12 +152,12 @@ sequenceDiagram
"payload": {
"jsonrpc": "2.0",
"id": 1,
"result": {...}
"result": {}
}
}
```
4. **Goodbye 消息**
4. **Goodbye**
```json
{
"session_id": "xxx",
@ -161,71 +165,84 @@ sequenceDiagram
}
```
#### 3.3.2 服务器→设备端
#### 3.3.2 Server -> Device
支持的消息类型与 WebSocket 协议一致,包括:
- **STT**:语音识别结果
- **TTS**:语音合成控制
- **LLM**:情感表达控制
- **MCP**:物联网控制
- **System**:系统控制
- **Custom**:自定义消息(可选)
Semantics match the WebSocket protocol. Supported types:
- **STT** - speech recognition result.
- **TTS** - TTS lifecycle (`start`, `stop`, `sentence_start`).
- **LLM** - emotion update for the UI.
- **MCP** - IoT control.
- **System** - system control, e.g. `"command": "reboot"`.
- **Alert** - show an alert on the UI; fields: `status`, `message`, `emotion`.
- **Goodbye** - server-initiated shutdown of the audio session. The device responds by closing the UDP channel without sending its own goodbye.
- **Custom** (optional, enabled via `CONFIG_RECEIVE_CUSTOM_MESSAGE`).
Example alert:
```json
{
"session_id": "xxx",
"type": "alert",
"status": "Warning",
"message": "Battery low",
"emotion": "sad"
}
```
---
## 4. UDP 音频通道
## 4. UDP Audio Channel
### 4.1 连接建立
### 4.1 Establishing the channel
设备收到 MQTT Hello 响应后,使用其中的 UDP 连接信息建立音频通道:
1. 解析 UDP 服务器地址和端口
2. 解析加密密钥和随机数
3. 初始化 AES-CTR 加密上下文
4. 建立 UDP 连接
After the device receives the MQTT hello response, it:
1. Parses the UDP host and port.
2. Parses the AES key and nonce.
3. Initializes the AES-CTR context.
4. Opens the UDP socket.
### 4.2 音频数据格式
### 4.2 Audio packet format
#### 4.2.1 加密音频包结构
#### 4.2.1 Encrypted audio packet
```
|type 1byte|flags 1byte|payload_len 2bytes|ssrc 4bytes|timestamp 4bytes|sequence 4bytes|
|type 1B|flags 1B|payload_len 2B|ssrc 4B|timestamp 4B|sequence 4B|
|payload payload_len bytes|
```
**字段说明:**
- `type`:数据包类型,固定为 0x01
- `flags`:标志位,当前未使用
- `payload_len`:负载长度(网络字节序)
- `ssrc`:同步源标识符
- `timestamp`:时间戳(网络字节序)
- `sequence`:序列号(网络字节序)
- `payload`:加密的 Opus 音频数据
Field reference:
- `type`: packet type, always `0x01`.
- `flags`: flags, currently unused.
- `payload_len`: payload length (network byte order).
- `ssrc`: synchronization source identifier.
- `timestamp`: timestamp (network byte order).
- `sequence`: sequence number (network byte order).
- `payload`: encrypted Opus audio data.
#### 4.2.2 加密算法
#### 4.2.2 Encryption
使用 **AES-CTR** 模式加密:
- **密钥**128位由服务器提供
- **随机数**128位由服务器提供
- **计数器**:包含时间戳和序列号信息
Uses **AES-CTR** with:
- **Key**: 128-bit, provided by the server.
- **Nonce**: 128-bit, provided by the server.
- **Counter**: built from the timestamp and sequence number.
### 4.3 序列号管理
### 4.3 Sequence number management
- **发送端**`local_sequence_` 单调递增
- **接收端**`remote_sequence_` 验证连续性
- **防重放**:拒绝序列号小于期望值的数据包
- **容错处理**:允许轻微的序列号跳跃,记录警告
- **Sender**: `local_sequence_` is incremented monotonically.
- **Receiver**: `remote_sequence_` validates continuity.
- **Anti-replay**: packets with sequence numbers below the expected value are dropped.
- **Tolerance**: small gaps are logged as warnings but still accepted.
### 4.4 错误处理
### 4.4 Error handling
1. **解密失败**:记录错误,丢弃数据包
2. **序列号异常**:记录警告,但仍处理数据包
3. **数据包格式错误**:记录错误,丢弃数据包
1. **Decryption failure** - log an error and drop the packet.
2. **Sequence gap** - log a warning, continue processing the packet.
3. **Malformed packet** - log an error and drop.
---
## 5. 状态管理
## 5. State Management
### 5.1 连接状态
### 5.1 Connection state
```mermaid
stateDiagram
@ -233,21 +250,21 @@ stateDiagram
[*] --> Disconnected
Disconnected --> MqttConnecting: StartMqttClient()
MqttConnecting --> MqttConnected: MQTT Connected
MqttConnecting --> Disconnected: Connect Failed
MqttConnecting --> Disconnected: Connect failed
MqttConnected --> RequestingChannel: OpenAudioChannel()
RequestingChannel --> ChannelOpened: Hello Exchange Success
RequestingChannel --> MqttConnected: Hello Timeout/Failed
ChannelOpened --> UdpConnected: UDP Connect Success
UdpConnected --> AudioStreaming: Start Audio Transfer
AudioStreaming --> UdpConnected: Stop Audio Transfer
UdpConnected --> ChannelOpened: UDP Disconnect
RequestingChannel --> ChannelOpened: Hello exchange success
RequestingChannel --> MqttConnected: Hello timeout / failed
ChannelOpened --> UdpConnected: UDP connect success
UdpConnected --> AudioStreaming: Start audio
AudioStreaming --> UdpConnected: Stop audio
UdpConnected --> ChannelOpened: UDP disconnect
ChannelOpened --> MqttConnected: CloseAudioChannel()
MqttConnected --> Disconnected: MQTT Disconnect
MqttConnected --> Disconnected: MQTT disconnect
```
### 5.2 状态检查
### 5.2 State check
设备通过以下条件判断音频通道是否可用:
The device determines whether the audio channel is available with:
```cpp
bool IsAudioChannelOpened() const {
return udp_ != nullptr && !error_occurred_ && !IsTimeout();
@ -256,138 +273,137 @@ bool IsAudioChannelOpened() const {
---
## 6. 配置参数
## 6. Configuration Parameters
### 6.1 MQTT 配置
### 6.1 MQTT settings
从设置中读取的配置项:
- `endpoint`MQTT 服务器地址
- `client_id`:客户端标识符
- `username`:用户名
- `password`:密码
- `keepalive`心跳间隔默认240秒
- `publish_topic`:发布主题
Read from storage:
- `endpoint` - broker address.
- `client_id` - client identifier.
- `username` - user name.
- `password` - password.
- `keepalive` - keep-alive interval (default 240 s).
- `publish_topic` - publish topic.
### 6.2 音频参数
### 6.2 Audio parameters
- **格式**Opus
- **采样率**16000 Hz设备端/ 24000 Hz服务器端
- **声道数**1单声道
- **帧时长**60ms
- **Format**: Opus
- **Sample rate**: 16 kHz device / 24 kHz server
- **Channels**: 1 (mono)
- **Frame duration**: 60 ms
---
## 7. 错误处理与重连
## 7. Error Handling and Reconnection
### 7.1 MQTT 重连机制
### 7.1 MQTT reconnect
- 连接失败时自动重试
- 支持错误上报控制
- 断线时触发清理流程
- Automatic retry on connect failure.
- Optional error reporting.
- Clean-up runs on disconnect.
### 7.2 UDP 连接管理
### 7.2 UDP connection
- 连接失败时不自动重试
- 依赖 MQTT 通道重新协商
- 支持连接状态查询
- No automatic retry; depends on re-negotiation via MQTT.
- Status can be queried at any time.
### 7.3 超时处理
### 7.3 Timeouts
基类 `Protocol` 提供超时检测:
- 默认超时时间:120
- 基于最后接收时间计算
- 超时时自动标记为不可用
The base `Protocol` class provides timeout detection:
- Default timeout: 120 s.
- Based on the time since the last incoming packet.
- After a timeout the channel is marked unavailable.
---
## 8. 安全考虑
## 8. Security
### 8.1 传输加密
### 8.1 Transport encryption
- **MQTT**:支持 TLS/SSL 加密(端口8883
- **UDP**:使用 AES-CTR 加密音频数据
- **MQTT**: supports TLS/SSL (port 8883).
- **UDP**: AES-CTR on audio payloads.
### 8.2 认证机制
### 8.2 Authentication
- **MQTT**:用户名/密码认证
- **UDP**:通过 MQTT 通道分发密钥
- **MQTT**: user name / password.
- **UDP**: keys are distributed via the MQTT channel.
### 8.3 防重放攻击
### 8.3 Anti-replay
- 序列号单调递增
- 拒绝过期数据包
- 时间戳验证
- Monotonically increasing sequence numbers.
- Stale packets are dropped.
- Timestamps are validated.
---
## 9. 性能优化
## 9. Performance Notes
### 9.1 并发控制
### 9.1 Concurrency
使用互斥锁保护 UDP 连接:
A mutex protects the UDP connection:
```cpp
std::lock_guard<std::mutex> lock(channel_mutex_);
```
### 9.2 内存管理
### 9.2 Memory management
- 动态创建/销毁网络对象
- 智能指针管理音频数据包
- 及时释放加密上下文
- Network objects are created and destroyed dynamically.
- Audio packets are managed with smart pointers.
- Encryption contexts are released promptly.
### 9.3 网络优化
### 9.3 Network optimizations
- UDP 连接复用
- 数据包大小优化
- 序列号连续性检查
- UDP connection reuse.
- Reasonable packet sizes.
- Sequence continuity checks.
---
## 10. WebSocket 协议的比较
## 10. Comparison with WebSocket
| 特性 | MQTT + UDP | WebSocket |
|------|------------|-----------|
| 控制通道 | MQTT | WebSocket |
| 音频通道 | UDP (加密) | WebSocket (二进制) |
| 实时性 | 高 (UDP) | 中等 |
| 可靠性 | 中等 | 高 |
| 复杂度 | 高 | 低 |
| 加密 | AES-CTR | TLS |
| 防火墙友好度 | 低 | 高 |
| Feature | MQTT + UDP | WebSocket |
|---------|------------|-----------|
| Control channel | MQTT | WebSocket |
| Audio channel | UDP (encrypted) | WebSocket (binary) |
| Latency | Low (UDP) | Medium |
| Reliability | Medium | High |
| Complexity | High | Low |
| Encryption | AES-CTR | TLS |
| Firewall friendliness | Low | High |
---
## 11. 部署建议
## 11. Deployment Notes
### 11.1 网络环境
### 11.1 Network
- 确保 UDP 端口可达
- 配置防火墙规则
- 考虑 NAT 穿透
- Ensure UDP ports are reachable.
- Configure firewall rules accordingly.
- Plan for NAT traversal if needed.
### 11.2 服务器配置
### 11.2 Server infrastructure
- MQTT Broker 配置
- UDP 服务器部署
- 密钥管理系统
- MQTT broker configuration.
- UDP server deployment.
- Key management.
### 11.3 监控指标
### 11.3 Monitoring
- 连接成功率
- 音频传输延迟
- 数据包丢失率
- 解密失败率
- Connection success rate.
- Audio transmission latency.
- Packet loss.
- Decryption failures.
---
## 12. 总结
## 12. Summary
MQTT + UDP 混合协议通过以下设计实现高效的音视频通信:
The MQTT + UDP hybrid protocol achieves efficient audio communication through:
- **分离式架构**:控制与数据通道分离,各司其职
- **加密保护**AES-CTR 确保音频数据安全传输
- **序列化管理**:防止重放攻击和数据乱序
- **自动恢复**:支持连接断开后的自动重连
- **性能优化**UDP 传输保证音频数据的实时性
- **Split architecture** - separate control and data channels with clear responsibilities.
- **Encryption** - AES-CTR protects audio payloads.
- **Sequence management** - prevents replay and reordering.
- **Automatic recovery** - MQTT reconnects on failure.
- **Performance** - UDP keeps audio latency low.
该协议适用于对实时性要求较高的语音交互场景,但需要在网络复杂度和传输性能之间做出权衡。
The protocol is a good fit for low-latency voice interaction, at the cost of higher network complexity than pure WebSocket.

393
docs/mqtt-udp_zh.md Normal file
View File

@ -0,0 +1,393 @@
# MQTT + UDP 混合通信协议文档
基于代码实现整理的 MQTT + UDP 混合通信协议文档,概述设备端与服务器之间如何通过 MQTT 进行控制消息传输,通过 UDP 进行音频数据传输的交互方式。
---
## 1. 协议概览
本协议采用混合传输方式:
- **MQTT**用于控制消息、状态同步、JSON 数据交换
- **UDP**:用于实时音频数据传输,支持加密
### 1.1 协议特点
- **双通道设计**:控制与数据分离,确保实时性
- **加密传输**UDP 音频数据使用 AES-CTR 加密
- **序列号保护**:防止数据包重放和乱序
- **自动重连**MQTT 连接断开时自动重连
---
## 2. 总体流程概览
```mermaid
sequenceDiagram
participant Device as ESP32 设备
participant MQTT as MQTT 服务器
participant UDP as UDP 服务器
Note over Device, UDP: 1. 建立 MQTT 连接
Device->>MQTT: MQTT Connect
MQTT->>Device: Connected
Note over Device, UDP: 2. 请求音频通道
Device->>MQTT: Hello Message (type: "hello", transport: "udp")
MQTT->>Device: Hello Response (UDP 连接信息 + 加密密钥)
Note over Device, UDP: 3. 建立 UDP 连接
Device->>UDP: UDP Connect
UDP->>Device: Connected
Note over Device, UDP: 4. 音频数据传输
loop 音频流传输
Device->>UDP: 加密音频数据 (Opus)
UDP->>Device: 加密音频数据 (Opus)
end
Note over Device, UDP: 5. 控制消息交换
par 控制消息
Device->>MQTT: Listen/TTS/MCP 消息
MQTT->>Device: STT/TTS/MCP 响应
end
Note over Device, UDP: 6. 关闭连接
Device->>MQTT: Goodbye Message
Device->>UDP: Disconnect
```
---
## 3. MQTT 控制通道
### 3.1 连接建立
设备通过 MQTT 连接到服务器,连接参数包括:
- **Endpoint**MQTT 服务器地址和端口
- **Client ID**:设备唯一标识符
- **Username/Password**:认证凭据
- **Keep Alive**心跳间隔默认240秒
### 3.2 Hello 消息交换
#### 3.2.1 设备端发送 Hello
```json
{
"type": "hello",
"version": 3,
"transport": "udp",
"features": {
"mcp": true
},
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60
}
}
```
#### 3.2.2 服务器响应 Hello
```json
{
"type": "hello",
"transport": "udp",
"session_id": "xxx",
"audio_params": {
"format": "opus",
"sample_rate": 24000,
"channels": 1,
"frame_duration": 60
},
"udp": {
"server": "192.168.1.100",
"port": 8888,
"key": "0123456789ABCDEF0123456789ABCDEF",
"nonce": "0123456789ABCDEF0123456789ABCDEF"
}
}
```
**字段说明:**
- `udp.server`UDP 服务器地址
- `udp.port`UDP 服务器端口
- `udp.key`AES 加密密钥(十六进制字符串)
- `udp.nonce`AES 加密随机数(十六进制字符串)
### 3.3 JSON 消息类型
#### 3.3.1 设备端→服务器
1. **Listen 消息**
```json
{
"session_id": "xxx",
"type": "listen",
"state": "start",
"mode": "manual"
}
```
2. **Abort 消息**
```json
{
"session_id": "xxx",
"type": "abort",
"reason": "wake_word_detected"
}
```
3. **MCP 消息**
```json
{
"session_id": "xxx",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"id": 1,
"result": {...}
}
}
```
4. **Goodbye 消息**
```json
{
"session_id": "xxx",
"type": "goodbye"
}
```
#### 3.3.2 服务器→设备端
支持的消息类型与 WebSocket 协议一致,包括:
- **STT**:语音识别结果
- **TTS**:语音合成控制
- **LLM**:情感表达控制
- **MCP**:物联网控制
- **System**:系统控制
- **Custom**:自定义消息(可选)
---
## 4. UDP 音频通道
### 4.1 连接建立
设备收到 MQTT Hello 响应后,使用其中的 UDP 连接信息建立音频通道:
1. 解析 UDP 服务器地址和端口
2. 解析加密密钥和随机数
3. 初始化 AES-CTR 加密上下文
4. 建立 UDP 连接
### 4.2 音频数据格式
#### 4.2.1 加密音频包结构
```
|type 1byte|flags 1byte|payload_len 2bytes|ssrc 4bytes|timestamp 4bytes|sequence 4bytes|
|payload payload_len bytes|
```
**字段说明:**
- `type`:数据包类型,固定为 0x01
- `flags`:标志位,当前未使用
- `payload_len`:负载长度(网络字节序)
- `ssrc`:同步源标识符
- `timestamp`:时间戳(网络字节序)
- `sequence`:序列号(网络字节序)
- `payload`:加密的 Opus 音频数据
#### 4.2.2 加密算法
使用 **AES-CTR** 模式加密:
- **密钥**128位由服务器提供
- **随机数**128位由服务器提供
- **计数器**:包含时间戳和序列号信息
### 4.3 序列号管理
- **发送端**`local_sequence_` 单调递增
- **接收端**`remote_sequence_` 验证连续性
- **防重放**:拒绝序列号小于期望值的数据包
- **容错处理**:允许轻微的序列号跳跃,记录警告
### 4.4 错误处理
1. **解密失败**:记录错误,丢弃数据包
2. **序列号异常**:记录警告,但仍处理数据包
3. **数据包格式错误**:记录错误,丢弃数据包
---
## 5. 状态管理
### 5.1 连接状态
```mermaid
stateDiagram
direction TB
[*] --> Disconnected
Disconnected --> MqttConnecting: StartMqttClient()
MqttConnecting --> MqttConnected: MQTT Connected
MqttConnecting --> Disconnected: Connect Failed
MqttConnected --> RequestingChannel: OpenAudioChannel()
RequestingChannel --> ChannelOpened: Hello Exchange Success
RequestingChannel --> MqttConnected: Hello Timeout/Failed
ChannelOpened --> UdpConnected: UDP Connect Success
UdpConnected --> AudioStreaming: Start Audio Transfer
AudioStreaming --> UdpConnected: Stop Audio Transfer
UdpConnected --> ChannelOpened: UDP Disconnect
ChannelOpened --> MqttConnected: CloseAudioChannel()
MqttConnected --> Disconnected: MQTT Disconnect
```
### 5.2 状态检查
设备通过以下条件判断音频通道是否可用:
```cpp
bool IsAudioChannelOpened() const {
return udp_ != nullptr && !error_occurred_ && !IsTimeout();
}
```
---
## 6. 配置参数
### 6.1 MQTT 配置
从设置中读取的配置项:
- `endpoint`MQTT 服务器地址
- `client_id`:客户端标识符
- `username`:用户名
- `password`:密码
- `keepalive`心跳间隔默认240秒
- `publish_topic`:发布主题
### 6.2 音频参数
- **格式**Opus
- **采样率**16000 Hz设备端/ 24000 Hz服务器端
- **声道数**1单声道
- **帧时长**60ms
---
## 7. 错误处理与重连
### 7.1 MQTT 重连机制
- 连接失败时自动重试
- 支持错误上报控制
- 断线时触发清理流程
### 7.2 UDP 连接管理
- 连接失败时不自动重试
- 依赖 MQTT 通道重新协商
- 支持连接状态查询
### 7.3 超时处理
基类 `Protocol` 提供超时检测:
- 默认超时时间120 秒
- 基于最后接收时间计算
- 超时时自动标记为不可用
---
## 8. 安全考虑
### 8.1 传输加密
- **MQTT**:支持 TLS/SSL 加密端口8883
- **UDP**:使用 AES-CTR 加密音频数据
### 8.2 认证机制
- **MQTT**:用户名/密码认证
- **UDP**:通过 MQTT 通道分发密钥
### 8.3 防重放攻击
- 序列号单调递增
- 拒绝过期数据包
- 时间戳验证
---
## 9. 性能优化
### 9.1 并发控制
使用互斥锁保护 UDP 连接:
```cpp
std::lock_guard<std::mutex> lock(channel_mutex_);
```
### 9.2 内存管理
- 动态创建/销毁网络对象
- 智能指针管理音频数据包
- 及时释放加密上下文
### 9.3 网络优化
- UDP 连接复用
- 数据包大小优化
- 序列号连续性检查
---
## 10. 与 WebSocket 协议的比较
| 特性 | MQTT + UDP | WebSocket |
|------|------------|-----------|
| 控制通道 | MQTT | WebSocket |
| 音频通道 | UDP (加密) | WebSocket (二进制) |
| 实时性 | 高 (UDP) | 中等 |
| 可靠性 | 中等 | 高 |
| 复杂度 | 高 | 低 |
| 加密 | AES-CTR | TLS |
| 防火墙友好度 | 低 | 高 |
---
## 11. 部署建议
### 11.1 网络环境
- 确保 UDP 端口可达
- 配置防火墙规则
- 考虑 NAT 穿透
### 11.2 服务器配置
- MQTT Broker 配置
- UDP 服务器部署
- 密钥管理系统
### 11.3 监控指标
- 连接成功率
- 音频传输延迟
- 数据包丢失率
- 解密失败率
---
## 12. 总结
MQTT + UDP 混合协议通过以下设计实现高效的音视频通信:
- **分离式架构**:控制与数据通道分离,各司其职
- **加密保护**AES-CTR 确保音频数据安全传输
- **序列化管理**:防止重放攻击和数据乱序
- **自动恢复**:支持连接断开后的自动重连
- **性能优化**UDP 传输保证音频数据的实时性
该协议适用于对实时性要求较高的语音交互场景,但需要在网络复杂度和传输性能之间做出权衡。

View File

@ -1,32 +1,33 @@
以下是一份基于代码实现整理的 WebSocket 通信协议文档,概述设备端与服务器之间如何通过 WebSocket 进行交互。
# WebSocket Communication Protocol
该文档仅基于所提供的代码推断,实际部署时可能需要结合服务器端实现进行进一步确认或补充。
This document describes the WebSocket communication protocol between the device and the server, based on the current code. When implementing a server, please cross-check with the actual implementation.
---
## 1. 总体流程概览
## 1. Overall Flow
1. **设备端初始化**
- 设备上电、初始化 `Application`
- 初始化音频编解码器、显示屏、LED 等
- 连接网络
- 创建并初始化实现 `Protocol` 接口的 WebSocket 协议实例(`WebsocketProtocol`
- 进入主循环等待事件(音频输入、音频输出、调度任务等)。
1. **Device initialization**
- The device boots and initializes `Application`:
- Initializes the audio codec, display, LEDs, etc.
- Connects to the network.
- Creates a WebSocket protocol instance (`WebsocketProtocol`) that implements the `Protocol` interface.
- Enters the main loop and waits for events (audio input, audio output, scheduled tasks, etc.).
2. **建立 WebSocket 连接**
- 当设备需要开始语音会话时(例如用户唤醒、手动按键触发等),调用 `OpenAudioChannel()`
- 根据配置获取 WebSocket URL
- 设置若干请求头(`Authorization`, `Protocol-Version`, `Device-Id`, `Client-Id`
- 调用 `Connect()` 与服务器建立 WebSocket 连接
2. **Opening the WebSocket connection**
- When the device needs to start a voice session (wake-up, button press, etc.), it calls `OpenAudioChannel()`:
- Reads the WebSocket URL from settings.
- Sets the request headers (`Authorization`, `Protocol-Version`, `Device-Id`, `Client-Id`).
- Calls `Connect()` to establish the WebSocket connection.
3. **设备端发送 "hello" 消息**
- 连接成功后,设备会发送一条 JSON 消息,示例结构如下:
3. **Device sends a "hello" message**
- Once connected, the device sends a JSON message. Example:
```json
{
"type": "hello",
"version": 1,
"features": {
"mcp": true
"mcp": true,
"aec": true
},
"transport": "websocket",
"audio_params": {
@ -37,13 +38,13 @@
}
}
```
- 其中 `features` 字段为可选,内容根据设备编译配置自动生成。例如:`"mcp": true` 表示支持 MCP 协议。
- `frame_duration` 的值对应 `OPUS_FRAME_DURATION_MS`(例如 60ms)。
- `features` is optional and generated from compile-time configuration. For example, `"mcp": true` means the device supports MCP, and `"aec": true` is emitted when `CONFIG_USE_SERVER_AEC` is enabled.
- `frame_duration` matches `OPUS_FRAME_DURATION_MS` (typically 60 ms).
4. **服务器回复 "hello"**
- 设备等待服务器返回一条包含 `"type": "hello"` 的 JSON 消息,并检查 `"transport": "websocket"` 是否匹配。
- 服务器可选下发 `session_id` 字段,设备端收到后会自动记录。
- 示例:
4. **Server replies with "hello"**
- The device waits for a JSON message whose `"type"` is `"hello"` and whose `"transport"` is `"websocket"`.
- The server may include a `session_id`; the device will store it.
- Example:
```json
{
"type": "hello",
@ -57,89 +58,90 @@
}
}
```
- 如果匹配,则认为服务器已就绪,标记音频通道打开成功。
- 如果在超时时间(默认 10 秒)内未收到正确回复,认为连接失败并触发网络错误回调。
- If `transport` matches, the device marks the audio channel as opened.
- If no valid hello arrives within the timeout (default 10 seconds), the connection is considered failed and the network error callback is fired.
5. **后续消息交互**
- 设备端和服务器端之间可发送两种主要类型的数据:
1. **二进制音频数据**Opus 编码)
2. **文本 JSON 消息**用于传输聊天状态、TTS/STT 事件、MCP 协议消息等)
5. **Subsequent exchanges**
- Two kinds of data are sent in either direction:
1. **Binary audio data** (Opus encoded)
2. **Text JSON messages** (chat state, TTS/STT events, MCP messages, etc.)
- 在代码里,接收回调主要分为:
- `OnData(...)`:
- `binary` `true` 时,认为是音频帧;设备会将其当作 Opus 数据进行解码。
- `binary` `false` 时,认为是 JSON 文本,需要在设备端用 cJSON 进行解析并做相应业务逻辑处理如聊天、TTS、MCP 协议消息等)。
- In the code, the receive callback splits traffic as follows:
- `OnData(...)`:
- If `binary` is `true`, the payload is treated as an Opus frame and decoded.
- If `binary` is `false`, the payload is parsed as JSON and dispatched by `type`.
- 当服务器或网络出现断连,回调 `OnDisconnected()` 被触发:
- 设备会调用 `on_audio_channel_closed_()`,并最终回到空闲状态。
- When the server or network drops, `OnDisconnected()` fires:
- The device invokes `on_audio_channel_closed_()` and eventually returns to the idle state.
6. **关闭 WebSocket 连接**
- 设备在需要结束语音会话时,会调用 `CloseAudioChannel()` 主动断开连接,并回到空闲状态。
- 或者如果服务器端主动断开,也会引发同样的回调流程。
6. **Closing the WebSocket connection**
- When the device wants to end the session, it calls `CloseAudioChannel()` to tear down the socket and returns to idle.
- The same callback chain runs if the server closes the socket first.
---
## 2. 通用请求头
## 2. Common Request Headers
在建立 WebSocket 连接时,代码示例中设置了以下请求头:
When establishing the WebSocket connection, the device sets the following headers:
- `Authorization`: 用于存放访问令牌,形如 `"Bearer <token>"`
- `Protocol-Version`: 协议版本号,与 hello 消息体内的 `version` 字段保持一致
- `Device-Id`: 设备物理网卡 MAC 地址
- `Client-Id`: 软件生成的 UUID擦除 NVS 或重新烧录完整固件会重置)
- `Authorization`: access token, usually formatted as `"Bearer <token>"`.
- `Protocol-Version`: the protocol version number, matching the `version` field in the hello message.
- `Device-Id`: the physical MAC address of the device.
- `Client-Id`: a software-generated UUID (reset when NVS is erased or the full firmware is re-flashed).
这些头会随着 WebSocket 握手一起发送到服务器,服务器可根据需求进行校验、认证等。
These headers are sent with the WebSocket handshake; the server can use them for authentication or bookkeeping.
---
## 3. 二进制协议版本
## 3. Binary Protocol Versions
设备支持多种二进制协议版本,通过配置中的 `version` 字段指定:
The device supports several binary protocol versions, selected by the `version` field in settings:
### 3.1 版本1默认
直接发送 Opus 音频数据,无额外元数据。Websocket 协议会区分 text binary
### 3.1 Version 1 (default)
Raw Opus frames with no extra metadata. The WebSocket layer already distinguishes text and binary frames.
### 3.2 版本2
使用 `BinaryProtocol2` 结构:
### 3.2 Version 2
Uses the `BinaryProtocol2` structure:
```c
struct BinaryProtocol2 {
uint16_t version; // 协议版本
uint16_t type; // 消息类型 (0: OPUS, 1: JSON)
uint32_t reserved; // 保留字段
uint32_t timestamp; // 时间戳(毫秒,用于服务器端AEC
uint32_t payload_size; // 负载大小(字节)
uint8_t payload[]; // 负载数据
uint16_t version; // protocol version
uint16_t type; // message type (0: OPUS, 1: JSON)
uint32_t reserved; // reserved
uint32_t timestamp; // timestamp in milliseconds (useful for server-side AEC)
uint32_t payload_size; // payload size in bytes
uint8_t payload[]; // payload
} __attribute__((packed));
```
### 3.3 版本3
使用 `BinaryProtocol3` 结构:
### 3.3 Version 3
Uses the `BinaryProtocol3` structure:
```c
struct BinaryProtocol3 {
uint8_t type; // 消息类型
uint8_t reserved; // 保留字段
uint16_t payload_size; // 负载大小
uint8_t payload[]; // 负载数据
uint8_t type; // message type
uint8_t reserved; // reserved
uint16_t payload_size; // payload size
uint8_t payload[]; // payload
} __attribute__((packed));
```
---
## 4. JSON 消息结构
## 4. JSON Message Structure
WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及其对应业务逻辑。若消息里包含未列出的字段,可能为可选或特定实现细节。
WebSocket text frames carry JSON. The most common `"type"` values and their semantics are listed below. Fields that are not listed may be implementation-specific or optional.
### 4.1 设备端→服务器
### 4.1 Device -> Server
1. **Hello**
- 连接成功后,由设备端发送,告知服务器基本参数。
- 例:
1. **Hello**
- Sent once the connection is established; announces the device parameters.
- Example:
```json
{
"type": "hello",
"version": 1,
"features": {
"mcp": true
"mcp": true,
"aec": true
},
"transport": "websocket",
"audio_params": {
@ -151,14 +153,14 @@ WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及
}
```
2. **Listen**
- 表示设备端开始或停止录音监听。
- 常见字段:
- `"session_id"`:会话标识
- `"type": "listen"`
- `"state"``"start"`, `"stop"`, `"detect"`(唤醒检测已触发)
- `"mode"``"auto"`, `"manual"` `"realtime"`,表示识别模式。
- 例:开始监听
2. **Listen**
- Tells the server that the device is starting or stopping microphone capture.
- Common fields:
- `"session_id"`: session identifier.
- `"type": "listen"`
- `"state"`: `"start"`, `"stop"`, or `"detect"` (wake word detected).
- `"mode"`: `"auto"`, `"manual"`, or `"realtime"`.
- Example (start listening):
```json
{
"session_id": "xxx",
@ -168,9 +170,9 @@ WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及
}
```
3. **Abort**
- 终止当前说话TTS 播放)或语音通道。
- 例:
3. **Abort**
- Aborts the current TTS playback or the voice channel.
- Example:
```json
{
"session_id": "xxx",
@ -178,25 +180,24 @@ WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及
"reason": "wake_word_detected"
}
```
- `reason` 值可为 `"wake_word_detected"` 或其他。
- `reason` may be `"wake_word_detected"` or other implementation-defined values.
4. **Wake Word Detected**
- 用于设备端向服务器告知检测到唤醒词。
- 在发送该消息之前,可提前发送唤醒词的 Opus 音频数据,用于服务器进行声纹检测。
- 例:
4. **Wake Word Detected**
- Sent by the device when the local wake word detector fires.
- Opus audio containing the wake word may be streamed before this message to let the server run voice-print verification.
- Example:
```json
{
"session_id": "xxx",
"type": "listen",
"state": "detect",
"text": "你好小明"
"text": "Hi XiaoZhi"
}
```
5. **MCP**
- 推荐用于物联网控制的新一代协议。所有设备能力发现、工具调用等均通过 type: "mcp" 的消息进行,payload 内部为标准 JSON-RPC 2.0(详见 [MCP 协议文档](./mcp-protocol.md))。
- **设备端到服务器发送 result 的例子:**
- The recommended channel for IoT control. Device capability discovery and tool invocation all flow through `type: "mcp"` messages whose `payload` is JSON-RPC 2.0 (see [MCP protocol document](./mcp-protocol.md)).
- Device-to-server response example:
```json
{
"session_id": "xxx",
@ -216,34 +217,31 @@ WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及
---
### 4.2 服务器→设备端
### 4.2 Server -> Device
1. **Hello**
- 服务器端返回的握手确认消息。
- 必须包含 `"type": "hello"` `"transport": "websocket"`
- 可能会带有 `audio_params`,表示服务器期望的音频参数,或与设备端对齐的配置。
- 服务器可选下发 `session_id` 字段,设备端收到后会自动记录。
- 成功接收后设备端会设置事件标志,表示 WebSocket 通道就绪。
1. **Hello**
- The handshake acknowledgement.
- Must include `"type": "hello"` and `"transport": "websocket"`.
- May include `audio_params`, meaning the audio parameters the server expects / the canonical set agreed with the device.
- May include a `session_id` which the device records.
- Once received, the device sets the "audio channel open" event.
2. **STT**
2. **STT**
- `{"session_id": "xxx", "type": "stt", "text": "..."}`
- 表示服务器端识别到了用户语音。(例如语音转文本结果)
- 设备可能将此文本显示到屏幕上,后续再进入回答等流程。
- The speech-to-text result for the user utterance. Typically shown on the display before moving to the response.
3. **LLM**
3. **LLM**
- `{"session_id": "xxx", "type": "llm", "emotion": "happy", "text": "😀"}`
- 服务器指示设备调整表情动画 / UI 表达。
- Tells the device to update the emotion / facial expression on the UI.
4. **TTS**
- `{"session_id": "xxx", "type": "tts", "state": "start"}`:服务器准备下发 TTS 音频,设备端进入 "speaking" 播放状态。
- `{"session_id": "xxx", "type": "tts", "state": "stop"}`:表示本次 TTS 结束。
- `{"session_id": "xxx", "type": "tts", "state": "sentence_start", "text": "..."}`
- 让设备在界面上显示当前要播放或朗读的文本片段(例如用于显示给用户)。
4. **TTS**
- `{"session_id": "xxx", "type": "tts", "state": "start"}`: the server is about to stream TTS audio. The device transitions to the speaking state.
- `{"session_id": "xxx", "type": "tts", "state": "stop"}`: the TTS segment is finished.
- `{"session_id": "xxx", "type": "tts", "state": "sentence_start", "text": "..."}`: show the current sentence on the UI (for example, subtitle display).
5. **MCP**
- 服务器通过 type: "mcp" 的消息下发物联网相关的控制指令或返回调用结果payload 结构同上。
- **服务器到设备端发送 tools/call 的例子:**
- The server sends IoT-related commands or receives tool-call results. The `payload` structure follows JSON-RPC 2.0.
- Server-to-device `tools/call` example:
```json
{
"session_id": "xxx",
@ -261,8 +259,8 @@ WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及
```
6. **System**
- 系统控制命令,常用于远程升级更新。
- 例:
- System-level control, often used for remote upgrades / management.
- Example:
```json
{
"session_id": "xxx",
@ -270,153 +268,190 @@ WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及
"command": "reboot"
}
```
- 支持的命令:
- `"reboot"`:重启设备
- Supported commands:
- `"reboot"`: reboot the device.
7. **Custom**(可选)
- 自定义消息,当 `CONFIG_RECEIVE_CUSTOM_MESSAGE` 启用时支持。
- 例:
7. **Alert**
- Instructs the device to show an alert and play a vibration sound. Handled in `Application::OnIncomingJson`.
- Example:
```json
{
"session_id": "xxx",
"type": "alert",
"status": "Warning",
"message": "Battery low",
"emotion": "sad"
}
```
- Fields:
- `status`: short title displayed on screen.
- `message`: detailed message.
- `emotion`: emotion shown while alerting (e.g. `"sad"`, `"neutral"`).
8. **Custom** (optional)
- Available when `CONFIG_RECEIVE_CUSTOM_MESSAGE` is enabled.
- Example:
```json
{
"session_id": "xxx",
"type": "custom",
"payload": {
"message": "自定义内容"
"message": "anything you want"
}
}
```
8. **音频数据:二进制帧**
- 当服务器发送音频二进制帧Opus 编码)时,设备端解码并播放。
- 若设备端正在处于 "listening" (录音)状态,收到的音频帧会被忽略或清空以防冲突。
9. **Binary audio frames**
- When the server pushes Opus-encoded audio as binary frames, the device decodes and plays them.
- Frames received while the device is in the `listening` state are dropped to avoid conflicts with the microphone stream.
---
## 5. 音频编解码
## 5. Audio Codec
1. **设备端发送录音数据**
- 音频输入经过可能的回声消除、降噪或音量增益后,通过 Opus 编码打包为二进制帧发送给服务器。
- 根据协议版本,可能直接发送 Opus 数据版本1或使用带元数据的二进制协议版本2/3
1. **Device uploads microphone audio**
- After optional AEC / NR / AGC processing, the audio is Opus-encoded and sent as binary frames.
- Depending on the protocol version, the frames may be raw Opus (v1) or wrapped in the metadata structures (v2/v3).
2. **设备端播放收到的音频**
- 收到服务器的二进制帧时,同样认定是 Opus 数据。
- 设备端会进行解码,然后交由音频输出接口播放。
- 如果服务器的音频采样率与设备不一致,会在解码后再进行重采样。
2. **Device plays server audio**
- Incoming binary frames are also treated as Opus.
- The device decodes and sends them to the audio output.
- If the sample rate differs from the device's output, it is resampled after decoding.
---
## 6. 常见状态流转
## 6. Device States
以下为常见设备端关键状态流转,与 WebSocket 消息对应:
### 6.1 Main states
1. **Idle** → **Connecting**
- 用户触发或唤醒后,设备调用 `OpenAudioChannel()` → 建立 WebSocket 连接 → 发送 `"type":"hello"`。
The device state machine is defined in [`main/device_state.h`](../main/device_state.h) and includes:
2. **Connecting** → **Listening**
- 成功建立连接后,若继续执行 `SendStartListening(...)`,则进入录音状态。此时设备会持续编码麦克风数据并发送到服务器。
- `kDeviceStateUnknown`
- `kDeviceStateStarting`
- `kDeviceStateWifiConfiguring`
- `kDeviceStateIdle`
- `kDeviceStateConnecting`
- `kDeviceStateListening`
- `kDeviceStateSpeaking`
- `kDeviceStateUpgrading`
- `kDeviceStateActivating`
- `kDeviceStateAudioTesting` (factory / bring-up audio testing)
- `kDeviceStateFatalError` (non-recoverable error requiring user action)
3. **Listening** → **Speaking**
- 收到服务器 TTS Start 消息 (`{"type":"tts","state":"start"}`) → 停止录音并播放接收到的音频。
### 6.2 Typical transitions
4. **Speaking** → **Idle**
- 服务器 TTS Stop (`{"type":"tts","state":"stop"}`) → 音频播放结束。若未继续进入自动监听,则返回 Idle如果配置了自动循环则再度进入 Listening。
1. **Idle -> Connecting**
- Triggered by wake word or button press. The device calls `OpenAudioChannel()`, sets up the WebSocket, and sends `"type":"hello"`.
5. **Listening** / **Speaking** → **Idle**(遇到异常或主动中断)
- 调用 `SendAbortSpeaking(...)` 或 `CloseAudioChannel()` → 中断会话 → 关闭 WebSocket → 状态回到 Idle。
2. **Connecting -> Listening**
- Once connected, `SendStartListening(...)` is called and microphone streaming begins.
### 自动模式状态流转图
3. **Listening -> Speaking**
- Server sends `{"type":"tts","state":"start"}`; the device stops sending mic audio and plays incoming TTS.
4. **Speaking -> Idle**
- Server sends `{"type":"tts","state":"stop"}`. When auto-continue is enabled the device transitions back to Listening; otherwise it returns to Idle.
5. **Listening / Speaking -> Idle** (abort)
- `SendAbortSpeaking(...)` or `CloseAudioChannel()` interrupts the session and closes the WebSocket.
### 6.3 Auto-mode state diagram
```mermaid
stateDiagram
direction TB
[*] --> kDeviceStateUnknown
kDeviceStateUnknown --> kDeviceStateStarting:初始化
kDeviceStateStarting --> kDeviceStateWifiConfiguring:配置WiFi
kDeviceStateStarting --> kDeviceStateActivating:激活设备
kDeviceStateActivating --> kDeviceStateUpgrading:检测到新版本
kDeviceStateActivating --> kDeviceStateIdle:激活完成
kDeviceStateIdle --> kDeviceStateConnecting:开始连接
kDeviceStateConnecting --> kDeviceStateIdle:连接失败
kDeviceStateConnecting --> kDeviceStateListening:连接成功
kDeviceStateListening --> kDeviceStateSpeaking:开始说话
kDeviceStateSpeaking --> kDeviceStateListening:结束说话
kDeviceStateListening --> kDeviceStateIdle:手动终止
kDeviceStateSpeaking --> kDeviceStateIdle:自动终止
kDeviceStateUnknown --> kDeviceStateStarting: Initialize
kDeviceStateStarting --> kDeviceStateWifiConfiguring: Configure WiFi
kDeviceStateStarting --> kDeviceStateActivating: Activate device
kDeviceStateActivating --> kDeviceStateUpgrading: New firmware detected
kDeviceStateActivating --> kDeviceStateIdle: Activation complete
kDeviceStateIdle --> kDeviceStateConnecting: Start connecting
kDeviceStateConnecting --> kDeviceStateIdle: Connection failed
kDeviceStateConnecting --> kDeviceStateListening: Connection succeeded
kDeviceStateListening --> kDeviceStateSpeaking: TTS start
kDeviceStateSpeaking --> kDeviceStateListening: TTS stop
kDeviceStateListening --> kDeviceStateIdle: Manual abort
kDeviceStateSpeaking --> kDeviceStateIdle: Auto stop
kDeviceStateStarting --> kDeviceStateAudioTesting: Factory audio test
kDeviceStateStarting --> kDeviceStateFatalError: Fatal error
```
### 手动模式状态流转图
### 6.4 Manual-mode state diagram
```mermaid
stateDiagram
direction TB
[*] --> kDeviceStateUnknown
kDeviceStateUnknown --> kDeviceStateStarting:初始化
kDeviceStateStarting --> kDeviceStateWifiConfiguring:配置WiFi
kDeviceStateStarting --> kDeviceStateActivating:激活设备
kDeviceStateActivating --> kDeviceStateUpgrading:检测到新版本
kDeviceStateActivating --> kDeviceStateIdle:激活完成
kDeviceStateIdle --> kDeviceStateConnecting:开始连接
kDeviceStateConnecting --> kDeviceStateIdle:连接失败
kDeviceStateConnecting --> kDeviceStateListening:连接成功
kDeviceStateIdle --> kDeviceStateListening:开始监听
kDeviceStateListening --> kDeviceStateIdle:停止监听
kDeviceStateIdle --> kDeviceStateSpeaking:开始说话
kDeviceStateSpeaking --> kDeviceStateIdle:结束说话
kDeviceStateUnknown --> kDeviceStateStarting: Initialize
kDeviceStateStarting --> kDeviceStateWifiConfiguring: Configure WiFi
kDeviceStateStarting --> kDeviceStateActivating: Activate device
kDeviceStateActivating --> kDeviceStateUpgrading: New firmware detected
kDeviceStateActivating --> kDeviceStateIdle: Activation complete
kDeviceStateIdle --> kDeviceStateConnecting: Start connecting
kDeviceStateConnecting --> kDeviceStateIdle: Connection failed
kDeviceStateConnecting --> kDeviceStateListening: Connection succeeded
kDeviceStateIdle --> kDeviceStateListening: Start listening
kDeviceStateListening --> kDeviceStateIdle: Stop listening
kDeviceStateIdle --> kDeviceStateSpeaking: Start speaking
kDeviceStateSpeaking --> kDeviceStateIdle: Stop speaking
```
---
## 7. 错误处理
## 7. Error Handling
1. **连接失败**
- 如果 `Connect(url)` 返回失败或在等待服务器 "hello" 消息时超时,触发 `on_network_error_()` 回调。设备会提示"无法连接到服务"或类似错误信息。
1. **Connection failure**
- If `Connect(url)` fails or the server hello is not received before the timeout, `on_network_error_()` is invoked and the device shows a "cannot connect" alert.
2. **服务器断开**
- 如果 WebSocket 异常断开,回调 `OnDisconnected()`
- 设备回调 `on_audio_channel_closed_()`
- 切换到 Idle 或其他重试逻辑。
2. **Server disconnect**
- If the WebSocket drops unexpectedly, `OnDisconnected()` is called:
- `on_audio_channel_closed_()` runs.
- The device returns to Idle (or retries, depending on policy).
---
## 8. 其它注意事项
## 8. Other Notes
1. **鉴权**
- 设备通过设置 `Authorization: Bearer <token>` 提供鉴权,服务器端需验证是否有效。
- 如果令牌过期或无效,服务器可拒绝握手或在后续断开。
1. **Authentication**
- The device supplies `Authorization: Bearer <token>`; the server must validate it.
- If the token is missing or invalid the server may reject the handshake or terminate the session later.
2. **会话控制**
- 代码中部分消息包含 `session_id`,用于区分独立的对话或操作。服务端可根据需要对不同会话做分离处理。
2. **Session scope**
- Many messages carry a `session_id`, useful when the server serves multiple concurrent interactions.
3. **音频负载**
- 代码里默认使用 Opus 格式,并设置 `sample_rate = 16000`,单声道。帧时长由 `OPUS_FRAME_DURATION_MS` 控制,一般为 60ms。可根据带宽或性能做适当调整。为了获得更好的音乐播放效果,服务器下行音频可能使用 24000 采样率。
3. **Audio payload**
- Default audio format is Opus at 16 kHz, mono. The frame duration is controlled by `OPUS_FRAME_DURATION_MS` (typically 60 ms). The server may use 24 kHz on the downlink for better music playback.
4. **协议版本配置**
- 通过设置中的 `version` 字段配置二进制协议版本1、2 或 3
- 版本1直接发送 Opus 数据
- 版本2使用带时间戳的二进制协议适用于服务器端 AEC
- 版本3使用简化的二进制协议
4. **Binary protocol version selection**
- Configured through the `version` setting:
- v1: raw Opus
- v2: metadata + timestamp (useful for server-side AEC)
- v3: lightweight header
- The value is echoed back in the `Protocol-Version` header and the hello message.
5. **物联网控制推荐 MCP 协议**
- 设备与服务器之间的物联网能力发现、状态同步、控制指令等,建议全部通过 MCP 协议(type: "mcp")实现。原有的 type: "iot" 方案已废弃。
- MCP 协议可在 WebSocket、MQTT 等多种底层协议上传输,具备更好的扩展性和标准化能力。
- 详细用法请参考 [MCP 协议文档](./mcp-protocol.md) [MCP 物联网控制用法](./mcp-usage.md)
5. **IoT control via MCP**
- All IoT capability discovery and control flows through MCP (`type: "mcp"`). The legacy `type: "iot"` protocol is deprecated.
- MCP works over both WebSocket and MQTT, giving better standardization and extensibility.
- See [MCP protocol document](./mcp-protocol.md) and [MCP IoT control usage](./mcp-usage.md) for details.
6. **错误或异常 JSON**
- 当 JSON 中缺少必要字段,例如 `{"type": ...}`,设备端会记录错误日志(`ESP_LOGE(TAG, "Missing message type, data: %s", data);`),不会执行任何业务。
6. **Malformed JSON**
- When a required field such as `type` is missing, the device logs `ESP_LOGE(TAG, "Missing message type, data: %s", data);` and ignores the message.
---
## 9. 消息示例
## 9. Example Message Flow
下面给出一个典型的双向消息示例(流程简化示意):
A simplified two-way exchange:
1. **设备端 → 服务器**(握手)
1. **Device -> Server** (handshake)
```json
{
"type": "hello",
"version": 1,
"features": {
"mcp": true
"mcp": true,
"aec": true
},
"transport": "websocket",
"audio_params": {
@ -428,7 +463,7 @@ stateDiagram
}
```
2. **服务器 → 设备端**(握手应答)
2. **Server -> Device** (handshake ack)
```json
{
"type": "hello",
@ -441,7 +476,7 @@ stateDiagram
}
```
3. **设备端 → 服务器**(开始监听)
3. **Device -> Server** (start listening)
```json
{
"session_id": "xxx",
@ -450,18 +485,18 @@ stateDiagram
"mode": "auto"
}
```
同时设备端开始发送二进制帧Opus 数据)。
The device begins streaming binary Opus frames.
4. **服务器 → 设备端**ASR 结果)
4. **Server -> Device** (ASR result)
```json
{
"session_id": "xxx",
"type": "stt",
"text": "用户说的话"
"text": "what the user said"
}
```
5. **服务器 → 设备端**TTS开始
5. **Server -> Device** (TTS start)
```json
{
"session_id": "xxx",
@ -469,9 +504,9 @@ stateDiagram
"state": "start"
}
```
接着服务器发送二进制音频帧给设备端播放。
The server follows up with binary Opus frames for the device to play.
6. **服务器 → 设备端**TTS结束
6. **Server -> Device** (TTS stop)
```json
{
"session_id": "xxx",
@ -479,17 +514,17 @@ stateDiagram
"state": "stop"
}
```
设备端停止播放音频,若无更多指令,则回到空闲状态。
The device stops playback and, if no further instructions arrive, returns to idle.
---
## 10. 总结
## 10. Summary
本协议通过在 WebSocket 上层传输 JSON 文本与二进制音频帧完成功能包括音频流上传、TTS 音频播放、语音识别与状态管理、MCP 指令下发等。其核心特征:
This protocol carries JSON text and binary Opus frames over a WebSocket connection to implement audio streaming, TTS playback, speech recognition, device state management, MCP dispatch, and more. Key traits:
- **握手阶段**:发送 `"type":"hello"`,等待服务器返回。
- **音频通道**:采用 Opus 编码的二进制帧双向传输语音流,支持多种协议版本。
- **JSON 消息**:使用 `"type"` 为核心字段标识不同业务逻辑,包括 TTSSTTMCPWakeWordSystem、Custom 等。
- **扩展性**:可根据实际需求在 JSON 消息中添加字段,或在 headers 里进行额外鉴权。
- **Handshake**: send `"type":"hello"` and wait for the server reply.
- **Audio channel**: bidirectional Opus streaming, with three binary framing variants.
- **JSON messages**: dispatched by `"type"` (TTS, STT, MCP, WakeWord, System, Alert, Custom, ...).
- **Extensibility**: extra fields in JSON, additional headers for authentication.
服务器与设备端需提前约定各类消息的字段含义、时序逻辑以及错误处理规则,方能保证通信顺畅。上述信息可作为基础文档,便于后续对接、开发或扩展。
Server and device must agree on the meaning, timing, and error handling of each message type so the session runs smoothly. The text above provides the baseline for integration, debugging, and extension.

495
docs/websocket_zh.md Normal file
View File

@ -0,0 +1,495 @@
以下是一份基于代码实现整理的 WebSocket 通信协议文档,概述设备端与服务器之间如何通过 WebSocket 进行交互。
该文档仅基于所提供的代码推断,实际部署时可能需要结合服务器端实现进行进一步确认或补充。
---
## 1. 总体流程概览
1. **设备端初始化**
- 设备上电、初始化 `Application`
- 初始化音频编解码器、显示屏、LED 等
- 连接网络
- 创建并初始化实现 `Protocol` 接口的 WebSocket 协议实例(`WebsocketProtocol`
- 进入主循环等待事件(音频输入、音频输出、调度任务等)。
2. **建立 WebSocket 连接**
- 当设备需要开始语音会话时(例如用户唤醒、手动按键触发等),调用 `OpenAudioChannel()`
- 根据配置获取 WebSocket URL
- 设置若干请求头(`Authorization`, `Protocol-Version`, `Device-Id`, `Client-Id`
- 调用 `Connect()` 与服务器建立 WebSocket 连接
3. **设备端发送 "hello" 消息**
- 连接成功后,设备会发送一条 JSON 消息,示例结构如下:
```json
{
"type": "hello",
"version": 1,
"features": {
"mcp": true
},
"transport": "websocket",
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60
}
}
```
- 其中 `features` 字段为可选,内容根据设备编译配置自动生成。例如:`"mcp": true` 表示支持 MCP 协议。
- `frame_duration` 的值对应 `OPUS_FRAME_DURATION_MS`(例如 60ms
4. **服务器回复 "hello"**
- 设备等待服务器返回一条包含 `"type": "hello"` 的 JSON 消息,并检查 `"transport": "websocket"` 是否匹配。
- 服务器可选下发 `session_id` 字段,设备端收到后会自动记录。
- 示例:
```json
{
"type": "hello",
"transport": "websocket",
"session_id": "xxx",
"audio_params": {
"format": "opus",
"sample_rate": 24000,
"channels": 1,
"frame_duration": 60
}
}
```
- 如果匹配,则认为服务器已就绪,标记音频通道打开成功。
- 如果在超时时间(默认 10 秒)内未收到正确回复,认为连接失败并触发网络错误回调。
5. **后续消息交互**
- 设备端和服务器端之间可发送两种主要类型的数据:
1. **二进制音频数据**Opus 编码)
2. **文本 JSON 消息**用于传输聊天状态、TTS/STT 事件、MCP 协议消息等)
- 在代码里,接收回调主要分为:
- `OnData(...)`:
- 当 `binary` 为 `true` 时,认为是音频帧;设备会将其当作 Opus 数据进行解码。
- 当 `binary` 为 `false` 时,认为是 JSON 文本,需要在设备端用 cJSON 进行解析并做相应业务逻辑处理如聊天、TTS、MCP 协议消息等)。
- 当服务器或网络出现断连,回调 `OnDisconnected()` 被触发:
- 设备会调用 `on_audio_channel_closed_()`,并最终回到空闲状态。
6. **关闭 WebSocket 连接**
- 设备在需要结束语音会话时,会调用 `CloseAudioChannel()` 主动断开连接,并回到空闲状态。
- 或者如果服务器端主动断开,也会引发同样的回调流程。
---
## 2. 通用请求头
在建立 WebSocket 连接时,代码示例中设置了以下请求头:
- `Authorization`: 用于存放访问令牌,形如 `"Bearer <token>"`
- `Protocol-Version`: 协议版本号,与 hello 消息体内的 `version` 字段保持一致
- `Device-Id`: 设备物理网卡 MAC 地址
- `Client-Id`: 软件生成的 UUID擦除 NVS 或重新烧录完整固件会重置)
这些头会随着 WebSocket 握手一起发送到服务器,服务器可根据需求进行校验、认证等。
---
## 3. 二进制协议版本
设备支持多种二进制协议版本,通过配置中的 `version` 字段指定:
### 3.1 版本1默认
直接发送 Opus 音频数据无额外元数据。Websocket 协议会区分 text 与 binary。
### 3.2 版本2
使用 `BinaryProtocol2` 结构:
```c
struct BinaryProtocol2 {
uint16_t version; // 协议版本
uint16_t type; // 消息类型 (0: OPUS, 1: JSON)
uint32_t reserved; // 保留字段
uint32_t timestamp; // 时间戳毫秒用于服务器端AEC
uint32_t payload_size; // 负载大小(字节)
uint8_t payload[]; // 负载数据
} __attribute__((packed));
```
### 3.3 版本3
使用 `BinaryProtocol3` 结构:
```c
struct BinaryProtocol3 {
uint8_t type; // 消息类型
uint8_t reserved; // 保留字段
uint16_t payload_size; // 负载大小
uint8_t payload[]; // 负载数据
} __attribute__((packed));
```
---
## 4. JSON 消息结构
WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及其对应业务逻辑。若消息里包含未列出的字段,可能为可选或特定实现细节。
### 4.1 设备端→服务器
1. **Hello**
- 连接成功后,由设备端发送,告知服务器基本参数。
- 例:
```json
{
"type": "hello",
"version": 1,
"features": {
"mcp": true
},
"transport": "websocket",
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60
}
}
```
2. **Listen**
- 表示设备端开始或停止录音监听。
- 常见字段:
- `"session_id"`:会话标识
- `"type": "listen"`
- `"state"``"start"`, `"stop"`, `"detect"`(唤醒检测已触发)
- `"mode"``"auto"`, `"manual"` 或 `"realtime"`,表示识别模式。
- 例:开始监听
```json
{
"session_id": "xxx",
"type": "listen",
"state": "start",
"mode": "manual"
}
```
3. **Abort**
- 终止当前说话TTS 播放)或语音通道。
- 例:
```json
{
"session_id": "xxx",
"type": "abort",
"reason": "wake_word_detected"
}
```
- `reason` 值可为 `"wake_word_detected"` 或其他。
4. **Wake Word Detected**
- 用于设备端向服务器告知检测到唤醒词。
- 在发送该消息之前,可提前发送唤醒词的 Opus 音频数据,用于服务器进行声纹检测。
- 例:
```json
{
"session_id": "xxx",
"type": "listen",
"state": "detect",
"text": "你好小明"
}
```
5. **MCP**
- 推荐用于物联网控制的新一代协议。所有设备能力发现、工具调用等均通过 type: "mcp" 的消息进行payload 内部为标准 JSON-RPC 2.0(详见 [MCP 协议文档](./mcp-protocol_zh.md))。
- **设备端到服务器发送 result 的例子:**
```json
{
"session_id": "xxx",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"id": 1,
"result": {
"content": [
{ "type": "text", "text": "true" }
],
"isError": false
}
}
}
```
---
### 4.2 服务器→设备端
1. **Hello**
- 服务器端返回的握手确认消息。
- 必须包含 `"type": "hello"` 和 `"transport": "websocket"`。
- 可能会带有 `audio_params`,表示服务器期望的音频参数,或与设备端对齐的配置。
- 服务器可选下发 `session_id` 字段,设备端收到后会自动记录。
- 成功接收后设备端会设置事件标志,表示 WebSocket 通道就绪。
2. **STT**
- `{"session_id": "xxx", "type": "stt", "text": "..."}`
- 表示服务器端识别到了用户语音。(例如语音转文本结果)
- 设备可能将此文本显示到屏幕上,后续再进入回答等流程。
3. **LLM**
- `{"session_id": "xxx", "type": "llm", "emotion": "happy", "text": "😀"}`
- 服务器指示设备调整表情动画 / UI 表达。
4. **TTS**
- `{"session_id": "xxx", "type": "tts", "state": "start"}`:服务器准备下发 TTS 音频,设备端进入 "speaking" 播放状态。
- `{"session_id": "xxx", "type": "tts", "state": "stop"}`:表示本次 TTS 结束。
- `{"session_id": "xxx", "type": "tts", "state": "sentence_start", "text": "..."}`
- 让设备在界面上显示当前要播放或朗读的文本片段(例如用于显示给用户)。
5. **MCP**
- 服务器通过 type: "mcp" 的消息下发物联网相关的控制指令或返回调用结果payload 结构同上。
- **服务器到设备端发送 tools/call 的例子:**
```json
{
"session_id": "xxx",
"type": "mcp",
"payload": {
"jsonrpc": "2.0",
"method": "tools/call",
"params": {
"name": "self.light.set_rgb",
"arguments": { "r": 255, "g": 0, "b": 0 }
},
"id": 1
}
}
```
6. **System**
- 系统控制命令,常用于远程升级更新。
- 例:
```json
{
"session_id": "xxx",
"type": "system",
"command": "reboot"
}
```
- 支持的命令:
- `"reboot"`:重启设备
7. **Custom**(可选)
- 自定义消息,当 `CONFIG_RECEIVE_CUSTOM_MESSAGE` 启用时支持。
- 例:
```json
{
"session_id": "xxx",
"type": "custom",
"payload": {
"message": "自定义内容"
}
}
```
8. **音频数据:二进制帧**
- 当服务器发送音频二进制帧Opus 编码)时,设备端解码并播放。
- 若设备端正在处于 "listening" (录音)状态,收到的音频帧会被忽略或清空以防冲突。
---
## 5. 音频编解码
1. **设备端发送录音数据**
- 音频输入经过可能的回声消除、降噪或音量增益后,通过 Opus 编码打包为二进制帧发送给服务器。
- 根据协议版本,可能直接发送 Opus 数据版本1或使用带元数据的二进制协议版本2/3
2. **设备端播放收到的音频**
- 收到服务器的二进制帧时,同样认定是 Opus 数据。
- 设备端会进行解码,然后交由音频输出接口播放。
- 如果服务器的音频采样率与设备不一致,会在解码后再进行重采样。
---
## 6. 常见状态流转
以下为常见设备端关键状态流转,与 WebSocket 消息对应:
1. **Idle** → **Connecting**
- 用户触发或唤醒后,设备调用 `OpenAudioChannel()` → 建立 WebSocket 连接 → 发送 `"type":"hello"`。
2. **Connecting** → **Listening**
- 成功建立连接后,若继续执行 `SendStartListening(...)`,则进入录音状态。此时设备会持续编码麦克风数据并发送到服务器。
3. **Listening** → **Speaking**
- 收到服务器 TTS Start 消息 (`{"type":"tts","state":"start"}`) → 停止录音并播放接收到的音频。
4. **Speaking** → **Idle**
- 服务器 TTS Stop (`{"type":"tts","state":"stop"}`) → 音频播放结束。若未继续进入自动监听,则返回 Idle如果配置了自动循环则再度进入 Listening。
5. **Listening** / **Speaking** → **Idle**(遇到异常或主动中断)
- 调用 `SendAbortSpeaking(...)` 或 `CloseAudioChannel()` → 中断会话 → 关闭 WebSocket → 状态回到 Idle。
### 自动模式状态流转图
```mermaid
stateDiagram
direction TB
[*] --> kDeviceStateUnknown
kDeviceStateUnknown --> kDeviceStateStarting:初始化
kDeviceStateStarting --> kDeviceStateWifiConfiguring:配置WiFi
kDeviceStateStarting --> kDeviceStateActivating:激活设备
kDeviceStateActivating --> kDeviceStateUpgrading:检测到新版本
kDeviceStateActivating --> kDeviceStateIdle:激活完成
kDeviceStateIdle --> kDeviceStateConnecting:开始连接
kDeviceStateConnecting --> kDeviceStateIdle:连接失败
kDeviceStateConnecting --> kDeviceStateListening:连接成功
kDeviceStateListening --> kDeviceStateSpeaking:开始说话
kDeviceStateSpeaking --> kDeviceStateListening:结束说话
kDeviceStateListening --> kDeviceStateIdle:手动终止
kDeviceStateSpeaking --> kDeviceStateIdle:自动终止
```
### 手动模式状态流转图
```mermaid
stateDiagram
direction TB
[*] --> kDeviceStateUnknown
kDeviceStateUnknown --> kDeviceStateStarting:初始化
kDeviceStateStarting --> kDeviceStateWifiConfiguring:配置WiFi
kDeviceStateStarting --> kDeviceStateActivating:激活设备
kDeviceStateActivating --> kDeviceStateUpgrading:检测到新版本
kDeviceStateActivating --> kDeviceStateIdle:激活完成
kDeviceStateIdle --> kDeviceStateConnecting:开始连接
kDeviceStateConnecting --> kDeviceStateIdle:连接失败
kDeviceStateConnecting --> kDeviceStateListening:连接成功
kDeviceStateIdle --> kDeviceStateListening:开始监听
kDeviceStateListening --> kDeviceStateIdle:停止监听
kDeviceStateIdle --> kDeviceStateSpeaking:开始说话
kDeviceStateSpeaking --> kDeviceStateIdle:结束说话
```
---
## 7. 错误处理
1. **连接失败**
- 如果 `Connect(url)` 返回失败或在等待服务器 "hello" 消息时超时,触发 `on_network_error_()` 回调。设备会提示"无法连接到服务"或类似错误信息。
2. **服务器断开**
- 如果 WebSocket 异常断开,回调 `OnDisconnected()`
- 设备回调 `on_audio_channel_closed_()`
- 切换到 Idle 或其他重试逻辑。
---
## 8. 其它注意事项
1. **鉴权**
- 设备通过设置 `Authorization: Bearer <token>` 提供鉴权,服务器端需验证是否有效。
- 如果令牌过期或无效,服务器可拒绝握手或在后续断开。
2. **会话控制**
- 代码中部分消息包含 `session_id`,用于区分独立的对话或操作。服务端可根据需要对不同会话做分离处理。
3. **音频负载**
- 代码里默认使用 Opus 格式,并设置 `sample_rate = 16000`,单声道。帧时长由 `OPUS_FRAME_DURATION_MS` 控制,一般为 60ms。可根据带宽或性能做适当调整。为了获得更好的音乐播放效果服务器下行音频可能使用 24000 采样率。
4. **协议版本配置**
- 通过设置中的 `version` 字段配置二进制协议版本1、2 或 3
- 版本1直接发送 Opus 数据
- 版本2使用带时间戳的二进制协议适用于服务器端 AEC
- 版本3使用简化的二进制协议
5. **物联网控制推荐 MCP 协议**
- 设备与服务器之间的物联网能力发现、状态同步、控制指令等,建议全部通过 MCP 协议type: "mcp")实现。原有的 type: "iot" 方案已废弃。
- MCP 协议可在 WebSocket、MQTT 等多种底层协议上传输,具备更好的扩展性和标准化能力。
- 详细用法请参考 [MCP 协议文档](./mcp-protocol_zh.md) 及 [MCP 物联网控制用法](./mcp-usage_zh.md)。
6. **错误或异常 JSON**
- 当 JSON 中缺少必要字段,例如 `{"type": ...}`,设备端会记录错误日志(`ESP_LOGE(TAG, "Missing message type, data: %s", data);`),不会执行任何业务。
---
## 9. 消息示例
下面给出一个典型的双向消息示例(流程简化示意):
1. **设备端 → 服务器**(握手)
```json
{
"type": "hello",
"version": 1,
"features": {
"mcp": true
},
"transport": "websocket",
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60
}
}
```
2. **服务器 → 设备端**(握手应答)
```json
{
"type": "hello",
"transport": "websocket",
"session_id": "xxx",
"audio_params": {
"format": "opus",
"sample_rate": 16000
}
}
```
3. **设备端 → 服务器**(开始监听)
```json
{
"session_id": "xxx",
"type": "listen",
"state": "start",
"mode": "auto"
}
```
同时设备端开始发送二进制帧Opus 数据)。
4. **服务器 → 设备端**ASR 结果)
```json
{
"session_id": "xxx",
"type": "stt",
"text": "用户说的话"
}
```
5. **服务器 → 设备端**TTS开始
```json
{
"session_id": "xxx",
"type": "tts",
"state": "start"
}
```
接着服务器发送二进制音频帧给设备端播放。
6. **服务器 → 设备端**TTS结束
```json
{
"session_id": "xxx",
"type": "tts",
"state": "stop"
}
```
设备端停止播放音频,若无更多指令,则回到空闲状态。
---
## 10. 总结
本协议通过在 WebSocket 上层传输 JSON 文本与二进制音频帧完成功能包括音频流上传、TTS 音频播放、语音识别与状态管理、MCP 指令下发等。其核心特征:
- **握手阶段**:发送 `"type":"hello"`,等待服务器返回。
- **音频通道**:采用 Opus 编码的二进制帧双向传输语音流,支持多种协议版本。
- **JSON 消息**:使用 `"type"` 为核心字段标识不同业务逻辑,包括 TTS、STT、MCP、WakeWord、System、Custom 等。
- **扩展性**:可根据实际需求在 JSON 消息中添加字段,或在 headers 里进行额外鉴权。
服务器与设备端需提前约定各类消息的字段含义、时序逻辑以及错误处理规则,方能保证通信顺畅。上述信息可作为基础文档,便于后续对接、开发或扩展。

View File

@ -1,6 +1,7 @@
# Define source files
set(SOURCES "audio/audio_codec.cc"
"audio/audio_service.cc"
"audio/demuxer/ogg_demuxer.cc"
"audio/codecs/no_audio_codec.cc"
"audio/codecs/box_audio_codec.cc"
"audio/codecs/es8311_audio_codec.cc"
@ -38,7 +39,7 @@ set(SOURCES "audio/audio_codec.cc"
"main.cc"
)
set(INCLUDE_DIRS "." "display" "display/lvgl_display" "display/lvgl_display/jpg" "audio" "protocols")
set(INCLUDE_DIRS "." "display" "display/lvgl_display" "display/lvgl_display/jpg" "audio" "audio/demuxer" "protocols")
# Add board common files
list(APPEND SOURCES
@ -104,28 +105,28 @@ elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_ESP32_LCD)
set(BUILTIN_ICON_FONT font_awesome_14_1)
elseif(CONFIG_BOARD_TYPE_DF_K10)
set(BOARD_TYPE "df-k10")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_DF_S3_AI_CAM)
set(BOARD_TYPE "df-s3-ai-cam")
elseif(CONFIG_BOARD_TYPE_ESP_BOX_3)
set(BOARD_TYPE "esp-box-3")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
set(EMOTE_RESOLUTION "320_240")
elseif(CONFIG_BOARD_TYPE_ESP_BOX)
set(BOARD_TYPE "esp-box")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
set(EMOTE_RESOLUTION "320_240")
elseif(CONFIG_BOARD_TYPE_ESP_BOX_LITE)
set(BOARD_TYPE "esp-box-lite")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_KEVIN_BOX_2)
set(BOARD_TYPE "kevin-box-2")
set(BUILTIN_TEXT_FONT font_puhui_basic_14_1)
@ -134,14 +135,14 @@ elseif(CONFIG_BOARD_TYPE_KEVIN_C3)
set(BOARD_TYPE "kevin-c3")
elseif(CONFIG_BOARD_TYPE_KEVIN_SP_V3_DEV)
set(BOARD_TYPE "kevin-sp-v3-dev")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_KEVIN_SP_V4_DEV)
set(BOARD_TYPE "kevin-sp-v4-dev")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_KEVIN_YUYING_313LCD)
set(BOARD_TYPE "kevin-yuying-313lcd")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
@ -149,24 +150,42 @@ elseif(CONFIG_BOARD_TYPE_KEVIN_YUYING_313LCD)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_LICHUANG_DEV_S3)
set(BOARD_TYPE "lichuang-dev")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_LICHUANG_DEV_C3)
set(BOARD_TYPE "lichuang-c3-dev")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_RYMCU_BIGSMART)
set(MANUFACTURER "rymcu")
set(BOARD_TYPE "bigsmart")
set(BOARD_NAME "rymcu-bigsmart")
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_EDA_TV_PRO)
set(MANUFACTURER "lceda-course-examples")
set(BOARD_TYPE "eda-tv-pro")
elseif(CONFIG_BOARD_TYPE_EDA_ROBOT_PRO)
set(MANUFACTURER "lceda-course-examples")
set(BOARD_TYPE "eda-robot-pro")
set(BUILTIN_TEXT_FONT font_puhui_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
elseif(CONFIG_BOARD_TYPE_EDA_SUPER_BEAR)
set(MANUFACTURER "lceda-course-examples")
set(BOARD_TYPE "eda-super-bear")
elseif(CONFIG_BOARD_TYPE_MAGICLICK_S3_2P4)
set(BOARD_TYPE "magiclick-2p4")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_TEXT_FONT font_noto_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_64)
elseif(CONFIG_BOARD_TYPE_MAGICLICK_S3_2P5)
set(BOARD_TYPE "magiclick-2p5")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_TEXT_FONT font_noto_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_64)
elseif(CONFIG_BOARD_TYPE_MAGICLICK_C3)
set(BOARD_TYPE "magiclick-c3")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
@ -197,10 +216,20 @@ elseif(CONFIG_BOARD_TYPE_M5STACK_ATOM_S3R_ECHO_BASE)
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_M5STACK_ATOM_S3R_ECHO_PYRAMID)
set(BOARD_TYPE "atoms3r-echo-pyramid")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_M5STACK_ATOM_S3R_CAM_M12_ECHO_BASE)
set(BOARD_TYPE "atoms3r-cam-m12-echo-base")
elseif(CONFIG_BOARD_TYPE_M5STACK_ATOM_ECHOS3R)
set(BOARD_TYPE "atom-echos3r")
elseif(CONFIG_BOARD_TYPE_M5STACK_CARDPUTER_ADV)
set(BOARD_TYPE "m5stack-cardputer-adv")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_M5STACK_ATOM_MATRIX_ECHO_BASE)
set(BOARD_TYPE "atommatrix-echo-base")
elseif(CONFIG_BOARD_TYPE_XMINI_C3_V3)
@ -238,143 +267,261 @@ elseif(CONFIG_BOARD_TYPE_ESP_HI)
set(BOARD_TYPE "esp-hi")
# Set ESP_HI emoji directory for DEFAULT_ASSETS_EXTRA_FILES
set(DEFAULT_ASSETS_EXTRA_FILES "${CMAKE_BINARY_DIR}/emoji")
elseif(CONFIG_BOARD_TYPE_ECHOEAR)
set(BOARD_TYPE "echoear")
elseif(CONFIG_BOARD_TYPE_ESP_VOCAT)
set(BOARD_TYPE "esp-vocat")
set(BUILTIN_TEXT_FONT font_puhui_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(EMOTE_RESOLUTION "360_360")
# set(EMOTE_EXTERNAL_PATH "${CMAKE_CURRENT_SOURCE_DIR}/boards/echoear/assets")
# set(EMOTE_EXTERNAL_PATH "${CMAKE_CURRENT_SOURCE_DIR}/boards/esp-vocat/assets")
elseif(CONFIG_BOARD_TYPE_ESP_SENSAIRSHUTTLE)
set(BOARD_TYPE "esp-sensairshuttle")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_AUDIO_BOARD)
set(BOARD_TYPE "waveshare-s3-audio-board")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_TOUCH_LCD_3_5)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-touch-lcd-3.5")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_CAM_XXXX)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-cam")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_AUDIO_BOARD)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-audio-board")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_8)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_8)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-amoled-1.8")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_1_8)
set(BOARD_TYPE "waveshare-c6-touch-amoled-1.8")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_1_8)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-c6-touch-amoled-1.8")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_2_06)
set(BOARD_TYPE "waveshare-s3-touch-amoled-2.06")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_2_06)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-amoled-2.06")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_2_06)
set(BOARD_TYPE "waveshare-c6-touch-amoled-2.06")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_2_06)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-c6-touch-amoled-2.06")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_4B)
set(BOARD_TYPE "waveshare-s3-touch-lcd-4b")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_2_16)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-c6-touch-amoled-2.16")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_4_3C)
set(BOARD_TYPE "waveshare-s3-touch-lcd-4.3c")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_2_16)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-amoled-2.16")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_75)
set(BOARD_TYPE "waveshare-s3-touch-amoled-1.75")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_43C)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-amoled-1.43c")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4B)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-lcd-4b")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_83)
set(BOARD_TYPE "waveshare-s3-touch-lcd-1.83")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4_3C)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-lcd-4.3c")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_75)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-amoled-1.75")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_75C)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-amoled-1.75")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_83)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-lcd-1.83")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_85C)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_85C)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-lcd-1.85c")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_85)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_85)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-lcd-1.85")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_46)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_46)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-lcd-1.46")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_5)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_5)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-lcd-3.5")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_5B)
set(BOARD_TYPE "waveshare-s3-touch-lcd-3.5b")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_5B)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-lcd-3.5b")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_ePaper_1_54)
set(BOARD_TYPE "waveshare-s3-epaper-1.54")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_ePaper_1_54_v1)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-epaper-1.54")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_ePaper_1_54_v2)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-epaper-1.54")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_RLCD_4_2)
set(BOARD_TYPE "waveshare-s3-rlcd-4.2")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_ePaper_3_97)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-epaper-3.97")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_49)
set(BOARD_TYPE "waveshare-s3-touch-lcd-3.49")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_RLCD_4_2)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-rlcd-4.2")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_49)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-lcd-3.49")
set(LVGL_TEXT_FONT font_puhui_basic_30_4)
set(LVGL_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_C6_LCD_1_69)
set(BOARD_TYPE "waveshare-c6-lcd-1.69")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_54)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-lcd-1.54")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_C6_TOUCH_LCD_1_83)
set(BOARD_TYPE "waveshare-c6-touch-lcd-1.83")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_LCD_0_85)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-lcd-0.85")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_LCD_1_69)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-c6-lcd-1.69")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_LCD_1_83)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-c6-touch-lcd-1.83")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_1_43)
set(BOARD_TYPE "waveshare-c6-touch-amoled-1.43")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_1_43)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-c6-touch-amoled-1.43")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_1_32)
set(BOARD_TYPE "waveshare-c6-touch-amoled-1.32")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_1_32)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-c6-touch-amoled-1.32")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_32)
set(BOARD_TYPE "waveshare-s3-touch-amoled-1.32")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_32)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-s3-touch-amoled-1.32")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_P4_NANO)
set(BOARD_TYPE "waveshare-p4-nano")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_NANO)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-nano")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_4B)
set(BOARD_TYPE "waveshare-p4-wifi6-touch-lcd-4b")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4B)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_7B)
set(BOARD_TYPE "waveshare-p4-wifi6-touch-lcd-7b")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4_3)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_XC)
set(BOARD_TYPE "waveshare-p4-wifi6-touch-lcd-xc")
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7B)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_3_4C)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_3_5)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd-3.5")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4C)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_8)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_10_1)
set(MANUFACTURER "waveshare")
set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_30_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
@ -436,29 +583,29 @@ elseif(CONFIG_BOARD_TYPE_MOVECALL_CUICAN_ESP32S3)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3)
set(BOARD_TYPE "atk-dnesp32s3")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3_BOX)
set(BOARD_TYPE "atk-dnesp32s3-box")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3_BOX0)
set(BOARD_TYPE "atk-dnesp32s3-box0")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3_BOX2_WIFI)
set(BOARD_TYPE "atk-dnesp32s3-box2-wifi")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3_BOX2_4G)
set(BOARD_TYPE "atk-dnesp32s3-box2-4g")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3M_WIFI)
set(BOARD_TYPE "atk-dnesp32s3m-wifi")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
@ -469,6 +616,11 @@ elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3M_4G)
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_32)
elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3_BOX3)
set(BOARD_TYPE "atk-dnesp32s3-box3")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_DU_CHATX)
set(BOARD_TYPE "du-chatx")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
@ -499,24 +651,29 @@ elseif(CONFIG_BOARD_TYPE_XINGZHI_CUBE_0_96OLED_ML307)
set(BUILTIN_ICON_FONT font_awesome_14_1)
elseif(CONFIG_BOARD_TYPE_XINGZHI_CUBE_1_54TFT_WIFI)
set(BOARD_TYPE "xingzhi-cube-1.54tft-wifi")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_XINGZHI_CUBE_1_54TFT_ML307)
set(BOARD_TYPE "xingzhi-cube-1.54tft-ml307")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_XINGZHI_METAL_1_54_WIFI)
set(BOARD_TYPE "xingzhi-metal-1.54-wifi")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_XINGZHI_ABS_2_0)
set(BOARD_TYPE "xingzhi-abs-2.0")
set(BUILTIN_TEXT_FONT font_noto_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_SEEED_STUDIO_SENSECAP_WATCHER)
set(BOARD_TYPE "sensecap-watcher")
set(BUILTIN_TEXT_FONT font_puhui_basic_30_4)
set(BUILTIN_TEXT_FONT font_noto_basic_30_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION noto-emoji_128)
elseif(CONFIG_BOARD_TYPE_DOIT_S3_AIBOX)
set(BOARD_TYPE "doit-s3-aibox")
elseif(CONFIG_BOARD_TYPE_MIXGO_NOVA)
@ -571,7 +728,7 @@ elseif(CONFIG_BOARD_TYPE_ZHENGCHEN_CAM_ML307)
set(BOARD_TYPE "zhengchen-cam-ml307")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_SPOTPEAR_ESP32_S3_1_54_MUMA)
set(BOARD_TYPE "sp-esp32-s3-1.54-muma")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
@ -586,10 +743,12 @@ elseif(CONFIG_BOARD_TYPE_OTTO_ROBOT)
set(BOARD_TYPE "otto-robot")
set(BUILTIN_TEXT_FONT font_puhui_16_4)
set(BUILTIN_ICON_FONT font_awesome_16_4)
set(DEFAULT_EMOJI_COLLECTION otto-gif)
elseif(CONFIG_BOARD_TYPE_ELECTRON_BOT)
set(BOARD_TYPE "electron-bot")
set(BUILTIN_TEXT_FONT font_puhui_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION otto-gif)
elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI_CAM)
set(BOARD_TYPE "bread-compact-wifi-s3cam")
set(BUILTIN_TEXT_FONT font_puhui_basic_16_4)
@ -634,12 +793,29 @@ elseif(CONFIG_BOARD_TYPE_HU_087)
set(BOARD_TYPE "hu-087")
set(BUILTIN_TEXT_FONT font_puhui_basic_14_1)
set(BUILTIN_ICON_FONT font_awesome_14_1)
elseif(CONFIG_BOARD_TYPE_Freenove_ESP32S3_DISPLAY_2_8_LCD)
set(BOARD_TYPE "freenove-esp32s3-display-2.8-lcd")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
elseif(CONFIG_BOARD_TYPE_AI_VOX3)
set(BOARD_TYPE "nulllab-ai-vox-v3")
set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
set(BUILTIN_ICON_FONT font_awesome_20_4)
set(DEFAULT_EMOJI_COLLECTION twemoji_64)
endif()
file(GLOB BOARD_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_TYPE}/*.cc
${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_TYPE}/*.c
)
if(MANUFACTURER)
file(GLOB BOARD_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/boards/${MANUFACTURER}/${BOARD_TYPE}/*.cc
${CMAKE_CURRENT_SOURCE_DIR}/boards/${MANUFACTURER}/${BOARD_TYPE}/*.c
)
else()
file(GLOB BOARD_SOURCES
${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_TYPE}/*.cc
${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_TYPE}/*.c
)
endif()
list(APPEND SOURCES ${BOARD_SOURCES})
# Select audio processor according to Kconfig
@ -748,14 +924,14 @@ file(GLOB LANG_SOUNDS ${CMAKE_CURRENT_SOURCE_DIR}/assets/locales/${LANG_DIR}/*.o
# If not en-US, collect en-US audio files as fallback for missing files
if(NOT LANG_DIR STREQUAL "en-US")
file(GLOB EN_US_SOUNDS ${CMAKE_CURRENT_SOURCE_DIR}/assets/locales/en-US/*.ogg)
# Extract filenames (without path) from current language
set(EXISTING_NAMES "")
foreach(SOUND_FILE ${LANG_SOUNDS})
get_filename_component(FILENAME ${SOUND_FILE} NAME)
list(APPEND EXISTING_NAMES ${FILENAME})
endforeach()
# Only add en-US audio files that are missing in current language
foreach(EN_SOUND ${EN_US_SOUNDS})
get_filename_component(FILENAME ${EN_SOUND} NAME)
@ -777,6 +953,8 @@ if(CONFIG_IDF_TARGET_ESP32)
"display/lvgl_display/jpg/image_to_jpeg.cpp"
"display/lvgl_display/jpg/jpeg_to_image.c"
"boards/common/nt26_board.cc"
"boards/common/ml307_board.cc"
"boards/common/dual_network_board.cc"
)
endif()
@ -792,6 +970,14 @@ if(CONFIG_IDF_TARGET_ESP32S3)
list(APPEND SOURCES "boards/common/esp32_camera.cc")
endif()
set(MAIN_PRIV_REQUIRES_EXTRA "")
if(CONFIG_BOARD_TYPE_ESP_VOCAT)
list(APPEND MAIN_PRIV_REQUIRES_EXTRA
espressif__touch_slider_sensor
espressif__touch_button_sensor
)
endif()
idf_component_register(SRCS ${SOURCES}
EMBED_FILES ${LANG_SOUNDS} ${COMMON_SOUNDS}
INCLUDE_DIRS ${INCLUDE_DIRS}
@ -813,6 +999,8 @@ idf_component_register(SRCS ${SOURCES}
console
efuse
bt
fatfs
${MAIN_PRIV_REQUIRES_EXTRA}
)
# Use target_compile_definitions to define BOARD_TYPE, BOARD_NAME
@ -874,7 +1062,7 @@ list(APPEND FILES_TO_DOWNLOAD "panic_return.aaf" "wake.aaf")
foreach(FILENAME IN LISTS FILES_TO_DOWNLOAD)
set(REMOTE_FILE "${URL}/${FILENAME}")
set(LOCAL_FILE "${EMOJI_DIR}/${FILENAME}")
# Check if local file exists
if(EXISTS ${LOCAL_FILE})
message(STATUS "File ${FILENAME} already exists, skipping download")
@ -896,31 +1084,31 @@ endif()
function(build_default_assets_bin)
# Set output path for generated assets.bin
set(GENERATED_ASSETS_BIN "${CMAKE_BINARY_DIR}/generated_assets.bin")
# Prepare arguments for build script
set(BUILD_ARGS
"--sdkconfig" "${SDKCONFIG}"
"--output" "${GENERATED_ASSETS_BIN}"
)
# Add builtin text font if defined
if(BUILTIN_TEXT_FONT)
list(APPEND BUILD_ARGS "--builtin_text_font" "${BUILTIN_TEXT_FONT}")
endif()
# Add default emoji collection if defined
if(DEFAULT_EMOJI_COLLECTION)
list(APPEND BUILD_ARGS "--emoji_collection" "${DEFAULT_EMOJI_COLLECTION}")
endif()
# Add default assets extra files if defined
if(DEFAULT_ASSETS_EXTRA_FILES)
list(APPEND BUILD_ARGS "--extra_files" "${DEFAULT_ASSETS_EXTRA_FILES}")
endif()
list(APPEND BUILD_ARGS "--esp_sr_model_path" "${ESP_SR_MODEL_PATH}")
list(APPEND BUILD_ARGS "--xiaozhi_fonts_path" "${XIAOZHI_FONTS_PATH}")
# Create custom command to build assets
add_custom_command(
OUTPUT ${GENERATED_ASSETS_BIN}
@ -931,15 +1119,15 @@ function(build_default_assets_bin)
COMMENT "Building default assets.bin based on configuration"
VERBATIM
)
# Create target for generated assets
add_custom_target(generated_default_assets ALL
DEPENDS ${GENERATED_ASSETS_BIN}
)
# Set the generated file path in parent scope
set(GENERATED_ASSETS_LOCAL_FILE ${GENERATED_ASSETS_BIN} PARENT_SCOPE)
message(STATUS "Default assets build configured: ${GENERATED_ASSETS_BIN}")
endfunction()
@ -952,18 +1140,18 @@ function(get_assets_local_file assets_source assets_local_file_var)
get_filename_component(ASSETS_FILENAME "${assets_source}" NAME)
set(ASSETS_LOCAL_FILE "${CMAKE_BINARY_DIR}/${ASSETS_FILENAME}")
set(ASSETS_TEMP_FILE "${CMAKE_BINARY_DIR}/${ASSETS_FILENAME}.tmp")
# Check if local file exists
if(EXISTS ${ASSETS_LOCAL_FILE})
message(STATUS "Assets file ${ASSETS_FILENAME} already exists, skipping download")
else()
message(STATUS "Downloading ${ASSETS_FILENAME}")
# Clean up any existing temp file
if(EXISTS ${ASSETS_TEMP_FILE})
file(REMOVE ${ASSETS_TEMP_FILE})
endif()
# Download to temporary file first
file(DOWNLOAD ${assets_source} ${ASSETS_TEMP_FILE}
STATUS DOWNLOAD_STATUS)
@ -975,7 +1163,7 @@ function(get_assets_local_file assets_source assets_local_file_var)
endif()
message(FATAL_ERROR "Failed to download ${ASSETS_FILENAME} from ${assets_source}")
endif()
# Move temp file to final location (atomic operation)
file(RENAME ${ASSETS_TEMP_FILE} ${ASSETS_LOCAL_FILE})
message(STATUS "Successfully downloaded ${ASSETS_FILENAME}")
@ -987,15 +1175,15 @@ function(get_assets_local_file assets_source assets_local_file_var)
else()
set(ASSETS_LOCAL_FILE "${CMAKE_CURRENT_SOURCE_DIR}/${assets_source}")
endif()
# Check if local file exists
if(NOT EXISTS ${ASSETS_LOCAL_FILE})
message(FATAL_ERROR "Assets file not found: ${ASSETS_LOCAL_FILE}")
endif()
message(STATUS "Using assets file: ${ASSETS_LOCAL_FILE}")
endif()
set(${assets_local_file_var} ${ASSETS_LOCAL_FILE} PARENT_SCOPE)
endfunction()

View File

@ -6,6 +6,34 @@ config OTA_URL
help
The application will access this URL to check for new firmwares and server address.
config USE_DIRECT_WEBSOCKET
bool "Use direct WebSocket without OTA"
default n
help
Skip the OTA server check and use the WebSocket settings below directly.
config WEBSOCKET_URL
string "Default WebSocket URL"
depends on USE_DIRECT_WEBSOCKET
default "ws://172.19.0.240:8080"
help
The WebSocket server URL used when direct WebSocket mode is enabled.
config WEBSOCKET_TOKEN
string "Default WebSocket token"
depends on USE_DIRECT_WEBSOCKET
default ""
help
Optional Authorization token for the direct WebSocket server.
config WEBSOCKET_PROTOCOL_VERSION
int "Default WebSocket protocol version"
depends on USE_DIRECT_WEBSOCKET
range 1 3
default 1
help
Protocol-Version header and hello version used by the WebSocket protocol.
choice
prompt "Flash Assets"
default FLASH_DEFAULT_ASSETS if !USE_EMOTE_MESSAGE_STYLE
@ -130,7 +158,7 @@ choice BOARD_TYPE
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_BREAD_COMPACT_WIFI_CAM
bool "Bread Compact WiFi + LCD + Camera (面包板)"
depends on IDF_TARGET_ESP32S3
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_BREAD_COMPACT_ML307
bool "Bread Compact ML307/EC801E (面包板 4G)"
depends on IDF_TARGET_ESP32S3
@ -185,8 +213,8 @@ choice BOARD_TYPE
config BOARD_TYPE_ESP_P4_FUNCTION_EV_BOARD
bool "Espressif ESP-P4-Function-EV-Board"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_ECHOEAR
bool "EchoEar"
config BOARD_TYPE_ESP_VOCAT
bool "Espressif ESP-VoCat"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_KEVIN_BOX_2
bool "Kevin Box 2"
@ -215,6 +243,18 @@ choice BOARD_TYPE
config BOARD_TYPE_LICHUANG_DEV_C3
bool "立创·实战派 ESP32-C3"
depends on IDF_TARGET_ESP32C3
config BOARD_TYPE_RYMCU_BIGSMART
bool "RYMCU BigSmart"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_EDA_TV_PRO
bool "EDA课程案例 EDA-TV-Pro"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_EDA_ROBOT_PRO
bool "EDA课程案例 EDA-Robot-Pro"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_EDA_SUPER_BEAR
bool "EDA课程案例 EDA-Super-Bear"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_DF_K10
bool "DFRobot 行空板 k10"
depends on IDF_TARGET_ESP32S3
@ -245,92 +285,146 @@ choice BOARD_TYPE
config BOARD_TYPE_M5STACK_ATOM_S3R_ECHO_BASE
bool "M5Stack AtomS3R + Echo Base"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_M5STACK_ATOM_S3R_ECHO_PYRAMID
bool "M5Stack AtomS3R + Echo Pyramid"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_M5STACK_ATOM_S3R_CAM_M12_ECHO_BASE
bool "M5Stack AtomS3R CAM/M12 + Echo Base"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_M5STACK_ATOM_ECHOS3R
bool "M5Stack AtomEchoS3R"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_M5STACK_CARDPUTER_ADV
bool "M5Stack Cardputer Adv"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_M5STACK_ATOM_MATRIX_ECHO_BASE
bool "M5Stack AtomMatrix + Echo Base"
depends on IDF_TARGET_ESP32
config BOARD_TYPE_WAVESHARE_S3_AUDIO_BOARD
config BOARD_TYPE_WAVESHARE_ESP32_TOUCH_LCD_3_5
bool "Waveshare ESP32-Touch-LCD-3.5"
depends on IDF_TARGET_ESP32
config BOARD_TYPE_WAVESHARE_ESP32_S3_CAM_XXXX
bool "Waveshare ESP32-S3-CAM"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_ESP32_S3_AUDIO_BOARD
bool "Waveshare ESP32-S3-Audio-Board"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_8
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_8
bool "Waveshare ESP32-S3-Touch-AMOLED-1.8"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_2_06
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_43C
bool "Waveshare ESP32-S3-Touch-AMOLED-1.43C"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_2_06
bool "Waveshare ESP32-S3-Touch-AMOLED-2.06"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_2_06
config BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_2_06
bool "Waveshare ESP32-C6-Touch-AMOLED-2.06"
depends on IDF_TARGET_ESP32C6
config BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_75
config BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_2_16
bool "Waveshare ESP32-C6-Touch-AMOLED-2.16"
depends on IDF_TARGET_ESP32C6
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_2_16
bool "Waveshare ESP32-S3-Touch-AMOLED-2.16"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_75
bool "Waveshare ESP32-S3-Touch-AMOLED-1.75"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_83
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_75C
bool "Waveshare ESP32-S3-Touch-AMOLED-1.75C"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_83
bool "Waveshare ESP32-S3-Touch-LCD-1.83"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_4B
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4B
bool "Waveshare ESP32-S3-Touch-LCD-4B"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_4_3C
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4_3C
bool "Waveshare ESP32-S3-Touch-LCD-4.3C"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_85C
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_85C
bool "Waveshare ESP32-S3-Touch-LCD-1.85C"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_85
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_85
bool "Waveshare ESP32-S3-Touch-LCD-1.85"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_46
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_46
bool "Waveshare ESP32-S3-Touch-LCD-1.46"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_C6_LCD_1_69
config BOARD_TYPE_WAVESHARE_ESP32_C6_LCD_1_69
bool "Waveshare ESP32-C6-LCD-1.69"
depends on IDF_TARGET_ESP32C6
config BOARD_TYPE_WAVESHARE_C6_TOUCH_LCD_1_83
config BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_LCD_1_83
bool "Waveshare ESP32-C6-Touch-LCD-1.83"
depends on IDF_TARGET_ESP32C6
config BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_1_43
config BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_1_43
bool "Waveshare ESP32-C6-Touch-AMOLOED-1.43"
depends on IDF_TARGET_ESP32C6
config BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_1_32
config BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_1_32
bool "Waveshare ESP32-C6-Touch-AMOLOED-1.32"
depends on IDF_TARGET_ESP32C6
config BOARD_TYPE_WAVESHARE_C6_TOUCH_AMOLED_1_8
config BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_1_8
bool "Waveshare ESP32-C6-Touch-AMOLED-1.8"
depends on IDF_TARGET_ESP32C6
config BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_32
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_32
bool "Waveshare ESP32-S3-Touch-AMOLOED-1.32"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_49
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_49
bool "Waveshare ESP32-S3-Touch-LCD-3.49"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_5
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_5
bool "Waveshare ESP32-S3-Touch-LCD-3.5"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_ePaper_1_54
bool "Waveshare ESP32-S3-ePaper-1.54"
config BOARD_TYPE_WAVESHARE_ESP32_S3_ePaper_1_54_v1
bool "Waveshare ESP32-S3-ePaper-1.54_v1"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_RLCD_4_2
config BOARD_TYPE_WAVESHARE_ESP32_S3_ePaper_1_54_v2
bool "Waveshare ESP32-S3-ePaper-1.54_v2"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_ESP32_S3_ePaper_3_97
bool "Waveshare ESP32-S3-ePaper-3.97"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_ESP32_S3_RLCD_4_2
bool "Waveshare ESP32-S3-RLCD-4.2"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_5B
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_5B
bool "Waveshare ESP32-S3-Touch-LCD-3.5B"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_P4_NANO
config BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_54
bool "Waveshare ESP32-S3-Touch-LCD-1.54"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_ESP32_S3_LCD_0_85
bool "Waveshare ESP32-S3-LCD-0.85"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_WAVESHARE_ESP32_P4_NANO
bool "Waveshare ESP32-P4-NANO"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_4B
config BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4B
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-4B"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_7B
config BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4_3
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-4.3"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7B
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-7B"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_XC
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C or ESP32-P4-WIFI6-Touch-LCD-4C"
config BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_3_4C
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_3_5
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-3.5"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4C
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-4C"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-7"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_8
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-8"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_10_1
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-10.1"
depends on IDF_TARGET_ESP32P4
config BOARD_TYPE_TUDOUZI
bool "土豆子"
@ -383,6 +477,9 @@ choice BOARD_TYPE
config BOARD_TYPE_ATK_DNESP32S3M_4G
bool "正点原子DNESP32S3M-4G"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_ATK_DNESP32S3_BOX3
bool "正点原子DNESP32S3-BOX3"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_DU_CHATX
bool "嘟嘟开发板CHATX(wifi)"
depends on IDF_TARGET_ESP32S3
@ -410,6 +507,9 @@ choice BOARD_TYPE
config BOARD_TYPE_XINGZHI_METAL_1_54_WIFI
bool "无名科技星智1.54 METAL(wifi)"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_XINGZHI_ABS_2_0
bool "无名科技星智ABS 2.0"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_SEEED_STUDIO_SENSECAP_WATCHER
bool "Seeed Studio SenseCAP Watcher"
depends on IDF_TARGET_ESP32S3
@ -479,6 +579,12 @@ choice BOARD_TYPE
config BOARD_TYPE_HU_087
bool "HU-087"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_Freenove_ESP32S3_DISPLAY_2_8_LCD
bool "Freenove ESP32S3 Display 2.8 LCD"
depends on IDF_TARGET_ESP32S3
config BOARD_TYPE_AI_VOX3
bool "NULLLAB-AI-VOX3"
depends on IDF_TARGET_ESP32S3
endchoice
choice
@ -526,7 +632,7 @@ choice DISPLAY_OLED_TYPE
endchoice
choice DISPLAY_LCD_TYPE
depends on BOARD_TYPE_BREAD_COMPACT_WIFI_LCD || BOARD_TYPE_BREAD_COMPACT_ESP32_LCD || BOARD_TYPE_CGC || BOARD_TYPE_WAVESHARE_P4_NANO || BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_XC || BOARD_TYPE_BREAD_COMPACT_WIFI_CAM
depends on BOARD_TYPE_BREAD_COMPACT_WIFI_LCD || BOARD_TYPE_BREAD_COMPACT_ESP32_LCD || BOARD_TYPE_CGC || BOARD_TYPE_BREAD_COMPACT_WIFI_CAM
prompt "LCD Type"
default LCD_ST7789_240X320
help
@ -561,14 +667,6 @@ choice DISPLAY_LCD_TYPE
bool "ILI9341 240*320, Non-IPS"
config LCD_GC9A01_240X240
bool "GC9A01 240*240 Circle"
config LCD_TYPE_800_1280_10_1_INCH
bool "Waveshare 101M-8001280-IPS-CT-K Display"
config LCD_TYPE_800_1280_10_1_INCH_A
bool "Waveshare 10.1-DSI-TOUCH-A Display"
config LCD_TYPE_800_800_3_4_INCH
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-3.4C with 800*800 3.4inch round display"
config LCD_TYPE_720_720_4_INCH
bool "Waveshare ESP32-P4-WIFI6-Touch-LCD-4C with 720*720 4inch round display"
config LCD_CUSTOM
bool "Custom LCD (自定义屏幕参数)"
endchoice
@ -585,8 +683,27 @@ choice DISPLAY_ESP32S3_KORVO2_V3
bool "ILI9341 240*320"
endchoice
choice DISPLAY_ESP32S3_CAM_XXXX
depends on BOARD_TYPE_WAVESHARE_ESP32_S3_CAM_XXXX
prompt "ESP32S3_CAM LCD Type"
default BSP_LCD_SIZE_2INCH
help
LCD Display Type
config BSP_LCD_SIZE_2INCH
bool "2inch Touch LCD Module"
config BSP_LCD_SIZE_2_8INCH
bool "2.8inch Touch LCD Module"
config BSP_LCD_SIZE_3_5INCH
bool "3.5inch Touch LCD Module"
config BSP_LCD_SIZE_1_83INCH
bool "1.83inch Touch LCD Module"
endchoice
choice DISPLAY_ESP32S3_AUDIO_BOARD
depends on BOARD_TYPE_WAVESHARE_S3_AUDIO_BOARD
depends on BOARD_TYPE_WAVESHARE_ESP32_S3_AUDIO_BOARD
prompt "ESP32S3_AUDIO_BOARD LCD Type"
default AUDIO_BOARD_LCD_JD9853
help
@ -597,6 +714,19 @@ choice DISPLAY_ESP32S3_AUDIO_BOARD
bool "ST7789 240*320"
endchoice
choice DISPLAY_ESP32S3_TOUCH_LCD_1_85C
depends on BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_85C
prompt "ESP32S3_TOUCH_LCD_1_85C version"
default VERSION_2_0
help
hardware version
config VERSION_1_0
bool "version 1.0"
config VERSION_2_0
bool "version 2.0"
endchoice
choice DISPLAY_STYLE
prompt "Select display style"
default USE_DEFAULT_MESSAGE_STYLE
@ -612,10 +742,21 @@ choice DISPLAY_STYLE
config USE_EMOTE_MESSAGE_STYLE
bool "Emote animation style"
depends on BOARD_TYPE_ESP_BOX || BOARD_TYPE_ESP_BOX_3 \
|| BOARD_TYPE_ECHOEAR || BOARD_TYPE_LICHUANG_DEV_S3 \
|| BOARD_TYPE_ESP_VOCAT || BOARD_TYPE_LICHUANG_DEV_S3 || BOARD_TYPE_RYMCU_BIGSMART \
|| BOARD_TYPE_ESP_SENSAIRSHUTTLE
endchoice
config USE_MULTILINE_CHAT_MESSAGE
bool "Use multiline chat message display (default mode only)"
depends on USE_DEFAULT_MESSAGE_STYLE
default n
help
When enabled, the chat message area in the default display mode shows
multiple wrapped lines that grow upward from the bottom of the screen,
with auto-adaptive height.
When disabled (default), a single-line horizontally scrolling label
is shown at the bottom of the screen.
choice WAKE_WORD_TYPE
prompt "Wake Word Implementation Type"
default USE_AFE_WAKE_WORD if (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM
@ -677,6 +818,16 @@ config SEND_WAKE_WORD_DATA
help
Send wake word data to the server as the first message of the conversation and wait for response
config WAKE_WORD_DETECTION_IN_LISTENING
bool "Enable Wake Word Detection in Listening Mode"
default n
depends on USE_AFE_WAKE_WORD || USE_CUSTOM_WAKE_WORD
help
Enable wake word detection while in listening mode.
When enabled, the device can detect wake word during listening,
which allows interrupting the current conversation.
When disabled (default), wake word detection is turned off during listening.
config USE_AUDIO_PROCESSOR
bool "Enable Audio Noise Reduction"
default y
@ -688,11 +839,13 @@ config USE_DEVICE_AEC
bool "Enable Device-Side AEC"
default n
depends on USE_AUDIO_PROCESSOR && (BOARD_TYPE_ESP_BOX_3 || BOARD_TYPE_ESP_BOX || BOARD_TYPE_ESP_BOX_LITE \
|| BOARD_TYPE_LICHUANG_DEV_S3 || BOARD_TYPE_ESP_KORVO2_V3 || BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_1_75 || BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_1_83\
|| BOARD_TYPE_WAVESHARE_S3_TOUCH_AMOLED_2_06 || BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_4B || BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_4B || BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_7B \
|| BOARD_TYPE_WAVESHARE_P4_WIFI6_TOUCH_LCD_XC || BOARD_TYPE_ESP_S3_LCD_EV_Board_2 || BOARD_TYPE_YUNLIAO_S3 \
|| BOARD_TYPE_ECHOEAR || BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_3_49 || BOARD_TYPE_WAVESHARE_S3_RLCD_4_2 || BOARD_TYPE_ZHENGCHEN_CAM || BOARD_TYPE_ZHENGCHEN_CAM_ML307 \
|| BOARD_TYPE_WAVESHARE_S3_TOUCH_LCD_4_3C)
|| BOARD_TYPE_LICHUANG_DEV_S3 || BOARD_TYPE_RYMCU_BIGSMART || BOARD_TYPE_ESP_KORVO2_V3 || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_75|| BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_75C || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_83\
|| BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_2_06 || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4B || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4B || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4_3 \
|| BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7B \
|| BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_3_4C || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4C || BOARD_TYPE_ESP_S3_LCD_EV_Board_2 || BOARD_TYPE_YUNLIAO_S3 \
|| BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7 || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_8 || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_10_1 \
|| BOARD_TYPE_ESP_VOCAT || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_49 || BOARD_TYPE_WAVESHARE_ESP32_S3_RLCD_4_2 || BOARD_TYPE_ZHENGCHEN_CAM || BOARD_TYPE_ZHENGCHEN_CAM_ML307 \
|| BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4_3C || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_54 || BOARD_TYPE_WAVESHARE_ESP32_S3_LCD_0_85 || BOARD_TYPE_AI_VOX3 || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_43C)
help
To work properly, device-side AEC requires a clean output reference path from the speaker signal and physical acoustic isolation between the microphone and speaker.

View File

@ -64,7 +64,7 @@ void Application::Initialize() {
// Setup the display
auto display = board.GetDisplay();
display->SetupUI();
// Print board name/version info
display->SetChatMessage("system", SystemInfo::GetUserAgent().c_str());
@ -302,23 +302,33 @@ void Application::HandleActivationDoneEvent() {
SystemInfo::PrintHeapStats();
SetDeviceState(kDeviceStateIdle);
has_server_time_ = ota_->HasServerTime();
if (ota_ != nullptr) {
has_server_time_ = ota_->HasServerTime();
}
auto display = Board::GetInstance().GetDisplay();
std::string message = std::string(Lang::Strings::VERSION) + ota_->GetCurrentVersion();
display->ShowNotification(message.c_str());
if (ota_ != nullptr) {
std::string message = std::string(Lang::Strings::VERSION) + ota_->GetCurrentVersion();
display->ShowNotification(message.c_str());
}
display->SetChatMessage("system", "");
// Play the success sound to indicate the device is ready
audio_service_.PlaySound(Lang::Sounds::OGG_SUCCESS);
// Release OTA object after activation is complete
ota_.reset();
auto& board = Board::GetInstance();
board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
Schedule([this]() {
// Play the success sound to indicate the device is ready
audio_service_.PlaySound(Lang::Sounds::OGG_SUCCESS);
});
}
void Application::ActivationTask() {
#if CONFIG_USE_DIRECT_WEBSOCKET
CheckAssetsVersion();
InitializeProtocol();
#else
// Create OTA object for activation process
ota_ = std::make_unique<Ota>();
@ -330,6 +340,7 @@ void Application::ActivationTask() {
// Initialize the protocol
InitializeProtocol();
#endif
// Signal completion to main loop
xEventGroupSetBits(event_group_, MAIN_EVENT_ACTIVATION_DONE);
@ -475,6 +486,9 @@ void Application::InitializeProtocol() {
display->SetStatus(Lang::Strings::LOADING_PROTOCOL);
#if CONFIG_USE_DIRECT_WEBSOCKET
protocol_ = std::make_unique<WebsocketProtocol>();
#else
if (ota_->HasMqttConfig()) {
protocol_ = std::make_unique<MqttProtocol>();
} else if (ota_->HasWebsocketConfig()) {
@ -483,6 +497,7 @@ void Application::InitializeProtocol() {
ESP_LOGW(TAG, "No protocol specified in the OTA config, using MQTT");
protocol_ = std::make_unique<MqttProtocol>();
}
#endif
protocol_->OnConnected([this]() {
DismissAlert();
@ -658,10 +673,17 @@ void Application::DismissAlert() {
}
void Application::ToggleChatState() {
vision_text_mode_enabled_.store(false);
xEventGroupSetBits(event_group_, MAIN_EVENT_TOGGLE_CHAT);
}
void Application::ToggleChatStateWithVision() {
vision_text_mode_enabled_.store(true);
xEventGroupSetBits(event_group_, MAIN_EVENT_TOGGLE_CHAT);
}
void Application::StartListening() {
vision_text_mode_enabled_.store(false);
xEventGroupSetBits(event_group_, MAIN_EVENT_START_LISTENING);
}
@ -671,7 +693,10 @@ void Application::StopListening() {
void Application::HandleToggleChatEvent() {
auto state = GetDeviceState();
if (state != kDeviceStateIdle) {
vision_text_mode_enabled_.store(false);
}
if (state == kDeviceStateActivating) {
SetDeviceState(kDeviceStateIdle);
return;
@ -691,14 +716,16 @@ void Application::HandleToggleChatEvent() {
}
if (state == kDeviceStateIdle) {
ListeningMode mode = GetDefaultListeningMode();
if (!protocol_->IsAudioChannelOpened()) {
SetDeviceState(kDeviceStateConnecting);
if (!protocol_->OpenAudioChannel()) {
return;
}
// Schedule to let the state change be processed first (UI update)
Schedule([this, mode]() {
ContinueOpenAudioChannel(mode);
});
return;
}
SetListeningMode(aec_mode_ == kAecOff ? kListeningModeAutoStop : kListeningModeRealtime);
SetListeningMode(mode);
} else if (state == kDeviceStateSpeaking) {
AbortSpeaking(kAbortReasonNone);
} else if (state == kDeviceStateListening) {
@ -706,6 +733,25 @@ void Application::HandleToggleChatEvent() {
}
}
void Application::ContinueOpenAudioChannel(ListeningMode mode) {
// Check state again in case it was changed during scheduling
if (GetDeviceState() != kDeviceStateConnecting) {
return;
}
// Switch to performance mode before connecting to reduce latency
auto& board = Board::GetInstance();
board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
if (!protocol_->IsAudioChannelOpened()) {
if (!protocol_->OpenAudioChannel()) {
return;
}
}
SetListeningMode(mode);
}
void Application::HandleStartListeningEvent() {
auto state = GetDeviceState();
@ -726,11 +772,12 @@ void Application::HandleStartListeningEvent() {
if (state == kDeviceStateIdle) {
if (!protocol_->IsAudioChannelOpened()) {
SetDeviceState(kDeviceStateConnecting);
if (!protocol_->OpenAudioChannel()) {
return;
}
// Schedule to let the state change be processed first (UI update)
Schedule([this]() {
ContinueOpenAudioChannel(kListeningModeManualStop);
});
return;
}
SetListeningMode(kListeningModeManualStop);
} else if (state == kDeviceStateSpeaking) {
AbortSpeaking(kAbortReasonNone);
@ -759,42 +806,80 @@ void Application::HandleWakeWordDetectedEvent() {
}
auto state = GetDeviceState();
auto wake_word = audio_service_.GetLastWakeWord();
ESP_LOGI(TAG, "Wake word detected: %s (state: %d)", wake_word.c_str(), (int)state);
if (state == kDeviceStateIdle) {
audio_service_.EncodeWakeWord();
auto wake_word = audio_service_.GetLastWakeWord();
if (!protocol_->IsAudioChannelOpened()) {
SetDeviceState(kDeviceStateConnecting);
if (!protocol_->OpenAudioChannel()) {
audio_service_.EnableWakeWordDetection(true);
return;
}
// Schedule to let the state change be processed first (UI update),
// then continue with OpenAudioChannel which may block for ~1 second
Schedule([this, wake_word]() {
ContinueWakeWordInvoke(wake_word);
});
return;
}
auto wake_word = audio_service_.GetLastWakeWord();
ESP_LOGI(TAG, "Wake word detected: %s", wake_word.c_str());
#if CONFIG_SEND_WAKE_WORD_DATA
// Encode and send the wake word data to the server
while (auto packet = audio_service_.PopWakeWordPacket()) {
protocol_->SendAudio(std::move(packet));
}
// Set the chat state to wake word detected
protocol_->SendWakeWordDetected(wake_word);
SetListeningMode(aec_mode_ == kAecOff ? kListeningModeAutoStop : kListeningModeRealtime);
#else
// Set flag to play popup sound after state changes to listening
// (PlaySound here would be cleared by ResetDecoder in EnableVoiceProcessing)
play_popup_on_listening_ = true;
SetListeningMode(aec_mode_ == kAecOff ? kListeningModeAutoStop : kListeningModeRealtime);
#endif
} else if (state == kDeviceStateSpeaking) {
// Channel already opened, continue directly
ContinueWakeWordInvoke(wake_word);
} else if (state == kDeviceStateSpeaking || state == kDeviceStateListening) {
AbortSpeaking(kAbortReasonWakeWordDetected);
// Clear send queue to avoid sending residues to server
while (audio_service_.PopPacketFromSendQueue());
if (state == kDeviceStateListening) {
protocol_->SendStartListening(GetDefaultListeningMode());
audio_service_.ResetDecoder();
audio_service_.PlaySound(Lang::Sounds::OGG_POPUP);
// Re-enable wake word detection as it was stopped by the detection itself
audio_service_.EnableWakeWordDetection(true);
} else {
// Play popup sound and start listening again
play_popup_on_listening_ = true;
SetListeningMode(GetDefaultListeningMode());
}
} else if (state == kDeviceStateActivating) {
// Restart the activation check if the wake word is detected during activation
SetDeviceState(kDeviceStateIdle);
}
}
void Application::ContinueWakeWordInvoke(const std::string& wake_word) {
// Check state again in case it was changed during scheduling
if (GetDeviceState() != kDeviceStateConnecting) {
return;
}
// Switch to performance mode before connecting to reduce latency
auto& board = Board::GetInstance();
board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
if (!protocol_->IsAudioChannelOpened()) {
if (!protocol_->OpenAudioChannel()) {
audio_service_.EnableWakeWordDetection(true);
return;
}
}
ESP_LOGI(TAG, "Wake word detected: %s", wake_word.c_str());
#if CONFIG_SEND_WAKE_WORD_DATA
// Encode and send the wake word data to the server
while (auto packet = audio_service_.PopWakeWordPacket()) {
protocol_->SendAudio(std::move(packet));
}
// Set the chat state to wake word detected
protocol_->SendWakeWordDetected(wake_word);
SetListeningMode(GetDefaultListeningMode());
#else
// Set flag to play popup sound after state changes to listening
// (PlaySound here would be cleared by ResetDecoder in EnableVoiceProcessing)
play_popup_on_listening_ = true;
SetListeningMode(GetDefaultListeningMode());
#endif
}
void Application::HandleStateChangedEvent() {
DeviceState new_state = state_machine_.GetState();
clock_ticks_ = 0;
@ -808,7 +893,8 @@ void Application::HandleStateChangedEvent() {
case kDeviceStateUnknown:
case kDeviceStateIdle:
display->SetStatus(Lang::Strings::STANDBY);
display->SetEmotion("neutral");
display->ClearChatMessages(); // Clear messages first
display->SetEmotion("neutral"); // Then set emotion (wechat mode checks child count)
audio_service_.EnableVoiceProcessing(false);
audio_service_.EnableWakeWordDetection(true);
break;
@ -822,19 +908,29 @@ void Application::HandleStateChangedEvent() {
display->SetEmotion("neutral");
// Make sure the audio processor is running
if (!audio_service_.IsAudioProcessorRunning()) {
if (play_popup_on_listening_ || !audio_service_.IsAudioProcessorRunning()) {
// For auto mode, wait for playback queue to be empty before enabling voice processing
// This prevents audio truncation when STOP arrives late due to network jitter
if (listening_mode_ == kListeningModeAutoStop) {
audio_service_.WaitForPlaybackQueueEmpty();
}
if (vision_text_mode_enabled_.load()) {
SendCurrentVisionFrame();
}
// Send the start listening command
protocol_->SendStartListening(listening_mode_);
audio_service_.EnableVoiceProcessing(true);
audio_service_.EnableWakeWordDetection(false);
}
#ifdef CONFIG_WAKE_WORD_DETECTION_IN_LISTENING
// Enable wake word detection in listening mode (configured via Kconfig)
audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
#else
// Disable wake word detection in listening mode
audio_service_.EnableWakeWordDetection(false);
#endif
// Play popup sound after ResetDecoder (in EnableVoiceProcessing) has been called
if (play_popup_on_listening_) {
play_popup_on_listening_ = false;
@ -861,6 +957,26 @@ void Application::HandleStateChangedEvent() {
}
}
void Application::SendCurrentVisionFrame() {
if (!protocol_ || !protocol_->IsAudioChannelOpened()) {
return;
}
auto camera = Board::GetInstance().GetCamera();
if (camera == nullptr) {
return;
}
std::string jpeg_data;
if (!camera->CaptureToJpeg(jpeg_data, false)) {
ESP_LOGW(TAG, "Failed to capture vision frame");
return;
}
protocol_->SendVisionFrame(jpeg_data);
ESP_LOGI(TAG, "Sent vision frame, size=%u bytes", static_cast<unsigned>(jpeg_data.size()));
}
void Application::Schedule(std::function<void()>&& callback) {
{
std::lock_guard<std::mutex> lock(mutex_);
@ -882,6 +998,10 @@ void Application::SetListeningMode(ListeningMode mode) {
SetDeviceState(kDeviceStateListening);
}
ListeningMode Application::GetDefaultListeningMode() const {
return aec_mode_ == kAecOff ? kListeningModeAutoStop : kListeningModeRealtime;
}
void Application::Reboot() {
ESP_LOGI(TAG, "Rebooting...");
// Disconnect the audio channel
@ -959,27 +1079,14 @@ void Application::WakeWordInvoke(const std::string& wake_word) {
if (!protocol_->IsAudioChannelOpened()) {
SetDeviceState(kDeviceStateConnecting);
if (!protocol_->OpenAudioChannel()) {
audio_service_.EnableWakeWordDetection(true);
return;
}
// Schedule to let the state change be processed first (UI update)
Schedule([this, wake_word]() {
ContinueWakeWordInvoke(wake_word);
});
return;
}
ESP_LOGI(TAG, "Wake word detected: %s", wake_word.c_str());
#if CONFIG_USE_AFE_WAKE_WORD || CONFIG_USE_CUSTOM_WAKE_WORD
// Encode and send the wake word data to the server
while (auto packet = audio_service_.PopWakeWordPacket()) {
protocol_->SendAudio(std::move(packet));
}
// Set the chat state to wake word detected
protocol_->SendWakeWordDetected(wake_word);
SetListeningMode(aec_mode_ == kAecOff ? kListeningModeAutoStop : kListeningModeRealtime);
#else
// Set flag to play popup sound after state changes to listening
// (PlaySound here would be cleared by ResetDecoder in EnableVoiceProcessing)
play_popup_on_listening_ = true;
SetListeningMode(aec_mode_ == kAecOff ? kListeningModeAutoStop : kListeningModeRealtime);
#endif
// Channel already opened, continue directly
ContinueWakeWordInvoke(wake_word);
} else if (state == kDeviceStateSpeaking) {
Schedule([this]() {
AbortSpeaking(kAbortReasonNone);
@ -1010,12 +1117,19 @@ bool Application::CanEnterSleepMode() {
return true;
}
void Application::RegisterMcpBroadcastCallback(std::function<void(const std::string&)> callback) {
mcp_broadcast_callback_ = std::move(callback);
}
void Application::SendMcpMessage(const std::string& payload) {
// Always schedule to run in main task for thread safety
Schedule([this, payload = std::move(payload)]() {
Schedule([this, payload](){
if (protocol_) {
protocol_->SendMcpMessage(payload);
}
if (mcp_broadcast_callback_) {
mcp_broadcast_callback_(payload);
}
});
}
@ -1060,4 +1174,3 @@ void Application::ResetProtocol() {
protocol_.reset();
});
}

View File

@ -10,6 +10,8 @@
#include <mutex>
#include <deque>
#include <memory>
#include <functional>
#include <atomic>
#include "protocol.h"
#include "ota.h"
@ -90,6 +92,7 @@ public:
* Sends MAIN_EVENT_TOGGLE_CHAT to be handled in Run()
*/
void ToggleChatState();
void ToggleChatStateWithVision();
/**
* Start listening (event-based, thread-safe)
@ -108,6 +111,7 @@ public:
bool UpgradeFirmware(const std::string& url, const std::string& version = "");
bool CanEnterSleepMode();
void SendMcpMessage(const std::string& payload);
void RegisterMcpBroadcastCallback(std::function<void(const std::string&)> callback);
void SetAecMode(AecMode mode);
AecMode GetAecMode() const { return aec_mode_; }
void PlaySound(const std::string_view& sound);
@ -136,10 +140,13 @@ private:
AudioService audio_service_;
std::unique_ptr<Ota> ota_;
std::function<void(const std::string&)> mcp_broadcast_callback_;
bool has_server_time_ = false;
bool aborted_ = false;
bool assets_version_checked_ = false;
bool play_popup_on_listening_ = false; // Flag to play popup sound after state changes to listening
std::atomic<bool> vision_text_mode_enabled_ = false;
int clock_ticks_ = 0;
TaskHandle_t activation_task_handle_ = nullptr;
@ -153,6 +160,9 @@ private:
void HandleNetworkDisconnectedEvent();
void HandleActivationDoneEvent();
void HandleWakeWordDetectedEvent();
void ContinueOpenAudioChannel(ListeningMode mode);
void ContinueWakeWordInvoke(const std::string& wake_word);
void SendCurrentVisionFrame();
// Activation task (runs in background)
void ActivationTask();
@ -163,6 +173,7 @@ private:
void InitializeProtocol();
void ShowActivationCode(const std::string& code, const std::string& message);
void SetListeningMode(ListeningMode mode);
ListeningMode GetDefaultListeningMode() const;
// State change handler called by state machine
void OnStateChanged(DeviceState old_state, DeviceState new_state);

View File

@ -12,6 +12,7 @@
#include <esp_log.h>
#include <esp_timer.h>
#include <esp_heap_caps.h>
#include <cbin_font.h>
@ -49,8 +50,8 @@ bool Assets::FindPartition(Assets* assets) {
return true;
}
bool Assets::Apply() {
return strategy_ ? strategy_->Apply(this) : false;
bool Assets::Apply(bool refresh_display_theme) {
return strategy_ ? strategy_->Apply(this, refresh_display_theme) : false;
}
bool Assets::InitializePartition() {
@ -210,7 +211,7 @@ bool Assets::LvglStrategy::GetAssetData(Assets* assets, const std::string& name,
return true;
}
bool Assets::LvglStrategy::Apply(Assets* assets) {
bool Assets::LvglStrategy::Apply(Assets* assets, bool refresh_display_theme) {
void* ptr = nullptr;
size_t size = 0;
if (!assets->GetAssetData("index.json", ptr, size)) {
@ -331,22 +332,24 @@ bool Assets::LvglStrategy::Apply(Assets* assets) {
}
}
auto display = Board::GetInstance().GetDisplay();
ESP_LOGI(TAG, "Refreshing display theme...");
if (refresh_display_theme) {
auto display = Board::GetInstance().GetDisplay();
ESP_LOGI(TAG, "Refreshing display theme...");
auto current_theme = display->GetTheme();
if (current_theme != nullptr) {
display->SetTheme(current_theme);
}
auto current_theme = display->GetTheme();
if (current_theme != nullptr) {
display->SetTheme(current_theme);
}
// Parse hide_subtitle configuration
cJSON* hide_subtitle = cJSON_GetObjectItem(root, "hide_subtitle");
if (cJSON_IsBool(hide_subtitle)) {
bool hide = cJSON_IsTrue(hide_subtitle);
auto lcd_display = dynamic_cast<LcdDisplay*>(display);
if (lcd_display != nullptr) {
lcd_display->SetHideSubtitle(hide);
ESP_LOGI(TAG, "Set hide_subtitle to %s", hide ? "true" : "false");
// Parse hide_subtitle configuration
cJSON* hide_subtitle = cJSON_GetObjectItem(root, "hide_subtitle");
if (cJSON_IsBool(hide_subtitle)) {
bool hide = cJSON_IsTrue(hide_subtitle);
auto lcd_display = dynamic_cast<LcdDisplay*>(display);
if (lcd_display != nullptr) {
lcd_display->SetHideSubtitle(hide);
ESP_LOGI(TAG, "Set hide_subtitle to %s", hide ? "true" : "false");
}
}
}
@ -410,7 +413,7 @@ bool Assets::EmoteStrategy::GetAssetData(Assets* assets, const std::string& name
return false;
}
bool Assets::EmoteStrategy::Apply(Assets* assets) {
bool Assets::EmoteStrategy::Apply(Assets* assets, bool refresh_display_theme) {
Assets::LoadSrmodelsFromIndex(assets);
auto display = Board::GetInstance().GetDisplay();
@ -464,16 +467,21 @@ bool Assets::Download(std::string url, std::function<void(int progress, size_t s
SECTOR_SIZE, content_length, sectors_to_erase, total_erase_size);
// 写入新的资源文件到分区一边erase一边写入
char buffer[512];
char* buffer = (char*)heap_caps_malloc(SECTOR_SIZE, MALLOC_CAP_INTERNAL);
if (buffer == nullptr) {
ESP_LOGE(TAG, "Failed to allocate buffer");
return false;
}
size_t total_written = 0;
size_t recent_written = 0;
size_t current_sector = 0;
auto last_calc_time = esp_timer_get_time();
while (true) {
int ret = http->Read(buffer, sizeof(buffer));
int ret = http->Read(buffer, SECTOR_SIZE);
if (ret < 0) {
ESP_LOGE(TAG, "Failed to read HTTP data: %s", esp_err_to_name(ret));
heap_caps_free(buffer);
return false;
}
@ -493,6 +501,7 @@ bool Assets::Download(std::string url, std::function<void(int progress, size_t s
// 确保擦除范围不超过分区大小
if (sector_end > partition_->size) {
ESP_LOGE(TAG, "Sector end (%u) exceeds partition size (%lu)", sector_end, partition_->size);
heap_caps_free(buffer);
return false;
}
@ -500,6 +509,7 @@ bool Assets::Download(std::string url, std::function<void(int progress, size_t s
esp_err_t err = esp_partition_erase_range(partition_, sector_start, SECTOR_SIZE);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to erase sector %u at offset %u: %s", current_sector, sector_start, esp_err_to_name(err));
heap_caps_free(buffer);
return false;
}
@ -510,6 +520,7 @@ bool Assets::Download(std::string url, std::function<void(int progress, size_t s
esp_err_t err = esp_partition_write(partition_, total_written, buffer, ret);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to write to assets partition at offset %u: %s", total_written, esp_err_to_name(err));
heap_caps_free(buffer);
return false;
}
@ -531,6 +542,7 @@ bool Assets::Download(std::string url, std::function<void(int progress, size_t s
}
http->Close();
heap_caps_free(buffer);
if (total_written != content_length) {
ESP_LOGE(TAG, "Downloaded size (%u) does not match expected size (%u)", total_written, content_length);

View File

@ -29,7 +29,7 @@ public:
~Assets();
bool Download(std::string url, std::function<void(int progress, size_t speed)> progress_callback);
bool Apply();
bool Apply(bool refresh_display_theme = true);
bool GetAssetData(const std::string& name, void*& ptr, size_t& size);
inline bool partition_valid() const { return partition_valid_; }
@ -48,7 +48,7 @@ private:
class AssetStrategy {
public:
virtual ~AssetStrategy() = default;
virtual bool Apply(Assets* assets) = 0;
virtual bool Apply(Assets* assets, bool refresh_display_theme = true) = 0;
virtual bool InitializePartition(Assets* assets) = 0;
virtual void UnApplyPartition(Assets* assets) = 0;
virtual bool GetAssetData(Assets* assets, const std::string& name, void*& ptr, size_t& size) = 0;
@ -56,7 +56,7 @@ private:
class LvglStrategy : public AssetStrategy {
public:
bool Apply(Assets* assets) override;
bool Apply(Assets* assets, bool refresh_display_theme = true) override;
bool InitializePartition(Assets* assets) override;
void UnApplyPartition(Assets* assets) override;
bool GetAssetData(Assets* assets, const std::string& name, void*& ptr, size_t& size) override;
@ -70,7 +70,7 @@ private:
class EmoteStrategy : public AssetStrategy {
public:
bool Apply(Assets* assets) override;
bool Apply(Assets* assets, bool refresh_display_theme = true) override;
bool InitializePartition(Assets* assets) override;
void UnApplyPartition(Assets* assets) override;
bool GetAssetData(Assets* assets, const std::string& name, void*& ptr, size_t& size) override;

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "جاري تحميل الموارد...",
"PLEASE_WAIT": "يرجى الانتظار...",
"FOUND_NEW_ASSETS": "تم العثور على موارد جديدة: %s",
"HELLO_MY_FRIEND": "مرحباً، صديقي!"
"HELLO_MY_FRIEND": "مرحباً، صديقي!",
"CONNECTION_SUCCESSFUL": "تم الاتصال بنجاح",
"FLIGHT_MODE_OFF": "وضع الطيران معطل",
"FLIGHT_MODE_ON": "وضع الطيران قيد التشغيل",
"MODEM_INIT_ERROR": "فشل تهيئة المودم"
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Намерени нови ресурси: %s",
"DOWNLOAD_ASSETS_FAILED": "Неуспешно изтегляне на ресурси",
"LOADING_ASSETS": "Зареждане на ресурси...",
"HELLO_MY_FRIEND": "Здравей, мой приятел!"
"HELLO_MY_FRIEND": "Здравей, мой приятел!",
"FLIGHT_MODE_OFF": "Режим на самолет е изключен",
"FLIGHT_MODE_ON": "Режим на самолет е включен",
"MODEM_INIT_ERROR": "Неуспешна инициализация на модема"
}
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "S'han trobat nous recursos: %s",
"DOWNLOAD_ASSETS_FAILED": "No s'han pogut descarregar els recursos",
"LOADING_ASSETS": "Carregant recursos...",
"HELLO_MY_FRIEND": "Hola, amic meu!"
"HELLO_MY_FRIEND": "Hola, amic meu!",
"FLIGHT_MODE_OFF": "El mode avió està desactivat",
"FLIGHT_MODE_ON": "El mode avió està activat",
"MODEM_INIT_ERROR": "Error d'inicialització del mòdem"
}
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Načítání prostředků...",
"PLEASE_WAIT": "Prosím čekejte...",
"FOUND_NEW_ASSETS": "Nalezeny nové prostředky: %s",
"HELLO_MY_FRIEND": "Ahoj, můj příteli!"
"HELLO_MY_FRIEND": "Ahoj, můj příteli!",
"CONNECTION_SUCCESSFUL": "Připojení úspěšné",
"FLIGHT_MODE_OFF": "Letecký režim je vypnutý",
"FLIGHT_MODE_ON": "Letecký režim je zapnutý",
"MODEM_INIT_ERROR": "Chyba inicializace modemu"
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Fandt nye ressourcer: %s",
"DOWNLOAD_ASSETS_FAILED": "Download af ressourcer mislykkedes",
"LOADING_ASSETS": "Indlæser ressourcer...",
"HELLO_MY_FRIEND": "Hej, min ven!"
"HELLO_MY_FRIEND": "Hej, min ven!",
"FLIGHT_MODE_OFF": "Flytilstand er slukket",
"FLIGHT_MODE_ON": "Flytilstand er tændt",
"MODEM_INIT_ERROR": "Modeminitialisering mislykkedes"
}
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Ressourcen werden geladen...",
"PLEASE_WAIT": "Bitte warten...",
"FOUND_NEW_ASSETS": "Neue Ressourcen gefunden: %s",
"HELLO_MY_FRIEND": "Hallo, mein Freund!"
"HELLO_MY_FRIEND": "Hallo, mein Freund!",
"CONNECTION_SUCCESSFUL": "Verbindung erfolgreich",
"FLIGHT_MODE_OFF": "Flugmodus ist deaktiviert",
"FLIGHT_MODE_ON": "Flugmodus ist aktiviert",
"MODEM_INIT_ERROR": "Modem-Initialisierung fehlgeschlagen"
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Βρέθηκαν νέοι πόροι: %s",
"DOWNLOAD_ASSETS_FAILED": "Αποτυχία λήψης πόρων",
"LOADING_ASSETS": "Φόρτωση πόρων...",
"HELLO_MY_FRIEND": "Γεια σου, φίλε μου!"
"HELLO_MY_FRIEND": "Γεια σου, φίλε μου!",
"FLIGHT_MODE_OFF": "Η λειτουργία πτήσης είναι απενεργοποιημένη",
"FLIGHT_MODE_ON": "Η λειτουργία πτήσης είναι ενεργή",
"MODEM_INIT_ERROR": "Αποτυχία αρχικοποίησης modem"
}
}
}

View File

@ -13,6 +13,8 @@
"REG_ERROR": "Unable to access network, please check SIM card status",
"MODEM_INIT_ERROR": "Modem initialization failed",
"DETECTING_MODULE": "Detecting module...",
"FLIGHT_MODE_ON": "Flight mode is on",
"FLIGHT_MODE_OFF": "Flight mode is off",
"REGISTERING_NETWORK": "Waiting for network...",
"CHECKING_NEW_VERSION": "Checking for new version...",
"CHECK_NEW_VERSION_FAILED": "Check for new version failed, will retry in %d seconds: %s",

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Cargando recursos...",
"PLEASE_WAIT": "Por favor espere...",
"FOUND_NEW_ASSETS": "Encontrados nuevos recursos: %s",
"HELLO_MY_FRIEND": "¡Hola, mi amigo!"
"HELLO_MY_FRIEND": "¡Hola, mi amigo!",
"CONNECTION_SUCCESSFUL": "Conexión exitosa",
"FLIGHT_MODE_OFF": "El modo avión está desactivado",
"FLIGHT_MODE_ON": "El modo avión está activado",
"MODEM_INIT_ERROR": "Error de inicialización del módem"
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "منابع جدید یافت شد: %s",
"DOWNLOAD_ASSETS_FAILED": "دانلود منابع ناموفق بود",
"LOADING_ASSETS": "بارگذاری منابع...",
"HELLO_MY_FRIEND": "سلام، دوست من!"
"HELLO_MY_FRIEND": "سلام، دوست من!",
"FLIGHT_MODE_OFF": "حالت پرواز خاموش است",
"FLIGHT_MODE_ON": "حالت پرواز روشن است",
"MODEM_INIT_ERROR": "خطا در راه‌اندازی مودم"
}
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Ladataan resursseja...",
"PLEASE_WAIT": "Odota hetki...",
"FOUND_NEW_ASSETS": "Löydetty uusia resursseja: %s",
"HELLO_MY_FRIEND": "Hei, ystäväni!"
"HELLO_MY_FRIEND": "Hei, ystäväni!",
"CONNECTION_SUCCESSFUL": "Yhteys onnistui",
"FLIGHT_MODE_OFF": "Lentotila on pois päältä",
"FLIGHT_MODE_ON": "Lentotila on päällä",
"MODEM_INIT_ERROR": "Modeemin alustus epäonnistui"
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Nakahanap ng mga bagong assets: %s",
"DOWNLOAD_ASSETS_FAILED": "Nabigo ang pag-download ng mga assets",
"LOADING_ASSETS": "Nilo-load ang mga assets...",
"HELLO_MY_FRIEND": "Kumusta, kaibigan ko!"
"HELLO_MY_FRIEND": "Kumusta, kaibigan ko!",
"FLIGHT_MODE_OFF": "Naka-off ang flight mode",
"FLIGHT_MODE_ON": "Naka-on ang flight mode",
"MODEM_INIT_ERROR": "Nabigo ang pag-initialize ng modem"
}
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Chargement des ressources...",
"PLEASE_WAIT": "Veuillez patienter...",
"FOUND_NEW_ASSETS": "Nouvelles ressources trouvées: %s",
"HELLO_MY_FRIEND": "Bonjour, mon ami !"
"HELLO_MY_FRIEND": "Bonjour, mon ami !",
"CONNECTION_SUCCESSFUL": "Connexion réussie",
"FLIGHT_MODE_OFF": "Le mode avion est désactivé",
"FLIGHT_MODE_ON": "Le mode avion est activé",
"MODEM_INIT_ERROR": "Échec de l'initialisation du modem"
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "נמצאו משאבים חדשים: %s",
"DOWNLOAD_ASSETS_FAILED": "הורדת משאבים נכשלה",
"LOADING_ASSETS": "טוען משאבים...",
"HELLO_MY_FRIEND": "שלום, ידידי!"
"HELLO_MY_FRIEND": "שלום, ידידי!",
"FLIGHT_MODE_OFF": "מצב טיסה כבוי",
"FLIGHT_MODE_ON": "מצב טיסה מופעל",
"MODEM_INIT_ERROR": "אתחול המודם נכשל"
}
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "संसाधन लोड हो रहे हैं...",
"PLEASE_WAIT": "कृपया प्रतीक्षा करें...",
"FOUND_NEW_ASSETS": "नए संसाधन मिले: %s",
"HELLO_MY_FRIEND": "नमस्ते, मेरे दोस्त!"
"HELLO_MY_FRIEND": "नमस्ते, मेरे दोस्त!",
"CONNECTION_SUCCESSFUL": "कनेक्शन सफल",
"FLIGHT_MODE_OFF": "फ़्लाइट मोड बंद है",
"FLIGHT_MODE_ON": "फ़्लाइट मोड चालू है",
"MODEM_INIT_ERROR": "मॉडेम आरंभीकरण विफल"
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Pronađeni novi resursi: %s",
"DOWNLOAD_ASSETS_FAILED": "Preuzimanje resursa nije uspjelo",
"LOADING_ASSETS": "Učitavanje resursa...",
"HELLO_MY_FRIEND": "Bok, moj prijatelju!"
"HELLO_MY_FRIEND": "Bok, moj prijatelju!",
"FLIGHT_MODE_OFF": "Način rada u zrakoplovu je isključen",
"FLIGHT_MODE_ON": "Način rada u zrakoplovu je uključen",
"MODEM_INIT_ERROR": "Neuspjela inicijalizacija modema"
}
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Új erőforrások találva: %s",
"DOWNLOAD_ASSETS_FAILED": "Az erőforrások letöltése sikertelen",
"LOADING_ASSETS": "Erőforrások betöltése...",
"HELLO_MY_FRIEND": "Helló, barátom!"
"HELLO_MY_FRIEND": "Helló, barátom!",
"FLIGHT_MODE_OFF": "A repülési mód ki van kapcsolva",
"FLIGHT_MODE_ON": "A repülési mód be van kapcsolva",
"MODEM_INIT_ERROR": "A modem inicializálása sikertelen"
}
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Memuat aset...",
"PLEASE_WAIT": "Mohon tunggu...",
"FOUND_NEW_ASSETS": "Ditemukan aset baru: %s",
"HELLO_MY_FRIEND": "Halo, teman saya!"
"HELLO_MY_FRIEND": "Halo, teman saya!",
"CONNECTION_SUCCESSFUL": "Koneksi berhasil",
"FLIGHT_MODE_OFF": "Mode pesawat nonaktif",
"FLIGHT_MODE_ON": "Mode pesawat aktif",
"MODEM_INIT_ERROR": "Gagal menginisialisasi modem"
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Caricamento risorse...",
"PLEASE_WAIT": "Attendere prego...",
"FOUND_NEW_ASSETS": "Trovate nuove risorse: %s",
"HELLO_MY_FRIEND": "Ciao, amico mio!"
"HELLO_MY_FRIEND": "Ciao, amico mio!",
"CONNECTION_SUCCESSFUL": "Connessione riuscita",
"FLIGHT_MODE_OFF": "La modalità aereo è disattivata",
"FLIGHT_MODE_ON": "La modalità aereo è attiva",
"MODEM_INIT_ERROR": "Inizializzazione modem non riuscita"
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "アセットを読み込み中...",
"PLEASE_WAIT": "お待ちください...",
"FOUND_NEW_ASSETS": "新しいアセットが見つかりました: %s",
"HELLO_MY_FRIEND": "こんにちは、友達!"
"HELLO_MY_FRIEND": "こんにちは、友達!",
"CONNECTION_SUCCESSFUL": "接続成功",
"FLIGHT_MODE_OFF": "機内モードがオフです",
"FLIGHT_MODE_ON": "機内モードがオンです",
"MODEM_INIT_ERROR": "モデムの初期化に失敗しました"
}
}

View File

@ -51,6 +51,9 @@
"LOADING_ASSETS": "에셋 로딩 중...",
"PLEASE_WAIT": "잠시 기다려 주세요...",
"FOUND_NEW_ASSETS": "새로운 에셋을 발견했습니다: %s",
"HELLO_MY_FRIEND": "안녕하세요, 친구!"
"HELLO_MY_FRIEND": "안녕하세요, 친구!",
"FLIGHT_MODE_OFF": "비행기 모드가 꺼져 있습니다",
"FLIGHT_MODE_ON": "비행기 모드가 켜져 있습니다",
"MODEM_INIT_ERROR": "모뎀 초기화 실패"
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Menemui aset baharu: %s",
"DOWNLOAD_ASSETS_FAILED": "Gagal memuat turun aset",
"LOADING_ASSETS": "Memuatkan aset...",
"HELLO_MY_FRIEND": "Hai, kawan saya!"
"HELLO_MY_FRIEND": "Hai, kawan saya!",
"FLIGHT_MODE_OFF": "Mod penerbangan dimatikan",
"FLIGHT_MODE_ON": "Mod penerbangan dihidupkan",
"MODEM_INIT_ERROR": "Modem gagal dimulakan"
}
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Fant nye ressurser: %s",
"DOWNLOAD_ASSETS_FAILED": "Nedlasting av ressurser mislyktes",
"LOADING_ASSETS": "Laster ressurser...",
"HELLO_MY_FRIEND": "Hei, min venn!"
"HELLO_MY_FRIEND": "Hei, min venn!",
"FLIGHT_MODE_OFF": "Flymodus er av",
"FLIGHT_MODE_ON": "Flymodus er på",
"MODEM_INIT_ERROR": "Modeminitialisering mislyktes"
}
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Nieuwe bronnen gevonden: %s",
"DOWNLOAD_ASSETS_FAILED": "Downloaden van bronnen mislukt",
"LOADING_ASSETS": "Bronnen laden...",
"HELLO_MY_FRIEND": "Hallo, mijn vriend!"
"HELLO_MY_FRIEND": "Hallo, mijn vriend!",
"FLIGHT_MODE_OFF": "Vliegtuigmodus is uitgeschakeld",
"FLIGHT_MODE_ON": "Vliegtuigmodus is ingeschakeld",
"MODEM_INIT_ERROR": "Modeminitialisatie mislukt"
}
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Ładowanie zasobów...",
"PLEASE_WAIT": "Proszę czekać...",
"FOUND_NEW_ASSETS": "Znaleziono nowe zasoby: %s",
"HELLO_MY_FRIEND": "Cześć, mój przyjacielu!"
"HELLO_MY_FRIEND": "Cześć, mój przyjacielu!",
"CONNECTION_SUCCESSFUL": "Połączenie udane",
"FLIGHT_MODE_OFF": "Tryb samolotowy jest wyłączony",
"FLIGHT_MODE_ON": "Tryb samolotowy jest włączony",
"MODEM_INIT_ERROR": "Inicjalizacja modemu nie powiodła się"
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "A carregar recursos...",
"PLEASE_WAIT": "Por favor aguarde...",
"FOUND_NEW_ASSETS": "Encontrados novos recursos: %s",
"HELLO_MY_FRIEND": "Olá, meu amigo!"
"HELLO_MY_FRIEND": "Olá, meu amigo!",
"CONNECTION_SUCCESSFUL": "Ligação bem-sucedida",
"FLIGHT_MODE_OFF": "O modo avião está desativado",
"FLIGHT_MODE_ON": "O modo avião está ativado",
"MODEM_INIT_ERROR": "Falha na inicialização do modem"
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Se încarcă resursele...",
"PLEASE_WAIT": "Vă rugăm să așteptați...",
"FOUND_NEW_ASSETS": "S-au găsit resurse noi: %s",
"HELLO_MY_FRIEND": "Salut, prietenul meu!"
"HELLO_MY_FRIEND": "Salut, prietenul meu!",
"CONNECTION_SUCCESSFUL": "Conexiune reușită",
"FLIGHT_MODE_OFF": "Modul avion este dezactivat",
"FLIGHT_MODE_ON": "Modul avion este activat",
"MODEM_INIT_ERROR": "Inițializarea modemului a eșuat"
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Загрузка ресурсов...",
"PLEASE_WAIT": "Пожалуйста, подождите...",
"FOUND_NEW_ASSETS": "Найдены новые ресурсы: %s",
"HELLO_MY_FRIEND": "Привет, мой друг!"
"HELLO_MY_FRIEND": "Привет, мой друг!",
"CONNECTION_SUCCESSFUL": "Подключение успешно",
"FLIGHT_MODE_OFF": "Режим полета выключен",
"FLIGHT_MODE_ON": "Режим полета включен",
"MODEM_INIT_ERROR": "Ошибка инициализации модема"
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Nájdené nové zdroje: %s",
"DOWNLOAD_ASSETS_FAILED": "Sťahovanie zdrojov zlyhalo",
"LOADING_ASSETS": "Načítavanie zdrojov...",
"HELLO_MY_FRIEND": "Ahoj, môj priateľ!"
"HELLO_MY_FRIEND": "Ahoj, môj priateľ!",
"FLIGHT_MODE_OFF": "Letecký režim je vypnutý",
"FLIGHT_MODE_ON": "Letecký režim je zapnutý",
"MODEM_INIT_ERROR": "Chyba inicializácie modemu"
}
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Najdeni novi viri: %s",
"DOWNLOAD_ASSETS_FAILED": "Prenos virov ni uspel",
"LOADING_ASSETS": "Nalaganje virov...",
"HELLO_MY_FRIEND": "Pozdravljeni, moj prijatelj!"
"HELLO_MY_FRIEND": "Pozdravljeni, moj prijatelj!",
"FLIGHT_MODE_OFF": "Način leta je izklopljen",
"FLIGHT_MODE_ON": "Način leta je vklopljen",
"MODEM_INIT_ERROR": "Inicializacija modema ni uspela"
}
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Пронађени нови ресурси: %s",
"DOWNLOAD_ASSETS_FAILED": "Преузимање ресурса није успело",
"LOADING_ASSETS": "Учитавање ресурса...",
"HELLO_MY_FRIEND": "Здраво, пријатељу!"
"HELLO_MY_FRIEND": "Здраво, пријатељу!",
"FLIGHT_MODE_OFF": "Режим лета је искључен",
"FLIGHT_MODE_ON": "Режим лета је укључен",
"MODEM_INIT_ERROR": "Иницијализација модема није успела"
}
}
}

View File

@ -51,7 +51,9 @@
"FOUND_NEW_ASSETS": "Hittade nya resurser: %s",
"DOWNLOAD_ASSETS_FAILED": "Nedladdning av resurser misslyckades",
"LOADING_ASSETS": "Laddar resurser...",
"HELLO_MY_FRIEND": "Hej, min vän!"
"HELLO_MY_FRIEND": "Hej, min vän!",
"FLIGHT_MODE_OFF": "Flygläge är av",
"FLIGHT_MODE_ON": "Flygläge är på",
"MODEM_INIT_ERROR": "Modeminitiering misslyckades"
}
}
}

View File

@ -51,6 +51,9 @@
"LOADING_ASSETS": "กำลังโหลดทรัพยากร...",
"PLEASE_WAIT": "กรุณารอสักครู่...",
"FOUND_NEW_ASSETS": "พบทรัพยากรใหม่: %s",
"HELLO_MY_FRIEND": "สวัสดี เพื่อนของฉัน!"
"HELLO_MY_FRIEND": "สวัสดี เพื่อนของฉัน!",
"FLIGHT_MODE_OFF": "โหมดเครื่องบินปิดอยู่",
"FLIGHT_MODE_ON": "โหมดเครื่องบินเปิดอยู่",
"MODEM_INIT_ERROR": "การเริ่มต้นโมเด็มล้มเหลว"
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Varlıklar yükleniyor...",
"PLEASE_WAIT": "Lütfen bekleyin...",
"FOUND_NEW_ASSETS": "Yeni varlıklar bulundu: %s",
"HELLO_MY_FRIEND": "Merhaba, arkadaşım!"
"HELLO_MY_FRIEND": "Merhaba, arkadaşım!",
"CONNECTION_SUCCESSFUL": "Bağlantı başarılı",
"FLIGHT_MODE_OFF": "Uçak modu kapalı",
"FLIGHT_MODE_ON": "Uçak modu açık",
"MODEM_INIT_ERROR": "Modem başlatma hatası"
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "Завантаження ресурсів...",
"PLEASE_WAIT": "Будь ласка, зачекайте...",
"FOUND_NEW_ASSETS": "Знайдено нові ресурси: %s",
"HELLO_MY_FRIEND": "Привіт, мій друже!"
"HELLO_MY_FRIEND": "Привіт, мій друже!",
"CONNECTION_SUCCESSFUL": "Підключення успішне",
"FLIGHT_MODE_OFF": "Режим польоту вимкнено",
"FLIGHT_MODE_ON": "Режим польоту увімкнено",
"MODEM_INIT_ERROR": "Помилка ініціалізації модему"
}
}

View File

@ -51,6 +51,9 @@
"LOADING_ASSETS": "Đang tải tài nguyên...",
"PLEASE_WAIT": "Vui lòng đợi...",
"FOUND_NEW_ASSETS": "Tìm thấy tài nguyên mới: %s",
"HELLO_MY_FRIEND": "Xin chào, bạn của tôi!"
"HELLO_MY_FRIEND": "Xin chào, bạn của tôi!",
"FLIGHT_MODE_OFF": "Chế độ máy bay đang tắt",
"FLIGHT_MODE_ON": "Chế độ máy bay đang bật",
"MODEM_INIT_ERROR": "Khởi tạo modem thất bại"
}
}

View File

@ -51,6 +51,9 @@
"LOADING_ASSETS": "加载资源...",
"PLEASE_WAIT": "请稍候...",
"FOUND_NEW_ASSETS": "发现新资源: %s",
"HELLO_MY_FRIEND": "你好,我的朋友!"
"HELLO_MY_FRIEND": "你好,我的朋友!",
"CONNECTION_SUCCESSFUL": "连接成功",
"FLIGHT_MODE_OFF": "飞行模式已关闭",
"FLIGHT_MODE_ON": "飞行模式已开启"
}
}

View File

@ -50,6 +50,10 @@
"LOADING_ASSETS": "載入資源...",
"PLEASE_WAIT": "請稍候...",
"FOUND_NEW_ASSETS": "發現新資源: %s",
"HELLO_MY_FRIEND": "你好,我的朋友!"
"HELLO_MY_FRIEND": "你好,我的朋友!",
"CONNECTION_SUCCESSFUL": "連線成功",
"FLIGHT_MODE_OFF": "飛航模式已關閉",
"FLIGHT_MODE_ON": "飛航模式已開啟",
"MODEM_INIT_ERROR": "模組初始化失敗"
}
}

View File

@ -34,16 +34,6 @@ void AudioCodec::Start() {
output_volume_ = 10;
}
if (tx_handle_ != nullptr) {
ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_));
}
if (rx_handle_ != nullptr) {
ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_));
}
EnableInput(true);
EnableOutput(true);
ESP_LOGI(TAG, "Audio codec started");
}

View File

@ -265,32 +265,23 @@ void AudioService::AudioInputTask() {
}
}
/* Feed the wake word */
if (bits & AS_EVENT_WAKE_WORD_RUNNING) {
/* Feed the wake word and/or audio processor */
if (bits & (AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING)) {
int samples = 160; // 10ms
std::vector<int16_t> data;
int samples = wake_word_->GetFeedSize();
if (samples > 0) {
if (ReadAudioData(data, 16000, samples)) {
if (ReadAudioData(data, 16000, samples)) {
if (bits & AS_EVENT_WAKE_WORD_RUNNING) {
wake_word_->Feed(data);
continue;
}
}
}
/* Feed the audio processor */
if (bits & AS_EVENT_AUDIO_PROCESSOR_RUNNING) {
std::vector<int16_t> data;
int samples = audio_processor_->GetFeedSize();
if (samples > 0) {
if (ReadAudioData(data, 16000, samples)) {
if (bits & AS_EVENT_AUDIO_PROCESSOR_RUNNING) {
audio_processor_->Feed(std::move(data));
continue;
}
continue;
}
}
ESP_LOGE(TAG, "Should not be here, bits: %lx", bits);
break;
// Read timeout/error should not terminate the input task.
vTaskDelay(pdMS_TO_TICKS(10));
}
ESP_LOGW(TAG, "Audio input task stopped");
@ -314,6 +305,7 @@ void AudioService::AudioOutputTask() {
esp_timer_start_periodic(audio_power_timer_, AUDIO_POWER_CHECK_INTERVAL_MS * 1000);
codec_->EnableOutput(true);
}
codec_->OutputData(task->pcm);
/* Update the last output time */
@ -645,94 +637,20 @@ void AudioService::PlaySound(const std::string_view& ogg) {
codec_->EnableOutput(true);
}
const uint8_t* buf = reinterpret_cast<const uint8_t*>(ogg.data());
const auto* buf = reinterpret_cast<const uint8_t*>(ogg.data());
size_t size = ogg.size();
size_t offset = 0;
auto find_page = [&](size_t start)->size_t {
for (size_t i = start; i + 4 <= size; ++i) {
if (buf[i] == 'O' && buf[i+1] == 'g' && buf[i+2] == 'g' && buf[i+3] == 'S') return i;
}
return static_cast<size_t>(-1);
};
bool seen_head = false;
bool seen_tags = false;
int sample_rate = 16000; // 默认值
while (true) {
size_t pos = find_page(offset);
if (pos == static_cast<size_t>(-1)) break;
offset = pos;
if (offset + 27 > size) break;
const uint8_t* page = buf + offset;
uint8_t page_segments = page[26];
size_t seg_table_off = offset + 27;
if (seg_table_off + page_segments > size) break;
size_t body_size = 0;
for (size_t i = 0; i < page_segments; ++i) body_size += page[27 + i];
size_t body_off = seg_table_off + page_segments;
if (body_off + body_size > size) break;
// Parse packets using lacing
size_t cur = body_off;
size_t seg_idx = 0;
while (seg_idx < page_segments) {
size_t pkt_len = 0;
size_t pkt_start = cur;
bool continued = false;
do {
uint8_t l = page[27 + seg_idx++];
pkt_len += l;
cur += l;
continued = (l == 255);
} while (continued && seg_idx < page_segments);
if (pkt_len == 0) continue;
const uint8_t* pkt_ptr = buf + pkt_start;
if (!seen_head) {
// 解析OpusHead包
if (pkt_len >= 19 && std::memcmp(pkt_ptr, "OpusHead", 8) == 0) {
seen_head = true;
// OpusHead结构[0-7] "OpusHead", [8] version, [9] channel_count, [10-11] pre_skip
// [12-15] input_sample_rate, [16-17] output_gain, [18] mapping_family
if (pkt_len >= 12) {
uint8_t version = pkt_ptr[8];
uint8_t channel_count = pkt_ptr[9];
if (pkt_len >= 16) {
// 读取输入采样率 (little-endian)
sample_rate = pkt_ptr[12] | (pkt_ptr[13] << 8) |
(pkt_ptr[14] << 16) | (pkt_ptr[15] << 24);
ESP_LOGI(TAG, "OpusHead: version=%d, channels=%d, sample_rate=%d",
version, channel_count, sample_rate);
}
}
}
continue;
}
if (!seen_tags) {
// Expect OpusTags in second packet
if (pkt_len >= 8 && std::memcmp(pkt_ptr, "OpusTags", 8) == 0) {
seen_tags = true;
}
continue;
}
// Audio packet (Opus)
auto packet = std::make_unique<AudioStreamPacket>();
packet->sample_rate = sample_rate;
packet->frame_duration = 60;
packet->payload.resize(pkt_len);
std::memcpy(packet->payload.data(), pkt_ptr, pkt_len);
PushPacketToDecodeQueue(std::move(packet), true);
}
offset = body_off + body_size;
}
auto demuxer = std::make_unique<OggDemuxer>();
demuxer->OnDemuxerFinished([this](const uint8_t* data, int sample_rate, size_t size){
auto packet = std::make_unique<AudioStreamPacket>();
packet->sample_rate = sample_rate;
packet->frame_duration = 60;
packet->payload.resize(size);
std::memcpy(packet->payload.data(), data, size);
PushPacketToDecodeQueue(std::move(packet), true);
});
demuxer->Reset();
demuxer->Process(buf, size);
}
bool AudioService::IsIdle() {
@ -769,7 +687,10 @@ void AudioService::CheckAndUpdateAudioPowerState() {
codec_->EnableInput(false);
}
if (output_elapsed > AUDIO_POWER_TIMEOUT_MS && codec_->output_enabled()) {
codec_->EnableOutput(false);
// Keep TX clock when duplex RX is active; otherwise RX may stall on some boards.
if (!(codec_->duplex() && codec_->input_enabled())) {
codec_->EnableOutput(false);
}
}
if (!codec_->input_enabled() && !codec_->output_enabled()) {
esp_timer_stop(audio_power_timer_);

View File

@ -23,7 +23,7 @@
#include "processors/audio_debugger.h"
#include "wake_word.h"
#include "protocol.h"
#include "ogg_demuxer.h"
/*
* There are two types of audio data flow:

View File

@ -176,6 +176,8 @@ void BoxAudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_
ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_init_tdm_mode(rx_handle_, &tdm_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_));
ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_));
ESP_LOGI(TAG, "Duplex channels created");
}

View File

@ -150,11 +150,16 @@ void Es8311AudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gp
ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_));
ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_));
ESP_LOGI(TAG, "Duplex channels created");
}
void Es8311AudioCodec::SetOutputVolume(int volume) {
ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(dev_, volume));
std::lock_guard<std::mutex> lock(data_if_mutex_);
if (dev_ != nullptr) {
ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(dev_, volume));
}
AudioCodec::SetOutputVolume(volume);
}

View File

@ -126,6 +126,8 @@ void Es8374AudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gp
ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_));
ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_));
ESP_LOGI(TAG, "Duplex channels created");
}

View File

@ -131,6 +131,8 @@ void Es8388AudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gp
ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_));
ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_));
ESP_LOGI(TAG, "Duplex channels created");
}
@ -186,9 +188,6 @@ void Es8388AudioCodec::EnableOutput(bool enable) {
// Set analog output volume to 0dB, default is -45dB
uint8_t reg_val = 30; // 0dB
if(input_reference_){
reg_val = 27;
}
uint8_t regs[] = { 46, 47, 48, 49 }; // HP_LVOL, HP_RVOL, SPK_LVOL, SPK_RVOL
for (uint8_t reg : regs) {
ctrl_if_->write_reg(ctrl_if_, reg, 1, &reg_val, 1);

View File

@ -132,6 +132,8 @@ void Es8389AudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gp
ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_));
ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_));
ESP_LOGI(TAG, "Duplex channels created");
}

View File

@ -2,6 +2,7 @@
#include <esp_log.h>
#include <cmath>
#include <cstdint>
#include <cstring>
#define TAG "NoAudioCodec"
@ -239,10 +240,10 @@ int NoAudioCodec::Write(const int16_t* data, int samples) {
int NoAudioCodec::Read(int16_t* dest, int samples) {
size_t bytes_read;
constexpr uint32_t kReadTimeoutMs = 200;
std::vector<int32_t> bit32_buffer(samples);
if (i2s_channel_read(rx_handle_, bit32_buffer.data(), samples * sizeof(int32_t), &bytes_read, portMAX_DELAY) != ESP_OK) {
ESP_LOGE(TAG, "Read Failed!");
if (i2s_channel_read(rx_handle_, bit32_buffer.data(), samples * sizeof(int32_t), &bytes_read, kReadTimeoutMs) != ESP_OK) {
return 0;
}
@ -254,6 +255,32 @@ int NoAudioCodec::Read(int16_t* dest, int samples) {
return samples;
}
void NoAudioCodec::EnableInput(bool enable) {
std::lock_guard<std::mutex> lock(data_if_mutex_);
if (enable == input_enabled_) {
return;
}
if (enable) {
ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_));
} else {
ESP_ERROR_CHECK(i2s_channel_disable(rx_handle_));
}
AudioCodec::EnableInput(enable);
}
void NoAudioCodec::EnableOutput(bool enable) {
std::lock_guard<std::mutex> lock(data_if_mutex_);
if (enable == output_enabled_) {
return;
}
if (enable) {
ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_));
} else {
ESP_ERROR_CHECK(i2s_channel_disable(tx_handle_));
}
AudioCodec::EnableOutput(enable);
}
// Delegating constructor: calls the main constructor with default slot mask
NoAudioCodecSimplexPdm::NoAudioCodecSimplexPdm(int input_sample_rate, int output_sample_rate, gpio_num_t spk_bclk, gpio_num_t spk_ws, gpio_num_t spk_dout, gpio_num_t mic_sck, gpio_num_t mic_din)
: NoAudioCodecSimplexPdm(input_sample_rate, output_sample_rate, spk_bclk, spk_ws, spk_dout, I2S_STD_SLOT_LEFT, mic_sck, mic_din) {

View File

@ -13,6 +13,8 @@ protected:
virtual int Write(const int16_t* data, int samples) override;
virtual int Read(int16_t* dest, int samples) override;
virtual void EnableInput(bool enable) override;
virtual void EnableOutput(bool enable) override;
public:
virtual ~NoAudioCodec();

View File

@ -0,0 +1,311 @@
#include "ogg_demuxer.h"
#include "esp_log.h"
#define TAG "OggDemuxer"
/// @brief 重置解封器
void OggDemuxer::Reset()
{
opus_info_ = {
.head_seen = false,
.tags_seen = false,
.sample_rate = 48000
};
state_ = ParseState::FIND_PAGE;
ctx_.packet_len = 0;
ctx_.seg_count = 0;
ctx_.seg_index = 0;
ctx_.data_offset = 0;
ctx_.bytes_needed = 4; // 需要4字节"OggS"
ctx_.seg_remaining = 0;
ctx_.body_size = 0;
ctx_.body_offset = 0;
ctx_.packet_continued = false;
// 清空缓冲区数据
memset(ctx_.header, 0, sizeof(ctx_.header));
memset(ctx_.seg_table, 0, sizeof(ctx_.seg_table));
memset(ctx_.packet_buf, 0, sizeof(ctx_.packet_buf));
}
/// @brief 处理数据块
/// @param data 输入数据
/// @param size 输入数据大小
/// @return 已处理的字节数
size_t OggDemuxer::Process(const uint8_t* data, size_t size)
{
size_t processed = 0; // 已处理的字节数
while (processed < size) {
switch (state_) {
case ParseState::FIND_PAGE: {
// 寻找页头"OggS"
if (ctx_.bytes_needed < 4) {
// 处理不完整的"OggS"匹配(跨数据块)
size_t to_copy = std::min(size - processed, ctx_.bytes_needed);
memcpy(ctx_.header + (4 - ctx_.bytes_needed), data + processed, to_copy);
processed += to_copy;
ctx_.bytes_needed -= to_copy;
if (ctx_.bytes_needed == 0) {
// 检查是否匹配"OggS"
if (memcmp(ctx_.header, "OggS", 4) == 0) {
state_ = ParseState::PARSE_HEADER;
ctx_.data_offset = 4;
ctx_.bytes_needed = 27 - 4; // 还需要23字节完成页头
} else {
// 匹配失败滑动1字节继续匹配
memmove(ctx_.header, ctx_.header + 1, 3);
ctx_.bytes_needed = 1;
}
} else {
// 数据不足,等待更多数据
return processed;
}
} else if (ctx_.bytes_needed == 4) {
// 在数据块中查找完整的"OggS"
bool found = false;
size_t i = 0;
size_t remaining = size - processed;
// 搜索"OggS"
for (; i + 4 <= remaining; i++) {
if (memcmp(data + processed + i, "OggS", 4) == 0) {
found = true;
break;
}
}
if (found) {
// 找到"OggS",跳过已搜索的字节
processed += i;
// 不记录找到的"OggS",无必要
// memcpy(ctx_.header, data + processed, 4);
processed += 4;
state_ = ParseState::PARSE_HEADER;
ctx_.data_offset = 4;
ctx_.bytes_needed = 27 - 4; // 还需要23字节
} else {
// 没有找到完整"OggS",保存可能的部分匹配
size_t partial_len = remaining - i;
if (partial_len > 0) {
memcpy(ctx_.header, data + processed + i, partial_len);
ctx_.bytes_needed = 4 - partial_len;
processed += i + partial_len;
} else {
processed += i; // 已搜索所有字节
}
return processed; // 返回已处理的字节数
}
} else {
ESP_LOGE(TAG, "OggDemuxer run in error state: bytes_needed=%zu", ctx_.bytes_needed);
Reset();
return processed;
}
break;
}
case ParseState::PARSE_HEADER: {
size_t available = size - processed;
if (available < ctx_.bytes_needed) {
// 数据不足,复制可用的部分
memcpy(ctx_.header + ctx_.data_offset,
data + processed, available);
ctx_.data_offset += available;
ctx_.bytes_needed -= available;
processed += available;
return processed; // 等待更多数据
} else {
// 有足够的数据完成页头
size_t to_copy = ctx_.bytes_needed;
memcpy(ctx_.header + ctx_.data_offset,
data + processed, to_copy);
processed += to_copy;
ctx_.data_offset += to_copy;
ctx_.bytes_needed = 0;
// 验证页头
if (ctx_.header[4] != 0) {
ESP_LOGE(TAG, "无效的Ogg版本: %d", ctx_.header[4]);
state_ = ParseState::FIND_PAGE;
ctx_.bytes_needed = 4;
ctx_.data_offset = 0;
break;
}
ctx_.seg_count = ctx_.header[26];
if (ctx_.seg_count > 0 && ctx_.seg_count <= 255) {
state_ = ParseState::PARSE_SEGMENTS;
ctx_.bytes_needed = ctx_.seg_count;
ctx_.data_offset = 0;
} else if (ctx_.seg_count == 0) {
// 没有段,直接跳到下一个页面
state_ = ParseState::FIND_PAGE;
ctx_.bytes_needed = 4;
ctx_.data_offset = 0;
} else {
ESP_LOGE(TAG, "无效的段数: %u", ctx_.seg_count);
state_ = ParseState::FIND_PAGE;
ctx_.bytes_needed = 4;
ctx_.data_offset = 0;
}
}
break;
}
case ParseState::PARSE_SEGMENTS: {
size_t available = size - processed;
if (available < ctx_.bytes_needed) {
memcpy(ctx_.seg_table + ctx_.data_offset,
data + processed, available);
ctx_.data_offset += available;
ctx_.bytes_needed -= available;
processed += available;
return processed; // 等待更多数据
} else {
size_t to_copy = ctx_.bytes_needed;
memcpy(ctx_.seg_table + ctx_.data_offset,
data + processed, to_copy);
processed += to_copy;
ctx_.data_offset += to_copy;
ctx_.bytes_needed = 0;
state_ = ParseState::PARSE_DATA;
ctx_.seg_index = 0;
ctx_.data_offset = 0;
// 计算数据体总大小
ctx_.body_size = 0;
for (size_t i = 0; i < ctx_.seg_count; ++i) {
ctx_.body_size += ctx_.seg_table[i];
}
ctx_.body_offset = 0;
ctx_.seg_remaining = 0;
}
break;
}
case ParseState::PARSE_DATA: {
while (ctx_.seg_index < ctx_.seg_count && processed < size) {
uint8_t seg_len = ctx_.seg_table[ctx_.seg_index];
// 检查段数据是否已经部分读取
if (ctx_.seg_remaining > 0) {
seg_len = ctx_.seg_remaining;
} else {
ctx_.seg_remaining = seg_len;
}
// 检查缓冲区是否足够
if (ctx_.packet_len + seg_len > sizeof(ctx_.packet_buf)) {
ESP_LOGE(TAG, "包缓冲区溢出: %zu + %u > %zu", ctx_.packet_len, seg_len, sizeof(ctx_.packet_buf));
state_ = ParseState::FIND_PAGE;
ctx_.packet_len = 0;
ctx_.packet_continued = false;
ctx_.seg_remaining = 0;
ctx_.bytes_needed = 4;
return processed;
}
// 复制数据
size_t to_copy = std::min(size - processed, (size_t)seg_len);
memcpy(ctx_.packet_buf + ctx_.packet_len, data + processed, to_copy);
processed += to_copy;
ctx_.packet_len += to_copy;
ctx_.body_offset += to_copy;
ctx_.seg_remaining -= to_copy;
// 检查段是否完整
if (ctx_.seg_remaining > 0) {
// 段不完整,等待更多数据
return processed;
}
// 段完整
bool seg_continued = (ctx_.seg_table[ctx_.seg_index] == 255);
if (!seg_continued) {
// 包结束
if (ctx_.packet_len) {
if (!opus_info_.head_seen) {
if (ctx_.packet_len >=8 && memcmp(ctx_.packet_buf, "OpusHead", 8) == 0) {
opus_info_.head_seen = true;
if (ctx_.packet_len >= 19) {
opus_info_.sample_rate = ctx_.packet_buf[12] |
(ctx_.packet_buf[13] << 8) |
(ctx_.packet_buf[14] << 16) |
(ctx_.packet_buf[15] << 24);
ESP_LOGD(TAG, "OpusHead found, sample_rate=%d", opus_info_.sample_rate);
}
ctx_.packet_len = 0;
ctx_.packet_continued = false;
ctx_.seg_index++;
ctx_.seg_remaining = 0;
continue;
}
}
if (!opus_info_.tags_seen) {
if (ctx_.packet_len >= 8 && memcmp(ctx_.packet_buf, "OpusTags", 8) == 0) {
opus_info_.tags_seen = true;
ESP_LOGD(TAG, "OpusTags found.");
ctx_.packet_len = 0;
ctx_.packet_continued = false;
ctx_.seg_index++;
ctx_.seg_remaining = 0;
continue;
}
}
if (opus_info_.head_seen && opus_info_.tags_seen) {
if (on_demuxer_finished_) {
on_demuxer_finished_(ctx_.packet_buf, opus_info_.sample_rate, ctx_.packet_len);
}
} else {
ESP_LOGW(TAG, "当前Ogg容器未解析到OpusHead/OpusTags丢弃");
}
}
ctx_.packet_len = 0;
ctx_.packet_continued = false;
} else {
ctx_.packet_continued = true;
}
ctx_.seg_index++;
ctx_.seg_remaining = 0;
}
if (ctx_.seg_index == ctx_.seg_count) {
// 检查是否所有数据体都已读取
if (ctx_.body_offset < ctx_.body_size) {
ESP_LOGW(TAG, "数据体不完整: %zu/%zu",
ctx_.body_offset, ctx_.body_size);
}
// 如果包跨页保持packet_len和packet_continued
if (!ctx_.packet_continued) {
ctx_.packet_len = 0;
}
// 进入下一页面
state_ = ParseState::FIND_PAGE;
ctx_.bytes_needed = 4;
ctx_.data_offset = 0;
}
break;
}
}
}
return processed;
}

View File

@ -0,0 +1,63 @@
#ifndef OGG_DEMUXER_H_
#define OGG_DEMUXER_H_
#include <functional>
#include <cstdint>
#include <cstring>
#include <vector>
class OggDemuxer {
private:
enum ParseState : int8_t {
FIND_PAGE,
PARSE_HEADER,
PARSE_SEGMENTS,
PARSE_DATA
};
struct Opus_t {
bool head_seen{false};
bool tags_seen{false};
int sample_rate{48000};
};
// 使用固定大小的缓冲区避免动态分配
struct context_t {
bool packet_continued{false}; // 当前包是否跨多个段
uint8_t header[27]; // Ogg页头
uint8_t seg_table[255]; // 当前存储的段表
uint8_t packet_buf[8192]; // 8KB包缓冲区
size_t packet_len = 0; // 缓冲区中累计的数据长度
size_t seg_count = 0; // 当前页段数
size_t seg_index = 0; // 当前处理的段索引
size_t data_offset = 0; // 解析当前阶段已读取的字节数
size_t bytes_needed = 0; // 解析当前字段还需要读取的字节数
size_t seg_remaining = 0; // 当前段剩余需要读取的字节数
size_t body_size = 0; // 数据体总大小
size_t body_offset = 0; // 数据体已读取的字节数
};
public:
OggDemuxer() {
Reset();
}
void Reset();
size_t Process(const uint8_t* data, size_t size);
/// @brief 设置解封装完毕后回调处理函数
/// @param on_demuxer_finished
void OnDemuxerFinished(std::function<void(const uint8_t* data, int sample_rate, size_t len)> on_demuxer_finished) {
on_demuxer_finished_ = on_demuxer_finished;
}
private:
ParseState state_ = ParseState::FIND_PAGE;
context_t ctx_;
Opus_t opus_info_;
std::function<void(const uint8_t*, int, size_t)> on_demuxer_finished_;
};
#endif

View File

@ -92,7 +92,18 @@ void AfeAudioProcessor::Feed(std::vector<int16_t>&& data) {
if (afe_data_ == nullptr) {
return;
}
afe_iface_->feed(afe_data_, data.data());
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
// Check running state inside lock to avoid TOCTOU race with Stop()
if (!IsRunning()) {
return;
}
input_buffer_.insert(input_buffer_.end(), data.begin(), data.end());
size_t chunk_size = afe_iface_->get_feed_chunksize(afe_data_) * codec_->input_channels();
while (input_buffer_.size() >= chunk_size) {
afe_iface_->feed(afe_data_, input_buffer_.data());
input_buffer_.erase(input_buffer_.begin(), input_buffer_.begin() + chunk_size);
}
}
void AfeAudioProcessor::Start() {
@ -101,9 +112,12 @@ void AfeAudioProcessor::Start() {
void AfeAudioProcessor::Stop() {
xEventGroupClearBits(event_group_, PROCESSOR_RUNNING);
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
if (afe_data_ != nullptr) {
afe_iface_->reset_buffer(afe_data_);
}
input_buffer_.clear();
}
bool AfeAudioProcessor::IsRunning() {

View File

@ -9,6 +9,7 @@
#include <string>
#include <vector>
#include <functional>
#include <mutex>
#include "audio_processor.h"
#include "audio_codec.h"
@ -37,6 +38,8 @@ private:
AudioCodec* codec_ = nullptr;
int frame_samples_ = 0;
bool is_speaking_ = false;
std::vector<int16_t> input_buffer_;
std::mutex input_buffer_mutex_;
std::vector<int16_t> output_buffer_;
void AudioProcessorTask();

View File

@ -6,6 +6,7 @@
void NoAudioProcessor::Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) {
codec_ = codec;
frame_samples_ = frame_duration_ms * 16000 / 1000;
output_buffer_.reserve(frame_samples_);
}
void NoAudioProcessor::Feed(std::vector<int16_t>&& data) {
@ -13,15 +14,25 @@ void NoAudioProcessor::Feed(std::vector<int16_t>&& data) {
return;
}
// Convert stereo to mono if needed
if (codec_->input_channels() == 2) {
// If input channels is 2, we need to fetch the left channel data
auto mono_data = std::vector<int16_t>(data.size() / 2);
for (size_t i = 0, j = 0; i < mono_data.size(); ++i, j += 2) {
mono_data[i] = data[j];
for (size_t i = 0, j = 0; i < data.size() / 2; ++i, j += 2) {
output_buffer_.push_back(data[j]);
}
output_callback_(std::move(mono_data));
} else {
output_callback_(std::move(data));
output_buffer_.insert(output_buffer_.end(), data.begin(), data.end());
}
// Output complete frames when buffer has enough data
while (output_buffer_.size() >= (size_t)frame_samples_) {
if (output_buffer_.size() == (size_t)frame_samples_) {
output_callback_(std::move(output_buffer_));
output_buffer_.clear();
output_buffer_.reserve(frame_samples_);
} else {
output_callback_(std::vector<int16_t>(output_buffer_.begin(), output_buffer_.begin() + frame_samples_));
output_buffer_.erase(output_buffer_.begin(), output_buffer_.begin() + frame_samples_);
}
}
}
@ -31,6 +42,7 @@ void NoAudioProcessor::Start() {
void NoAudioProcessor::Stop() {
is_running_ = false;
output_buffer_.clear();
}
bool NoAudioProcessor::IsRunning() {

View File

@ -3,6 +3,7 @@
#include <vector>
#include <functional>
#include <atomic>
#include "audio_processor.h"
#include "audio_codec.h"
@ -25,9 +26,10 @@ public:
private:
AudioCodec* codec_ = nullptr;
int frame_samples_ = 0;
std::vector<int16_t> output_buffer_;
std::function<void(std::vector<int16_t>&& data)> output_callback_;
std::function<void(bool speaking)> vad_state_change_callback_;
bool is_running_ = false;
std::atomic<bool> is_running_ = false;
};
#endif

View File

@ -99,16 +99,30 @@ void AfeWakeWord::Start() {
void AfeWakeWord::Stop() {
xEventGroupClearBits(event_group_, DETECTION_RUNNING_EVENT);
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
if (afe_data_ != nullptr) {
afe_iface_->reset_buffer(afe_data_);
}
input_buffer_.clear();
}
void AfeWakeWord::Feed(const std::vector<int16_t>& data) {
if (afe_data_ == nullptr) {
return;
}
afe_iface_->feed(afe_data_, data.data());
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
// Check running state inside lock to avoid TOCTOU race with Stop()
if (!(xEventGroupGetBits(event_group_) & DETECTION_RUNNING_EVENT)) {
return;
}
input_buffer_.insert(input_buffer_.end(), data.begin(), data.end());
size_t chunk_size = afe_iface_->get_feed_chunksize(afe_data_) * codec_->input_channels();
while (input_buffer_.size() >= chunk_size) {
afe_iface_->feed(afe_data_, input_buffer_.data());
input_buffer_.erase(input_buffer_.begin(), input_buffer_.begin() + chunk_size);
}
}
size_t AfeWakeWord::GetFeedSize() {

View File

@ -44,6 +44,8 @@ private:
std::function<void(const std::string& wake_word)> wake_word_detected_callback_;
AudioCodec* codec_ = nullptr;
std::string last_detected_wake_word_;
std::vector<int16_t> input_buffer_;
std::mutex input_buffer_mutex_;
TaskHandle_t wake_word_encode_task_ = nullptr;
StaticTask_t* wake_word_encode_task_buffer_ = nullptr;

View File

@ -138,49 +138,64 @@ void CustomWakeWord::Start() {
void CustomWakeWord::Stop() {
running_ = false;
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
input_buffer_.clear();
}
void CustomWakeWord::Feed(const std::vector<int16_t>& data) {
if (multinet_model_data_ == nullptr || !running_) {
if (multinet_model_data_ == nullptr) {
return;
}
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
// Check running state inside lock to avoid TOCTOU race with Stop()
if (!running_) {
return;
}
esp_mn_state_t mn_state;
// If input channels is 2, we need to fetch the left channel data
if (codec_->input_channels() == 2) {
auto mono_data = std::vector<int16_t>(data.size() / 2);
for (size_t i = 0, j = 0; i < mono_data.size(); ++i, j += 2) {
mono_data[i] = data[j];
for (size_t i = 0; i < data.size(); i += 2) {
input_buffer_.push_back(data[i]);
}
StoreWakeWordData(mono_data);
mn_state = multinet_->detect(multinet_model_data_, const_cast<int16_t*>(mono_data.data()));
} else {
StoreWakeWordData(data);
mn_state = multinet_->detect(multinet_model_data_, const_cast<int16_t*>(data.data()));
input_buffer_.insert(input_buffer_.end(), data.begin(), data.end());
}
if (mn_state == ESP_MN_STATE_DETECTING) {
return;
} else if (mn_state == ESP_MN_STATE_DETECTED) {
esp_mn_results_t *mn_result = multinet_->get_results(multinet_model_data_);
for (int i = 0; i < mn_result->num && running_; i++) {
ESP_LOGI(TAG, "Custom wake word detected: command_id=%d, string=%s, prob=%f",
mn_result->command_id[i], mn_result->string, mn_result->prob[i]);
auto& command = commands_[mn_result->command_id[i] - 1];
if (command.action == "wake") {
last_detected_wake_word_ = command.text;
running_ = false;
if (wake_word_detected_callback_) {
wake_word_detected_callback_(last_detected_wake_word_);
int chunksize = multinet_->get_samp_chunksize(multinet_model_data_);
while (input_buffer_.size() >= chunksize) {
std::vector<int16_t> chunk(input_buffer_.begin(), input_buffer_.begin() + chunksize);
StoreWakeWordData(chunk);
esp_mn_state_t mn_state = multinet_->detect(multinet_model_data_, chunk.data());
if (mn_state == ESP_MN_STATE_DETECTED) {
esp_mn_results_t *mn_result = multinet_->get_results(multinet_model_data_);
for (int i = 0; i < mn_result->num && running_; i++) {
ESP_LOGI(TAG, "Custom wake word detected: command_id=%d, string=%s, prob=%f",
mn_result->command_id[i], mn_result->string, mn_result->prob[i]);
auto& command = commands_[mn_result->command_id[i] - 1];
if (command.action == "wake") {
last_detected_wake_word_ = command.text;
running_ = false;
input_buffer_.clear();
if (wake_word_detected_callback_) {
wake_word_detected_callback_(last_detected_wake_word_);
}
}
}
multinet_->clean(multinet_model_data_);
} else if (mn_state == ESP_MN_STATE_TIMEOUT) {
ESP_LOGD(TAG, "Command word detection timeout, cleaning state");
multinet_->clean(multinet_model_data_);
}
multinet_->clean(multinet_model_data_);
} else if (mn_state == ESP_MN_STATE_TIMEOUT) {
ESP_LOGD(TAG, "Command word detection timeout, cleaning state");
multinet_->clean(multinet_model_data_);
if (!running_) {
break;
}
input_buffer_.erase(input_buffer_.begin(), input_buffer_.begin() + chunksize);
}
}

View File

@ -53,6 +53,8 @@ private:
AudioCodec* codec_ = nullptr;
std::string last_detected_wake_word_;
std::atomic<bool> running_ = false;
std::vector<int16_t> input_buffer_;
std::mutex input_buffer_mutex_;
TaskHandle_t wake_word_encode_task_ = nullptr;
StaticTask_t* wake_word_encode_task_buffer_ = nullptr;

View File

@ -54,21 +54,44 @@ void EspWakeWord::Start() {
void EspWakeWord::Stop() {
running_ = false;
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
input_buffer_.clear();
}
void EspWakeWord::Feed(const std::vector<int16_t>& data) {
if (wakenet_data_ == nullptr || !running_) {
if (wakenet_data_ == nullptr) {
return;
}
int res = wakenet_iface_->detect(wakenet_data_, (int16_t *)data.data());
if (res > 0) {
last_detected_wake_word_ = wakenet_iface_->get_word_name(wakenet_data_, res);
running_ = false;
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
// Check running state inside lock to avoid TOCTOU race with Stop()
if (!running_) {
return;
}
if (wake_word_detected_callback_) {
wake_word_detected_callback_(last_detected_wake_word_);
if (codec_->input_channels() == 2) {
for (size_t i = 0; i < data.size(); i += 2) {
input_buffer_.push_back(data[i]);
}
} else {
input_buffer_.insert(input_buffer_.end(), data.begin(), data.end());
}
int chunksize = wakenet_iface_->get_samp_chunksize(wakenet_data_);
while (input_buffer_.size() >= chunksize) {
int res = wakenet_iface_->detect(wakenet_data_, input_buffer_.data());
if (res > 0) {
last_detected_wake_word_ = wakenet_iface_->get_word_name(wakenet_data_, res);
running_ = false;
input_buffer_.clear();
if (wake_word_detected_callback_) {
wake_word_detected_callback_(last_detected_wake_word_);
}
break;
}
input_buffer_.erase(input_buffer_.begin(), input_buffer_.begin() + chunksize);
}
}

View File

@ -9,6 +9,7 @@
#include <vector>
#include <functional>
#include <atomic>
#include <mutex>
#include "audio_codec.h"
#include "wake_word.h"
@ -37,6 +38,8 @@ private:
std::function<void(const std::string& wake_word)> wake_word_detected_callback_;
std::string last_detected_wake_word_;
std::vector<int16_t> input_buffer_;
std::mutex input_buffer_mutex_;
};
#endif

View File

@ -0,0 +1,471 @@
#include "wifi_board.h"
#include "audio_codec.h"
#include "codecs/es8311_audio_codec.h"
#include "codecs/box_audio_codec.h"
#include "codecs/no_audio_codec.h"
#include "display/lcd_display.h"
#include "application.h"
#include "button.h"
#include "config.h"
#include "led/single_led.h"
#include "i2c_device.h"
#include "esp_video.h"
#include <wifi_station.h>
#include <esp_log.h>
#include <driver/i2c_master.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/timers.h>
#include <esp_lcd_panel_io.h>
#include <esp_lcd_panel_vendor.h>
#include <esp_lcd_panel_ops.h>
#include "power_save_timer.h"
#include "power_manager.h"
#include <driver/rtc_io.h>
#include <esp_sleep.h>
#include "esp_io_expander_tca95xx_16bit.h"
#include "assets/lang_config.h"
#include <driver/spi_common.h>
#define TAG "atk_dnesp32s3_box3"
LV_FONT_DECLARE(font_puhui_20_4);
LV_FONT_DECLARE(font_awesome_20_4);
class atk_dnesp32s3_box3 : public WifiBoard {
private:
i2c_master_bus_handle_t i2c_bus_;
LcdDisplay* display_;
EspVideo* camera_;
static atk_dnesp32s3_box3* instance_;
esp_io_expander_handle_t io_exp_handle;
button_handle_t btns;
button_driver_t* btn_driver_ = nullptr;
PowerSaveTimer* power_save_timer_;
PowerManager* power_manager_;
PowerSupply power_status_;
esp_timer_handle_t wake_timer_handle_;
int ticks_ = 0;
const int kChgCtrlInterval = 5;
void InitializeBoardPowerManager() {
instance_ = this;
if (IoExpanderGetLevel(XIO_BAT_CHRG) == 0) {
power_status_ = kDeviceTypecSupply;
} else {
power_status_ = kDeviceBatterySupply;
}
esp_timer_create_args_t wake_display_timer_args = {
.callback = [](void *arg) {
atk_dnesp32s3_box3* self = static_cast<atk_dnesp32s3_box3*>(arg);
self->ticks_ ++;
if (self->ticks_ % self->kChgCtrlInterval == 0) {
if (self->IoExpanderGetLevel(XIO_BAT_CHRG) == 0) {
self->power_status_ = kDeviceTypecSupply;
} else {
self->power_status_ = kDeviceBatterySupply;
}
/* 低于某个电量,会自动关机 */
if (self->power_manager_->low_voltage_ < 2630 && self->power_status_ == kDeviceBatterySupply) {
esp_timer_stop(self->power_manager_->timer_handle_);
esp_io_expander_set_dir(self->io_exp_handle, XIO_BAT_CHRG_EN, IO_EXPANDER_OUTPUT);
esp_io_expander_set_level(self->io_exp_handle, XIO_BAT_CHRG_EN, 0);
vTaskDelay(pdMS_TO_TICKS(100));
esp_io_expander_set_dir(self->io_exp_handle, XIO_BAT_CHRG_EN, IO_EXPANDER_INPUT);
esp_io_expander_set_level(self->io_exp_handle, XIO_BAT_CHRG_EN, 0);
vTaskDelay(pdMS_TO_TICKS(100));
}
}
},
.arg = this,
.dispatch_method = ESP_TIMER_TASK,
.name = "wake_update_timer",
.skip_unhandled_events = true,
};
ESP_ERROR_CHECK(esp_timer_create(&wake_display_timer_args, &wake_timer_handle_));
ESP_ERROR_CHECK(esp_timer_start_periodic(wake_timer_handle_, 100000));
}
void InitializePowerManager() {
power_manager_ = new PowerManager(io_exp_handle);
power_manager_->OnChargingStatusChanged([this](bool is_charging) {
if (is_charging) {
power_save_timer_->SetEnabled(false);
} else {
power_save_timer_->SetEnabled(true);
}
});
}
void InitializePowerSaveTimer() {
power_save_timer_ = new PowerSaveTimer(-1, -1, -1);
power_save_timer_->OnEnterSleepMode([this]() {
GetDisplay()->SetPowerSaveMode(true);
GetBacklight()->SetBrightness(1);
});
power_save_timer_->OnExitSleepMode([this]() {
GetDisplay()->SetPowerSaveMode(false);
GetBacklight()->RestoreBrightness();
});
power_save_timer_->OnShutdownRequest([this]() {
if (power_status_ == kDeviceBatterySupply) {
GetBacklight()->SetBrightness(0);
esp_timer_stop(power_manager_ ->timer_handle_);
esp_io_expander_set_dir(io_exp_handle, XIO_BAT_CHRG_EN, IO_EXPANDER_OUTPUT);
esp_io_expander_set_level(io_exp_handle, XIO_BAT_CHRG_EN, 0);
vTaskDelay(pdMS_TO_TICKS(100));
esp_io_expander_set_level(io_exp_handle, XIO_VDD_3V3_EN, 0);
}
});
power_save_timer_->SetEnabled(true);
}
void audio_volume_change(bool direction) {
auto codec = GetAudioCodec();
auto volume = codec->output_volume();
if (direction) {
volume += 10;
if (volume > 100) {
volume = 100;
}
codec->SetOutputVolume(volume);
} else {
volume -= 10;
if (volume < 0) {
volume = 0;
}
codec->SetOutputVolume(volume);
}
GetDisplay()->ShowNotification(Lang::Strings::VOLUME + std::to_string(volume));
}
void audio_volume_minimum(){
GetAudioCodec()->SetOutputVolume(0);
GetDisplay()->ShowNotification(Lang::Strings::MUTED);
}
void audio_volume_maxmum(){
GetAudioCodec()->SetOutputVolume(100);
GetDisplay()->ShowNotification(Lang::Strings::MAX_VOLUME);
}
esp_err_t IoExpanderSetLevel(uint16_t pin_mask, uint8_t level) {
return esp_io_expander_set_level(io_exp_handle, pin_mask, level);
}
uint8_t IoExpanderGetLevel(uint16_t pin_mask) {
uint32_t pin_val = 0;
esp_io_expander_get_level(io_exp_handle, DRV_IO_EXP_INPUT_MASK, &pin_val);
pin_mask &= DRV_IO_EXP_INPUT_MASK;
return (uint8_t)((pin_val & pin_mask) ? 1 : 0);
}
void InitializeIoExpander() {
esp_err_t ret = ESP_OK;
esp_io_expander_new_i2c_tca95xx_16bit(i2c_bus_, AW9523B_ADDR, &io_exp_handle);
// ret |= esp_io_expander_set_pullupdown(io_exp_handle, DRV_IO_EXP_INPUT_MASK, IO_EXPANDER_PULL_NONE);
ret |= esp_io_expander_set_dir(io_exp_handle, DRV_IO_EXP_OUTPUT_MASK, IO_EXPANDER_OUTPUT);
ret |= esp_io_expander_set_dir(io_exp_handle, DRV_IO_EXP_INPUT_MASK, IO_EXPANDER_INPUT);
ret |= esp_io_expander_set_level(io_exp_handle, XIO_VDD_2V8_EN, 1); /* 0308 */
ret |= esp_io_expander_set_level(io_exp_handle, XIO_VDD_3V3_EN, 1); /* 电源 */
ret |= esp_io_expander_set_level(io_exp_handle, XIO_ESP_ADC_SEL, 1); /* ADC */
ret |= esp_io_expander_set_level(io_exp_handle, XIO_VDDA_3V3_EN, 1);/* 音频电源 */
ret |= esp_io_expander_set_level(io_exp_handle, XIO_VBAT_EN, 1); /* 音频 */
ret |= esp_io_expander_set_level(io_exp_handle, XIO_PA_CTRL, 1); /* 音频功放 */
ret |= esp_io_expander_set_level(io_exp_handle, XIO_LCD_BL, 0); /* LCD背光 */
assert(ret == ESP_OK);
}
void InitializeI2c() {
// Initialize I2C peripheral
i2c_master_bus_config_t i2c_bus_cfg = {
.i2c_port = (i2c_port_t)I2C_NUM_0,
.sda_io_num = AUDIO_CODEC_I2C_SDA_PIN,
.scl_io_num = AUDIO_CODEC_I2C_SCL_PIN,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.intr_priority = 0,
.trans_queue_depth = 0,
.flags = {
.enable_internal_pullup = 1,
},
};
ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &i2c_bus_));
}
// Initialize spi peripheral
void InitializeSpi() {
spi_bus_config_t buscfg = {};
buscfg.mosi_io_num = LCD_MOSI_PIN;
buscfg.miso_io_num = LCD_MISO_PIN;
buscfg.sclk_io_num = LCD_SCLK_PIN;
buscfg.quadwp_io_num = GPIO_NUM_NC;
buscfg.quadhd_io_num = GPIO_NUM_NC;
buscfg.max_transfer_sz = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t);
ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO));
}
void InitializeSt7789Display() {
esp_lcd_panel_io_handle_t panel_io = nullptr;
esp_lcd_panel_handle_t panel = nullptr;
ESP_LOGD(TAG, "Install panel IO");
// 液晶屏控制IO初始化
esp_lcd_panel_io_spi_config_t io_config = {};
io_config.cs_gpio_num = LCD_CS_PIN;
io_config.dc_gpio_num = LCD_DC_PIN;
io_config.spi_mode = 0;
io_config.pclk_hz = 60 * 1000 * 1000;
io_config.trans_queue_depth = 7;
io_config.lcd_cmd_bits = 8;
io_config.lcd_param_bits = 8;
esp_lcd_new_panel_io_spi(SPI2_HOST, &io_config, &panel_io);
// 初始化液晶屏驱动芯片ST7789
ESP_LOGD(TAG, "Install LCD driver");
esp_lcd_panel_dev_config_t panel_config = {};
panel_config.reset_gpio_num = GPIO_NUM_NC;
panel_config.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB;
panel_config.bits_per_pixel = 16;
panel_config.data_endian = LCD_RGB_DATA_ENDIAN_BIG,
esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel);
esp_lcd_panel_reset(panel);
esp_lcd_panel_init(panel);
esp_lcd_panel_invert_color(panel, DISPLAY_BACKLIGHT_OUTPUT_INVERT);
esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY);
esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y);
display_ = new SpiLcdDisplay(panel_io, panel,
DISPLAY_WIDTH, DISPLAY_HEIGHT, DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY);
}
void InitializeButtons() {
instance_ = this;
button_config_t bo_btn_cfg = {
.long_press_time = 800,
.short_press_time = 500
};
button_config_t k1_btn_cfg = {
.long_press_time = 800,
.short_press_time = 500
};
button_config_t k2_btn_cfg = {
.long_press_time = 800,
.short_press_time = 500
};
button_driver_t* xio_k1_btn_driver_ = nullptr;
button_driver_t* xio_k2_btn_driver_ = nullptr;
button_handle_t bo_btn_handle = NULL;
button_handle_t k1_btn_handle = NULL;
button_handle_t k2_btn_handle = NULL;
xio_k1_btn_driver_ = (button_driver_t*)calloc(1, sizeof(button_driver_t));
xio_k1_btn_driver_->enable_power_save = false;
xio_k1_btn_driver_->get_key_level = [](button_driver_t *button_driver) -> uint8_t {
return !instance_->IoExpanderGetLevel(XIO_KEY_K1);
};
ESP_ERROR_CHECK(iot_button_create(&k1_btn_cfg, xio_k1_btn_driver_, &k1_btn_handle));
xio_k2_btn_driver_ = (button_driver_t*)calloc(1, sizeof(button_driver_t));
xio_k2_btn_driver_->enable_power_save = false;
xio_k2_btn_driver_->get_key_level = [](button_driver_t *button_driver) -> uint8_t {
return instance_->IoExpanderGetLevel(XIO_KEY_K2);
};
ESP_ERROR_CHECK(iot_button_create(&k2_btn_cfg, xio_k2_btn_driver_, &k2_btn_handle));
button_gpio_config_t bo_cfg = {
.gpio_num = BOOT_BUTTON_GPIO,
.active_level = BUTTON_INACTIVE,
.enable_power_save = false,
.disable_pull = false
};
ESP_ERROR_CHECK(iot_button_new_gpio_device(&bo_btn_cfg, &bo_cfg, &bo_btn_handle));
iot_button_register_cb(k1_btn_handle, BUTTON_PRESS_DOWN, nullptr, [](void* button_handle, void* usr_data) {
auto self = static_cast<atk_dnesp32s3_box3*>(usr_data);
self->power_save_timer_->WakeUp();
self->audio_volume_change(false);
}, this);
iot_button_register_cb(k1_btn_handle, BUTTON_LONG_PRESS_START, nullptr, [](void* button_handle, void* usr_data) {
auto self = static_cast<atk_dnesp32s3_box3*>(usr_data);
self->power_save_timer_->WakeUp();
self->audio_volume_minimum();
}, this);
iot_button_register_cb(k2_btn_handle, BUTTON_PRESS_DOWN, nullptr, [](void* button_handle, void* usr_data) {
auto self = static_cast<atk_dnesp32s3_box3*>(usr_data);
self->power_save_timer_->WakeUp();
auto& app = Application::GetInstance();
app.ToggleChatState();
}, this);
iot_button_register_cb(k2_btn_handle, BUTTON_LONG_PRESS_START, nullptr, [](void* button_handle, void* usr_data) {
auto self = static_cast<atk_dnesp32s3_box3*>(usr_data);
auto& app = Application::GetInstance();
if (app.GetDeviceState() == kDeviceStateStarting) {
self->EnterWifiConfigMode();
return;
}
if (self->power_status_ == kDeviceBatterySupply) {
auto backlight = Board::GetInstance().GetBacklight();
backlight->SetBrightness(0);
esp_timer_stop(self->power_manager_->timer_handle_);
esp_io_expander_set_dir(self->io_exp_handle, XIO_BAT_CHRG_EN, IO_EXPANDER_OUTPUT);
esp_io_expander_set_level(self->io_exp_handle, XIO_BAT_CHRG_EN, 0);
vTaskDelay(pdMS_TO_TICKS(100));
esp_io_expander_set_level(self->io_exp_handle, XIO_VDD_3V3_EN, 0);
vTaskDelay(pdMS_TO_TICKS(100));
}
}, this);
iot_button_register_cb(bo_btn_handle, BUTTON_PRESS_DOWN, nullptr, [](void* button_handle, void* usr_data) {
auto self = static_cast<atk_dnesp32s3_box3*>(usr_data);
self->power_save_timer_->WakeUp();
self->audio_volume_change(true);
}, this);
iot_button_register_cb(bo_btn_handle, BUTTON_LONG_PRESS_START, nullptr, [](void* button_handle, void* usr_data) {
auto self = static_cast<atk_dnesp32s3_box3*>(usr_data);
self->power_save_timer_->WakeUp();
self->audio_volume_maxmum();
}, this);
}
/* 初始化摄像头GC0308 */
/* 根据正点原子官方示例参数 */
void InitializeCamera() {
esp_io_expander_set_level(io_exp_handle, XIO_TP_CAM_RESET, 0); /* 确保复位 */
vTaskDelay(pdMS_TO_TICKS(50)); /* 延长复位保持时间 */
esp_io_expander_set_level(io_exp_handle, XIO_TP_CAM_RESET, 1); /* 释放复位 */
vTaskDelay(pdMS_TO_TICKS(50)); /* 延长 50ms */
/* DVP pin configuration */
static esp_cam_ctlr_dvp_pin_config_t dvp_pin_config = {
.data_width = CAM_CTLR_DATA_WIDTH_8,
.data_io = {
[0] = CAM_PIN_D0,
[1] = CAM_PIN_D1,
[2] = CAM_PIN_D2,
[3] = CAM_PIN_D3,
[4] = CAM_PIN_D4,
[5] = CAM_PIN_D5,
[6] = CAM_PIN_D6,
[7] = CAM_PIN_D7,
},
.vsync_io = CAM_PIN_VSYNC,
.de_io = CAM_PIN_LREF,
.pclk_io = CAM_PIN_PCLK,
.xclk_io = CAM_PIN_XCLK,
};
/* 复用 I2C 总线 */
esp_video_init_sccb_config_t sccb_config = {
.init_sccb = false, /* 不初始化新的 SCCB使用现有的 I2C 总线 */
.i2c_handle = i2c_bus_, /* 使用现有的 I2C 总线句柄 */
.freq = 100000, /* SCCB 通信频率,通常为 100kHz */
};
esp_video_init_dvp_config_t dvp_config = {
.sccb_config = sccb_config,
.reset_pin = CAM_PIN_RESET,
.pwdn_pin = CAM_PIN_PWDN,
.dvp_pin = dvp_pin_config,
.xclk_freq = 24000000,
};
esp_video_init_config_t video_config = {
.dvp = &dvp_config,
};
camera_ = new EspVideo(video_config);
}
public:
atk_dnesp32s3_box3(){
InitializeI2c();
InitializeSpi();
InitializeIoExpander();
InitializePowerSaveTimer();
//InitializePowerManager();
InitializeSt7789Display();
InitializeButtons();
//GetBacklight()->RestoreBrightness();
//InitializeBoardPowerManager();
InitializeCamera();
}
virtual AudioCodec* GetAudioCodec() override {
static BoxAudioCodec audio_codec(
i2c_bus_,
AUDIO_INPUT_SAMPLE_RATE,
AUDIO_OUTPUT_SAMPLE_RATE,
AUDIO_I2S_GPIO_MCLK,
AUDIO_I2S_GPIO_BCLK,
AUDIO_I2S_GPIO_WS,
AUDIO_I2S_GPIO_DOUT,
AUDIO_I2S_GPIO_DIN,
AUDIO_CODEC_PA_PIN,
AUDIO_CODEC_ES8311_ADDR,
AUDIO_CODEC_ES7210_ADDR,
AUDIO_INPUT_REFERENCE);
return &audio_codec;
}
virtual Display* GetDisplay() override {
return display_;
}
// virtual Backlight* GetBacklight() override {
// static PwmBacklight backlight(GPIO_NUM_0, DISPLAY_BACKLIGHT_OUTPUT_INVERT);
// return &backlight;
// }
// virtual bool GetBatteryLevel(int& level, bool& charging, bool& discharging) override {
// static bool last_discharging = false;
// charging = power_manager_->IsCharging();
// discharging = power_manager_->IsDischarging();
// if (discharging != last_discharging) {
// power_save_timer_->SetEnabled(discharging);
// last_discharging = discharging;
// }
// level = power_manager_->GetBatteryLevel();
// return true;
// }
virtual void SetPowerSaveLevel(PowerSaveLevel level) override {
if (level != PowerSaveLevel::LOW_POWER) {
power_save_timer_->WakeUp();
}
WifiBoard::SetPowerSaveLevel(level);
}
virtual Camera* GetCamera() override {
return camera_;
}
};
DECLARE_BOARD(atk_dnesp32s3_box3);
// 定义静态成员变量
atk_dnesp32s3_box3* atk_dnesp32s3_box3::instance_ = nullptr;

View File

@ -0,0 +1,94 @@
#ifndef _BOARD_CONFIG_H_
#define _BOARD_CONFIG_H_
#include <driver/gpio.h>
enum PowerSupply {
kDeviceTypecSupply,
kDeviceBatterySupply,
};
#define AUDIO_INPUT_SAMPLE_RATE 24000
#define AUDIO_OUTPUT_SAMPLE_RATE 24000
#define AUDIO_INPUT_REFERENCE true
#define AUDIO_I2S_GPIO_WS GPIO_NUM_39
#define AUDIO_I2S_GPIO_BCLK GPIO_NUM_38
#define AUDIO_I2S_GPIO_DIN GPIO_NUM_41
#define AUDIO_I2S_GPIO_DOUT GPIO_NUM_40
#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_21
#define AUDIO_CODEC_PA_PIN GPIO_NUM_NC
#define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_3
#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_2
#define AUDIO_CODEC_ES8311_ADDR ES8311_CODEC_DEFAULT_ADDR
#define AUDIO_CODEC_ES7210_ADDR ES7210_CODEC_DEFAULT_ADDR
#define BUILTIN_LED_GPIO GPIO_NUM_NC
#define BOOT_BUTTON_GPIO GPIO_NUM_0
#define DISPLAY_OFFSET_X 0
#define DISPLAY_OFFSET_Y 0
#define DISPLAY_WIDTH 320
#define DISPLAY_HEIGHT 240
#define DISPLAY_SWAP_XY true
#define DISPLAY_MIRROR_X true
#define DISPLAY_MIRROR_Y false
#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_NC
#define DISPLAY_BACKLIGHT_OUTPUT_INVERT true
// Pin Definitions
#define LCD_SCLK_PIN GPIO_NUM_15
#define LCD_MOSI_PIN GPIO_NUM_16
#define LCD_MISO_PIN GPIO_NUM_17
#define LCD_DC_PIN GPIO_NUM_48
#define LCD_CS_PIN GPIO_NUM_47
/* IO扩展 */
#define AW9523B_ADDR 0x59
#define AW9523B_INT_GPIO GPIO_NUM_42
#define XIO_KEY_K1 (IO_EXPANDER_PIN_NUM_0)
#define XIO_KEY_K2 (IO_EXPANDER_PIN_NUM_1)
#define XIO_BAT_CHRG_EN (IO_EXPANDER_PIN_NUM_2)
#define XIO_BAT_CHRG (IO_EXPANDER_PIN_NUM_3)
#define XIO_ESP_ADC_SEL (IO_EXPANDER_PIN_NUM_4)
#define XIO_PA_CTRL (IO_EXPANDER_PIN_NUM_5)
#define XIO_EXT_GPIO0 (IO_EXPANDER_PIN_NUM_6)
#define XIO_EXT_GPIO1 (IO_EXPANDER_PIN_NUM_7)
#define XIO_LCD_BL (IO_EXPANDER_PIN_NUM_8)
#define XIO_LED_RED (IO_EXPANDER_PIN_NUM_9)
#define XIO_LED_BLUE (IO_EXPANDER_PIN_NUM_10)
#define XIO_VDD_3V3_EN (IO_EXPANDER_PIN_NUM_11)
#define XIO_VBAT_EN (IO_EXPANDER_PIN_NUM_12)
#define XIO_VDDA_3V3_EN (IO_EXPANDER_PIN_NUM_13)
#define XIO_VDD_2V8_EN (IO_EXPANDER_PIN_NUM_14)
#define XIO_TP_CAM_RESET (IO_EXPANDER_PIN_NUM_15)
#define DRV_IO_EXP_OUTPUT_MASK 0XFFFC
#define DRV_IO_EXP_INPUT_MASK 0x0003
/* 相机引脚配置 */
#define CAM_PIN_PWDN GPIO_NUM_NC
#define CAM_PIN_RESET GPIO_NUM_NC
#define CAM_PIN_VSYNC GPIO_NUM_6
#define CAM_PIN_LREF GPIO_NUM_46
#define CAM_PIN_PCLK GPIO_NUM_45
#define CAM_PIN_XCLK GPIO_NUM_NC
#define CAM_PIN_SIOD GPIO_NUM_NC
#define CAM_PIN_SIOC GPIO_NUM_NC
#define CAM_PIN_D0 GPIO_NUM_7
#define CAM_PIN_D1 GPIO_NUM_8
#define CAM_PIN_D2 GPIO_NUM_9
#define CAM_PIN_D3 GPIO_NUM_10
#define CAM_PIN_D4 GPIO_NUM_11
#define CAM_PIN_D5 GPIO_NUM_12
#define CAM_PIN_D6 GPIO_NUM_4
#define CAM_PIN_D7 GPIO_NUM_5
#define CAM_2V8_EN 14
#define CAM_RST 15
#endif // _BOARD_CONFIG_H_

View File

@ -0,0 +1,9 @@
{
"target": "esp32s3",
"builds": [
{
"name": "atk-dnesp32s3-box3",
"sdkconfig_append": []
}
]
}

View File

@ -0,0 +1,195 @@
#pragma once
#include <vector>
#include <functional>
#include "esp_io_expander_tca95xx_16bit.h"
#include <esp_timer.h>
#include <driver/gpio.h>
#include <esp_adc/adc_oneshot.h>
class PowerManager {
private:
std::function<void(bool)> on_charging_status_changed_;
std::function<void(bool)> on_low_battery_status_changed_;
esp_io_expander_handle_t aw9523b_;
uint32_t pin_val = 0;
gpio_num_t charging_pin_ = GPIO_NUM_NC;
std::vector<uint16_t> adc_values_;
uint32_t battery_level_ = 0;
bool is_charging_ = false;
bool is_low_battery_ = false;
int ticks_ = 0;
const int kBatteryAdcInterval = 60;
const int kBatteryAdcDataCount = 3;
const int kLowBatteryLevel = 20;
adc_oneshot_unit_handle_t adc_handle_;
void CheckBatteryStatus() {
// Get charging status
esp_io_expander_get_level(aw9523b_, DRV_IO_EXP_INPUT_MASK, &pin_val);
bool new_charging_status = ((uint8_t)((pin_val & XIO_BAT_CHRG) ? 1 : 0)) == 0;
if (new_charging_status != is_charging_) {
is_charging_ = new_charging_status;
if (on_charging_status_changed_) {
on_charging_status_changed_(is_charging_);
}
ReadBatteryAdcData();
return;
}
// 如果电池电量数据不足,则读取电池电量数据
if (adc_values_.size() < kBatteryAdcDataCount) {
ReadBatteryAdcData();
return;
}
// 如果电池电量数据充足,则每 kBatteryAdcInterval 个 tick 读取一次电池电量数据
ticks_++;
if (ticks_ % kBatteryAdcInterval == 0) {
ReadBatteryAdcData();
}
}
void ReadBatteryAdcData() {
int adc_value;
uint32_t temp_val = 0;
esp_io_expander_set_dir(aw9523b_, XIO_BAT_CHRG_EN, IO_EXPANDER_OUTPUT);
esp_io_expander_set_level(aw9523b_, XIO_BAT_CHRG_EN, 0);
vTaskDelay(pdMS_TO_TICKS(500));
for(int t = 0; t < 10; t ++) {
ESP_ERROR_CHECK(adc_oneshot_read(adc_handle_, ADC_CHANNEL_0, &adc_value));
temp_val += adc_value;
}
esp_io_expander_set_dir(aw9523b_, XIO_BAT_CHRG_EN, IO_EXPANDER_INPUT);
adc_value = temp_val / 10;
// 将 ADC 值添加到队列中
adc_values_.push_back(adc_value);
if (adc_values_.size() > kBatteryAdcDataCount) {
adc_values_.erase(adc_values_.begin());
}
uint32_t average_adc = 0;
for (auto value : adc_values_) {
average_adc += value;
}
average_adc /= adc_values_.size();
// 定义电池电量区间
const struct {
uint16_t adc;
uint8_t level;
} levels[] = {
{2696, 0}, /* 3.48V -屏幕闪屏 */
{2724, 20}, /* 3.53V */
{2861, 40}, /* 3.7V */
{3038, 60}, /* 3.90V */
{3150, 80}, /* 4.02V */
{3280, 100} /* 4.14V */
};
// 低于最低值时
if (average_adc < levels[0].adc) {
battery_level_ = 0;
}
// 高于最高值时
else if (average_adc >= levels[5].adc) {
battery_level_ = 100;
} else {
// 线性插值计算中间值
for (int i = 0; i < 5; i++) {
if (average_adc >= levels[i].adc && average_adc < levels[i+1].adc) {
float ratio = static_cast<float>(average_adc - levels[i].adc) / (levels[i+1].adc - levels[i].adc);
battery_level_ = levels[i].level + ratio * (levels[i+1].level - levels[i].level);
break;
}
}
}
// Check low battery status
if (adc_values_.size() >= kBatteryAdcDataCount) {
bool new_low_battery_status = battery_level_ <= kLowBatteryLevel;
if (new_low_battery_status != is_low_battery_) {
is_low_battery_ = new_low_battery_status;
if (on_low_battery_status_changed_) {
on_low_battery_status_changed_(is_low_battery_);
}
}
}
low_voltage_ = adc_value;
ESP_LOGI("PowerManager", "ADC value: %d average: %ld level: %ld", adc_value, average_adc, battery_level_);
}
public:
esp_timer_handle_t timer_handle_;
uint16_t low_voltage_ = 2630;
PowerManager(esp_io_expander_handle_t aw9523b) : aw9523b_(aw9523b) {
// 创建电池电量检查定时器
esp_timer_create_args_t timer_args = {
.callback = [](void* arg) {
PowerManager* self = static_cast<PowerManager*>(arg);
self->CheckBatteryStatus();
},
.arg = this,
.dispatch_method = ESP_TIMER_TASK,
.name = "battery_check_timer",
.skip_unhandled_events = true,
};
ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer_handle_));
ESP_ERROR_CHECK(esp_timer_start_periodic(timer_handle_, 1000000));
// 初始化 ADC
adc_oneshot_unit_init_cfg_t init_config = {
.unit_id = ADC_UNIT_1,
.ulp_mode = ADC_ULP_MODE_DISABLE,
};
ESP_ERROR_CHECK(adc_oneshot_new_unit(&init_config, &adc_handle_));
adc_oneshot_chan_cfg_t chan_config = {
.atten = ADC_ATTEN_DB_12,
.bitwidth = ADC_BITWIDTH_12,
};
ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle_, ADC_CHANNEL_0, &chan_config));
}
~PowerManager() {
if (timer_handle_) {
esp_timer_stop(timer_handle_);
esp_timer_delete(timer_handle_);
}
if (adc_handle_) {
adc_oneshot_del_unit(adc_handle_);
}
}
bool IsCharging() {
// 如果电量已经满了,则不再显示充电中
if (battery_level_ == 100) {
return false;
}
return is_charging_;
}
bool IsDischarging() {
// 没有区分充电和放电,所以直接返回相反状态
return !is_charging_;
}
uint8_t GetBatteryLevel() {
return battery_level_;
}
void OnLowBatteryStatusChanged(std::function<void(bool)> callback) {
on_low_battery_status_changed_ = callback;
}
void OnChargingStatusChanged(std::function<void(bool)> callback) {
on_charging_status_changed_ = callback;
}
};

View File

@ -106,6 +106,10 @@ private:
InitializeGc9107Display();
InitializeButtons();
GetBacklight()->SetBrightness(100);
// Ensure UI is set up before displaying error
display_->SetupUI();
display_->SetStatus(Lang::Strings::ERROR);
display_->SetEmotion("triangle_exclamation");
display_->SetChatMessage("system", "Echo Base\nnot connected");

View File

@ -173,6 +173,10 @@ private:
InitializeGc9107Display();
InitializeButtons();
GetBacklight()->SetBrightness(100);
// Ensure UI is set up before displaying error
display_->SetupUI();
display_->SetStatus(Lang::Strings::ERROR);
display_->SetEmotion("triangle_exclamation");
display_->SetChatMessage("system", "Echo Base\nnot connected");

View File

@ -0,0 +1,51 @@
# 编译配置命令
**配置编译目标为 ESP32S3**
```bash
idf.py set-target esp32s3
```
**打开 menuconfig**
```bash
idf.py menuconfig
```
**选择板子:**
```
Xiaozhi Assistant -> Board Type -> M5Stack AtomS3R + Echo Pyramid
```
**修改 flash 大小:**
```
Serial flasher config -> Flash size -> 8 MB
```
**修改分区表:**
```
Partition Table -> Custom partition CSV file -> partitions/v2/8m.csv
```
**修改 psram 配置:**
```
Component config -> ESP PSRAM -> SPI RAM config -> Mode (QUAD/OCT) -> Octal Mode PSRAM
```
**编译:**
```bash
idf.py build
```
## 使用说明
Echo Pyramid 正常运行时请从 Pyramid 底座的 USB-C 口供电AtomS3R 的 USB-C 口主要用于烧录。
# 参考资料
https://github.com/m5stack/M5Echo-Pyramid

View File

@ -0,0 +1,763 @@
#include "wifi_board.h"
#include "display/lcd_display.h"
#include "application.h"
#include "button.h"
#include "config.h"
#include "i2c_device.h"
#include "audio_codec.h"
#include "led/led.h"
#include "assets/lang_config.h"
#include <esp_log.h>
#include <driver/i2c_master.h>
#include <driver/i2s_std.h>
#include <esp_codec_dev.h>
#include <esp_codec_dev_defaults.h>
#include <esp_lcd_panel_io.h>
#include <esp_lcd_panel_ops.h>
#include <esp_lcd_gc9a01.h>
#include <mutex>
#define TAG "AtomS3R+EchoPyramid"
#define PYRAMID_SI5351_ADDR 0x60
#define PYRAMID_STM32_ADDR 0x1A
#define PYRAMID_AW87559_ADDR 0x5B
#define PYRAMID_POWER_ON_RETRY_COUNT 20
#define PYRAMID_POWER_ON_RETRY_DELAY_MS 250
#define STM32_SPK_RESTART_REG_ADDR 0xA0
#define STM32_RGB1_BRIGHTNESS_REG_ADDR 0x10
#define STM32_RGB2_BRIGHTNESS_REG_ADDR 0x11
#define STM32_RGB1_STATUS_REG_ADDR 0x20
#define STM32_RGB2_STATUS_REG_ADDR 0x60
#define STM32_RGB_NUM_MAX 13
#define AW87559_REG_ID 0x00
#define AW87559_REG_SYSCTRL 0x01
#define AW87559_REG_PAGR 0x06
#define AW87559_ID 0x5A
#define AW87559_SYS_EN_SW_MASK (1 << 6)
#define AW87559_SYS_EN_BOOST_MASK (1 << 4)
#define AW87559_SYS_EN_PA_MASK (1 << 3)
#define AW87559_GAIN_16_5DB 11
class Si5351 : public I2cDevice {
public:
Si5351(i2c_master_bus_handle_t i2c_bus, uint8_t addr) : I2cDevice(i2c_bus, addr) {
WriteReg(3, 0xFF); // Disable all clock outputs.
WriteReg(16, 0x80);
WriteReg(17, 0x80);
WriteReg(18, 0x80);
WriteReg(183, 0xC0); // Crystal load capacitance: 10 pF.
}
void SetMclk(uint32_t sample_rate) {
if (sample_rate == 24000) {
SetPll(884736000UL, 144); // 884.736 MHz / 144 = 6.144 MHz
} else if (sample_rate == 16000) {
SetPll(884736000UL, 216); // 4.096 MHz
} else if (sample_rate == 44100) {
SetPll(903168000UL, 80); // 11.2896 MHz
} else if (sample_rate == 48000) {
SetPll(884736000UL, 72); // 12.288 MHz
} else {
ESP_LOGW(TAG, "Unsupported Si5351 sample rate: %lu", static_cast<unsigned long>(sample_rate));
}
}
private:
static constexpr uint32_t kXtalFreq = 27000000UL;
void WriteRegs(uint8_t reg, const uint8_t* data, size_t length) {
uint8_t buffer[9] = {};
buffer[0] = reg;
for (size_t i = 0; i < length; ++i) {
buffer[i + 1] = data[i];
}
ESP_ERROR_CHECK(i2c_master_transmit(i2c_device_, buffer, length + 1, 100));
}
void SetPll(uint32_t pll_freq, uint32_t ms_div) {
uint32_t a = pll_freq / kXtalFreq;
uint32_t rest = pll_freq % kXtalFreq;
uint32_t c = 1000000UL;
uint32_t b = (rest * c) / kXtalFreq;
uint32_t p1 = 128 * a + (128 * b) / c - 512;
uint32_t p2 = 128 * b - c * ((128 * b) / c);
uint32_t p3 = c;
WriteReg(3, 0xFF);
uint8_t pll_buf[8] = {
static_cast<uint8_t>((p3 >> 8) & 0xFF),
static_cast<uint8_t>(p3 & 0xFF),
static_cast<uint8_t>((p1 >> 16) & 0x03),
static_cast<uint8_t>((p1 >> 8) & 0xFF),
static_cast<uint8_t>(p1 & 0xFF),
static_cast<uint8_t>(((p3 >> 12) & 0xF0) | ((p2 >> 16) & 0x0F)),
static_cast<uint8_t>((p2 >> 8) & 0xFF),
static_cast<uint8_t>(p2 & 0xFF),
};
WriteRegs(26, pll_buf, sizeof(pll_buf));
uint32_t ms_p1 = 128 * ms_div - 512;
uint8_t ms_buf[8] = {
0x00,
0x01,
static_cast<uint8_t>((ms_p1 >> 16) & 0x03),
static_cast<uint8_t>((ms_p1 >> 8) & 0xFF),
static_cast<uint8_t>(ms_p1 & 0xFF),
0x00,
0x00,
0x00,
};
WriteRegs(50, ms_buf, sizeof(ms_buf)); // Multisynth1 -> CLK1
WriteReg(17, 0x4F); // CLK1 from PLLA, 8 mA drive.
WriteReg(16, 0x80);
WriteReg(18, 0x80);
WriteReg(177, 0xA0); // Reset PLLA.
vTaskDelay(pdMS_TO_TICKS(10));
WriteReg(3, 0xFD); // Enable CLK1 only.
ESP_LOGI(TAG, "Si5351 CLK1 set to %lu Hz", static_cast<unsigned long>(pll_freq / ms_div));
}
};
class Aw87559 : public I2cDevice {
public:
Aw87559(i2c_master_bus_handle_t i2c_bus, uint8_t addr) : I2cDevice(i2c_bus, addr) {
auto id = ReadReg(AW87559_REG_ID);
if (id != AW87559_ID) {
ESP_LOGW(TAG, "Unexpected AW87559 ID: 0x%02x", id);
}
UpdateBits(AW87559_REG_SYSCTRL, AW87559_SYS_EN_SW_MASK, AW87559_SYS_EN_SW_MASK);
UpdateBits(AW87559_REG_SYSCTRL, AW87559_SYS_EN_BOOST_MASK, AW87559_SYS_EN_BOOST_MASK);
UpdateBits(AW87559_REG_SYSCTRL, AW87559_SYS_EN_PA_MASK, AW87559_SYS_EN_PA_MASK);
UpdateBits(AW87559_REG_PAGR, 0x1F, AW87559_GAIN_16_5DB);
}
private:
void UpdateBits(uint8_t reg, uint8_t mask, uint8_t value) {
auto reg_value = ReadReg(reg);
reg_value &= ~mask;
reg_value |= value & mask;
WriteReg(reg, reg_value);
}
};
class Stm32PyramidCtrl : public I2cDevice {
public:
Stm32PyramidCtrl(i2c_master_bus_handle_t i2c_bus, uint8_t addr) : I2cDevice(i2c_bus, addr) {
ResetSpeaker();
SetBrightness(1, 100);
SetBrightness(2, 100);
SetAllRgb(1, 0, 0, 64);
SetAllRgb(2, 0, 0, 64);
}
void ResetSpeaker() {
WriteReg(STM32_SPK_RESTART_REG_ADDR, 1);
vTaskDelay(pdMS_TO_TICKS(100));
}
void SetBrightness(uint8_t channel, uint8_t brightness) {
if (brightness > 100) {
brightness = 100;
}
WriteReg(channel == 1 ? STM32_RGB1_BRIGHTNESS_REG_ADDR : STM32_RGB2_BRIGHTNESS_REG_ADDR, brightness);
}
void SetAllRgb(uint8_t channel, uint8_t r, uint8_t g, uint8_t b) {
const uint8_t base = (channel == 1) ? STM32_RGB1_STATUS_REG_ADDR : STM32_RGB2_STATUS_REG_ADDR;
// Echo Pyramid STM32 uses 4-byte stride per LED: (B,G,R,0x00).
// One page is 0x10 bytes and contains 4 LEDs.
for (int page = 0; page < 4; ++page) {
uint8_t reg = base + static_cast<uint8_t>(page * 0x10);
uint8_t payload[1 + 16] = {0};
payload[0] = reg;
for (int i = 0; i < 4; ++i) {
payload[1 + i * 4 + 0] = b;
payload[1 + i * 4 + 1] = g;
payload[1 + i * 4 + 2] = r;
payload[1 + i * 4 + 3] = 0x00;
}
ESP_ERROR_CHECK(i2c_master_transmit(i2c_device_, payload, sizeof(payload), 100));
}
}
void SetStatusColor(uint8_t r, uint8_t g, uint8_t b) {
SetAllRgb(1, r, g, b);
SetAllRgb(2, r, g, b);
}
};
class PyramidStatusLed : public Led {
public:
void SetController(Stm32PyramidCtrl* ctrl) { ctrl_ = ctrl; }
void OnStateChanged() override {
if (ctrl_ == nullptr) {
return;
}
auto& app = Application::GetInstance();
switch (app.GetDeviceState()) {
case kDeviceStateListening:
ctrl_->SetStatusColor(0, 64, 0); // green
break;
case kDeviceStateSpeaking:
ctrl_->SetStatusColor(64, 0, 0); // red
break;
default:
ctrl_->SetStatusColor(0, 0, 64); // blue
break;
}
}
private:
Stm32PyramidCtrl* ctrl_ = nullptr;
};
class Lp5562 : public I2cDevice {
public:
Lp5562(i2c_master_bus_handle_t i2c_bus, uint8_t addr) : I2cDevice(i2c_bus, addr) {
WriteReg(0x00, 0B01000000); // Set chip_en to 1
WriteReg(0x08, 0B00000001); // Enable internal clock
WriteReg(0x70, 0B00000000); // Configure all LED outputs to be controlled from I2C registers
// PWM clock frequency 558 Hz
auto data = ReadReg(0x08);
data = data | 0B01000000;
WriteReg(0x08, data);
}
void SetBrightness(uint8_t brightness) {
// Map 0~100 to 0~255
brightness = brightness * 255 / 100;
WriteReg(0x0E, brightness);
}
};
class CustomBacklight : public Backlight {
public:
CustomBacklight(Lp5562* lp5562) : lp5562_(lp5562) {}
void SetBrightnessImpl(uint8_t brightness) override {
if (lp5562_) {
lp5562_->SetBrightness(brightness);
} else {
ESP_LOGE(TAG, "LP5562 not available");
}
}
private:
Lp5562* lp5562_ = nullptr;
};
class PyramidAudioCodec : public AudioCodec {
public:
PyramidAudioCodec(void* i2c_master_handle, i2c_port_t i2c_port, int input_sample_rate, int output_sample_rate,
gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din,
gpio_num_t pa_pin, uint8_t es8311_addr, uint8_t es7210_addr, bool input_reference) {
duplex_ = true;
input_reference_ = input_reference;
input_channels_ = input_reference_ ? 2 : 1;
output_channels_ = 1;
input_sample_rate_ = input_sample_rate;
output_sample_rate_ = output_sample_rate;
input_gain_ = 30;
pa_pin_ = pa_pin;
CreateDuplexChannels(mclk, bclk, ws, dout, din);
audio_codec_i2s_cfg_t i2s_cfg = {
.port = I2S_NUM_0,
.rx_handle = rx_handle_,
.tx_handle = tx_handle_,
};
data_if_ = audio_codec_new_i2s_data(&i2s_cfg);
assert(data_if_ != NULL);
audio_codec_i2c_cfg_t i2c_cfg = {
.port = i2c_port,
.addr = es8311_addr,
.bus_handle = i2c_master_handle,
};
out_ctrl_if_ = audio_codec_new_i2c_ctrl(&i2c_cfg);
assert(out_ctrl_if_ != NULL);
gpio_if_ = audio_codec_new_gpio();
assert(gpio_if_ != NULL);
es8311_codec_cfg_t es8311_cfg = {};
es8311_cfg.ctrl_if = out_ctrl_if_;
es8311_cfg.gpio_if = gpio_if_;
es8311_cfg.codec_mode = ESP_CODEC_DEV_WORK_MODE_DAC;
es8311_cfg.pa_pin = pa_pin_;
es8311_cfg.use_mclk = true;
es8311_cfg.hw_gain.pa_voltage = 5.0;
es8311_cfg.hw_gain.codec_dac_voltage = 3.3;
out_codec_if_ = es8311_codec_new(&es8311_cfg);
assert(out_codec_if_ != NULL);
esp_codec_dev_cfg_t dev_cfg = {
.dev_type = ESP_CODEC_DEV_TYPE_OUT,
.codec_if = out_codec_if_,
.data_if = data_if_,
};
output_dev_ = esp_codec_dev_new(&dev_cfg);
assert(output_dev_ != NULL);
i2c_cfg.addr = es7210_addr;
in_ctrl_if_ = audio_codec_new_i2c_ctrl(&i2c_cfg);
assert(in_ctrl_if_ != NULL);
es7210_codec_cfg_t es7210_cfg = {};
es7210_cfg.ctrl_if = in_ctrl_if_;
es7210_cfg.mic_selected = ES7210_SEL_MIC1 | ES7210_SEL_MIC3;
in_codec_if_ = es7210_codec_new(&es7210_cfg);
assert(in_codec_if_ != NULL);
dev_cfg.dev_type = ESP_CODEC_DEV_TYPE_IN;
dev_cfg.codec_if = in_codec_if_;
input_dev_ = esp_codec_dev_new(&dev_cfg);
assert(input_dev_ != NULL);
ESP_LOGI(TAG, "Pyramid audio codec initialized");
}
virtual ~PyramidAudioCodec() {
if (output_dev_) {
esp_codec_dev_close(output_dev_);
esp_codec_dev_delete(output_dev_);
}
if (input_dev_) {
esp_codec_dev_close(input_dev_);
esp_codec_dev_delete(input_dev_);
}
audio_codec_delete_codec_if(in_codec_if_);
audio_codec_delete_ctrl_if(in_ctrl_if_);
audio_codec_delete_codec_if(out_codec_if_);
audio_codec_delete_ctrl_if(out_ctrl_if_);
audio_codec_delete_gpio_if(gpio_if_);
audio_codec_delete_data_if(data_if_);
}
void SetOutputVolume(int volume) override {
std::lock_guard<std::mutex> lock(data_if_mutex_);
if (output_dev_ != nullptr) {
ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, volume));
}
AudioCodec::SetOutputVolume(volume);
}
void EnableInput(bool enable) override {
std::lock_guard<std::mutex> lock(data_if_mutex_);
if (enable == input_enabled_) {
return;
}
if (enable) {
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = 16,
.channel = 2,
.channel_mask = ESP_CODEC_DEV_MAKE_CHANNEL_MASK(0),
.sample_rate = static_cast<uint32_t>(input_sample_rate_),
.mclk_multiple = 0,
};
if (input_reference_) {
fs.channel_mask |= ESP_CODEC_DEV_MAKE_CHANNEL_MASK(1);
}
ESP_ERROR_CHECK(esp_codec_dev_open(input_dev_, &fs));
ESP_ERROR_CHECK(esp_codec_dev_set_in_channel_gain(input_dev_, ESP_CODEC_DEV_MAKE_CHANNEL_MASK(0), input_gain_));
} else {
ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_));
}
AudioCodec::EnableInput(enable);
}
void EnableOutput(bool enable) override {
std::lock_guard<std::mutex> lock(data_if_mutex_);
if (enable == output_enabled_) {
return;
}
if (enable) {
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = 16,
.channel = 1,
.channel_mask = 0,
.sample_rate = static_cast<uint32_t>(output_sample_rate_),
.mclk_multiple = 0,
};
ESP_ERROR_CHECK(esp_codec_dev_open(output_dev_, &fs));
ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, output_volume_));
} else {
ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_));
}
AudioCodec::EnableOutput(enable);
}
private:
const audio_codec_data_if_t* data_if_ = nullptr;
const audio_codec_ctrl_if_t* out_ctrl_if_ = nullptr;
const audio_codec_if_t* out_codec_if_ = nullptr;
const audio_codec_ctrl_if_t* in_ctrl_if_ = nullptr;
const audio_codec_if_t* in_codec_if_ = nullptr;
const audio_codec_gpio_if_t* gpio_if_ = nullptr;
esp_codec_dev_handle_t output_dev_ = nullptr;
esp_codec_dev_handle_t input_dev_ = nullptr;
gpio_num_t pa_pin_ = GPIO_NUM_NC;
std::mutex data_if_mutex_;
void CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din) {
assert(input_sample_rate_ == output_sample_rate_);
i2s_chan_config_t chan_cfg = {
.id = I2S_NUM_0,
.role = I2S_ROLE_MASTER,
.dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM,
.dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM,
.auto_clear_after_cb = true,
.auto_clear_before_cb = false,
.intr_priority = 0,
};
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle_, &rx_handle_));
i2s_std_config_t std_cfg = {
.clk_cfg = {
.sample_rate_hz = static_cast<uint32_t>(output_sample_rate_),
.clk_src = I2S_CLK_SRC_DEFAULT,
.mclk_multiple = I2S_MCLK_MULTIPLE_256,
#ifdef I2S_HW_VERSION_2
.ext_clk_freq_hz = 0,
#endif
},
.slot_cfg = {
.data_bit_width = I2S_DATA_BIT_WIDTH_16BIT,
.slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO,
.slot_mode = I2S_SLOT_MODE_STEREO,
.slot_mask = I2S_STD_SLOT_BOTH,
.ws_width = I2S_DATA_BIT_WIDTH_16BIT,
.ws_pol = false,
.bit_shift = true,
#ifdef I2S_HW_VERSION_2
.left_align = true,
.big_endian = false,
.bit_order_lsb = false,
#endif
},
.gpio_cfg = {
.mclk = mclk,
.bclk = bclk,
.ws = ws,
.dout = dout,
.din = din,
.invert_flags = {
.mclk_inv = false,
.bclk_inv = false,
.ws_inv = false,
},
},
};
ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg));
ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_));
ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_));
ESP_LOGI(TAG, "Pyramid duplex I2S channels created");
}
int Read(int16_t* dest, int samples) override {
if (input_enabled_) {
ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_read(input_dev_, reinterpret_cast<void*>(dest), samples * sizeof(int16_t)));
}
return samples;
}
int Write(const int16_t* data, int samples) override {
if (output_enabled_) {
ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_write(output_dev_, const_cast<int16_t*>(data), samples * sizeof(int16_t)));
}
return samples;
}
};
static const gc9a01_lcd_init_cmd_t gc9107_lcd_init_cmds[] = {
// {cmd, { data }, data_size, delay_ms}
{0xfe, (uint8_t[]){0x00}, 0, 0},
{0xef, (uint8_t[]){0x00}, 0, 0},
{0xb0, (uint8_t[]){0xc0}, 1, 0},
{0xb2, (uint8_t[]){0x2f}, 1, 0},
{0xb3, (uint8_t[]){0x03}, 1, 0},
{0xb6, (uint8_t[]){0x19}, 1, 0},
{0xb7, (uint8_t[]){0x01}, 1, 0},
{0xac, (uint8_t[]){0xcb}, 1, 0},
{0xab, (uint8_t[]){0x0e}, 1, 0},
{0xb4, (uint8_t[]){0x04}, 1, 0},
{0xa8, (uint8_t[]){0x19}, 1, 0},
{0xb8, (uint8_t[]){0x08}, 1, 0},
{0xe8, (uint8_t[]){0x24}, 1, 0},
{0xe9, (uint8_t[]){0x48}, 1, 0},
{0xea, (uint8_t[]){0x22}, 1, 0},
{0xc6, (uint8_t[]){0x30}, 1, 0},
{0xc7, (uint8_t[]){0x18}, 1, 0},
{0xf0,
(uint8_t[]){0x1f, 0x28, 0x04, 0x3e, 0x2a, 0x2e, 0x20, 0x00, 0x0c, 0x06,
0x00, 0x1c, 0x1f, 0x0f},
14, 0},
{0xf1,
(uint8_t[]){0x00, 0x2d, 0x2f, 0x3c, 0x6f, 0x1c, 0x0b, 0x00, 0x00, 0x00,
0x07, 0x0d, 0x11, 0x0f},
14, 0},
};
class AtomS3rEchoPyramidBoard : public WifiBoard {
private:
i2c_master_bus_handle_t i2c_bus_;
i2c_master_bus_handle_t i2c_bus_internal_;
Si5351* si5351_ = nullptr;
Aw87559* aw87559_ = nullptr;
Stm32PyramidCtrl* stm32_ = nullptr;
PyramidStatusLed led_;
Lp5562* lp5562_ = nullptr;
Display* display_ = nullptr;
Button boot_button_;
bool is_pyramid_connected_ = false;
void InitializeI2c() {
i2c_master_bus_config_t i2c_bus_cfg = {
.i2c_port = I2C_NUM_1,
.sda_io_num = AUDIO_CODEC_I2C_SDA_PIN,
.scl_io_num = AUDIO_CODEC_I2C_SCL_PIN,
.clk_source = I2C_CLK_SRC_DEFAULT,
.glitch_ignore_cnt = 7,
.intr_priority = 0,
.trans_queue_depth = 0,
.flags = {
.enable_internal_pullup = 1,
},
};
ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &i2c_bus_));
i2c_bus_cfg.i2c_port = I2C_NUM_0;
i2c_bus_cfg.sda_io_num = GPIO_NUM_45;
i2c_bus_cfg.scl_io_num = GPIO_NUM_0;
ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &i2c_bus_internal_));
}
void I2cDetect() {
is_pyramid_connected_ = false;
bool has_es8311 = false;
bool has_es7210 = false;
bool has_si5351 = false;
bool has_stm32 = false;
bool has_aw87559 = false;
uint8_t address;
printf(" 0 1 2 3 4 5 6 7 8 9 a b c d e f\r\n");
for (int i = 0; i < 128; i += 16) {
printf("%02x: ", i);
for (int j = 0; j < 16; j++) {
fflush(stdout);
address = i + j;
esp_err_t ret = i2c_master_probe(i2c_bus_, address, pdMS_TO_TICKS(200));
if (ret == ESP_OK) {
printf("%02x ", address);
if (address == (AUDIO_CODEC_ES8311_ADDR >> 1)) {
has_es8311 = true;
} else if (address == (AUDIO_CODEC_ES7210_ADDR >> 1)) {
has_es7210 = true;
} else if (address == PYRAMID_SI5351_ADDR) {
has_si5351 = true;
} else if (address == PYRAMID_STM32_ADDR) {
has_stm32 = true;
} else if (address == PYRAMID_AW87559_ADDR) {
has_aw87559 = true;
}
} else if (ret == ESP_ERR_TIMEOUT) {
printf("UU ");
} else {
printf("-- ");
}
}
printf("\r\n");
}
is_pyramid_connected_ = has_es8311 && has_es7210 && has_si5351 && has_stm32 && has_aw87559;
}
void WaitForPyramidConnection() {
for (int attempt = 0; attempt < PYRAMID_POWER_ON_RETRY_COUNT; ++attempt) {
I2cDetect();
if (is_pyramid_connected_) {
if (attempt > 0) {
ESP_LOGI(TAG, "Echo Pyramid detected after %d retries", attempt);
}
return;
}
ESP_LOGW(TAG, "Echo Pyramid not ready, retrying (%d/%d)",
attempt + 1, PYRAMID_POWER_ON_RETRY_COUNT);
vTaskDelay(pdMS_TO_TICKS(PYRAMID_POWER_ON_RETRY_DELAY_MS));
}
}
void CheckPyramidConnection() {
if (is_pyramid_connected_) {
return;
}
InitializeLp5562();
InitializeSpi();
InitializeGc9107Display();
InitializeButtons();
GetBacklight()->SetBrightness(100);
display_->SetupUI();
display_->SetStatus(Lang::Strings::ERROR);
display_->SetEmotion("triangle_exclamation");
display_->SetChatMessage("system", "Echo Pyramid\nnot connected");
while (1) {
ESP_LOGE(TAG, "Echo Pyramid is disconnected");
vTaskDelay(pdMS_TO_TICKS(1000));
I2cDetect();
if (is_pyramid_connected_) {
vTaskDelay(pdMS_TO_TICKS(500));
I2cDetect();
if (is_pyramid_connected_) {
ESP_LOGI(TAG, "Echo Pyramid is reconnected");
vTaskDelay(pdMS_TO_TICKS(200));
esp_restart();
}
}
}
}
void InitializePyramidDevices() {
ESP_LOGI(TAG, "Init Echo Pyramid devices");
si5351_ = new Si5351(i2c_bus_, PYRAMID_SI5351_ADDR);
si5351_->SetMclk(AUDIO_OUTPUT_SAMPLE_RATE);
stm32_ = new Stm32PyramidCtrl(i2c_bus_, PYRAMID_STM32_ADDR);
led_.SetController(stm32_);
led_.OnStateChanged();
aw87559_ = new Aw87559(i2c_bus_, PYRAMID_AW87559_ADDR);
}
void InitializeLp5562() {
ESP_LOGI(TAG, "Init LP5562");
lp5562_ = new Lp5562(i2c_bus_internal_, 0x30);
}
void InitializeSpi() {
ESP_LOGI(TAG, "Initialize SPI bus");
spi_bus_config_t buscfg = {};
buscfg.mosi_io_num = GPIO_NUM_21;
buscfg.miso_io_num = GPIO_NUM_NC;
buscfg.sclk_io_num = GPIO_NUM_15;
buscfg.quadwp_io_num = GPIO_NUM_NC;
buscfg.quadhd_io_num = GPIO_NUM_NC;
buscfg.max_transfer_sz = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t);
ESP_ERROR_CHECK(spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO));
}
void InitializeGc9107Display() {
ESP_LOGI(TAG, "Init GC9107 display");
ESP_LOGI(TAG, "Install panel IO");
esp_lcd_panel_io_handle_t io_handle = NULL;
esp_lcd_panel_io_spi_config_t io_config = {};
io_config.cs_gpio_num = GPIO_NUM_14;
io_config.dc_gpio_num = GPIO_NUM_42;
io_config.spi_mode = 0;
io_config.pclk_hz = 40 * 1000 * 1000;
io_config.trans_queue_depth = 10;
io_config.lcd_cmd_bits = 8;
io_config.lcd_param_bits = 8;
ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI3_HOST, &io_config, &io_handle));
ESP_LOGI(TAG, "Install GC9A01 panel driver");
esp_lcd_panel_handle_t panel_handle = NULL;
gc9a01_vendor_config_t gc9107_vendor_config = {
.init_cmds = gc9107_lcd_init_cmds,
.init_cmds_size = sizeof(gc9107_lcd_init_cmds) / sizeof(gc9a01_lcd_init_cmd_t),
};
esp_lcd_panel_dev_config_t panel_config = {};
panel_config.reset_gpio_num = GPIO_NUM_48;
panel_config.rgb_endian = LCD_RGB_ENDIAN_BGR;
panel_config.bits_per_pixel = 16;
panel_config.vendor_config = &gc9107_vendor_config;
ESP_ERROR_CHECK(esp_lcd_new_panel_gc9a01(io_handle, &panel_config, &panel_handle));
ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle));
ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle));
ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true));
display_ = new SpiLcdDisplay(io_handle, panel_handle,
DISPLAY_WIDTH, DISPLAY_HEIGHT, DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY);
}
void InitializeButtons() {
boot_button_.OnClick([this]() {
auto& app = Application::GetInstance();
if (app.GetDeviceState() == kDeviceStateStarting) {
EnterWifiConfigMode();
return;
}
app.ToggleChatState();
});
}
public:
AtomS3rEchoPyramidBoard() : boot_button_(BOOT_BUTTON_GPIO) {
InitializeI2c();
WaitForPyramidConnection();
CheckPyramidConnection();
InitializePyramidDevices();
InitializeLp5562();
InitializeSpi();
InitializeGc9107Display();
InitializeButtons();
GetBacklight()->RestoreBrightness();
}
virtual Led* GetLed() override {
return &led_;
}
virtual AudioCodec* GetAudioCodec() override {
static PyramidAudioCodec audio_codec(
i2c_bus_,
I2C_NUM_1,
AUDIO_INPUT_SAMPLE_RATE,
AUDIO_OUTPUT_SAMPLE_RATE,
AUDIO_I2S_GPIO_MCLK,
AUDIO_I2S_GPIO_BCLK,
AUDIO_I2S_GPIO_WS,
AUDIO_I2S_GPIO_DOUT,
AUDIO_I2S_GPIO_DIN,
AUDIO_CODEC_GPIO_PA,
AUDIO_CODEC_ES8311_ADDR,
AUDIO_CODEC_ES7210_ADDR,
AUDIO_INPUT_REFERENCE);
return &audio_codec;
}
virtual Display* GetDisplay() override {
return display_;
}
virtual Backlight* GetBacklight() override {
static CustomBacklight backlight(lp5562_);
return &backlight;
}
};
DECLARE_BOARD(AtomS3rEchoPyramidBoard);

View File

@ -0,0 +1,43 @@
#ifndef _BOARD_CONFIG_H_
#define _BOARD_CONFIG_H_
// AtomS3R + Echo Pyramid Board configuration
#include <driver/gpio.h>
#define AUDIO_INPUT_REFERENCE true
#define AUDIO_INPUT_SAMPLE_RATE 24000
#define AUDIO_OUTPUT_SAMPLE_RATE 24000
#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_NC
#define AUDIO_I2S_GPIO_WS GPIO_NUM_8
#define AUDIO_I2S_GPIO_BCLK GPIO_NUM_6
#define AUDIO_I2S_GPIO_DIN GPIO_NUM_5
#define AUDIO_I2S_GPIO_DOUT GPIO_NUM_7
#define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_38
#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_39
#define AUDIO_CODEC_ES8311_ADDR ES8311_CODEC_DEFAULT_ADDR
#define AUDIO_CODEC_ES7210_ADDR ES7210_CODEC_DEFAULT_ADDR
#define AUDIO_CODEC_GPIO_PA GPIO_NUM_NC
#define BUILTIN_LED_GPIO GPIO_NUM_NC
#define BOOT_BUTTON_GPIO GPIO_NUM_41
#define VOLUME_UP_BUTTON_GPIO GPIO_NUM_NC
#define VOLUME_DOWN_BUTTON_GPIO GPIO_NUM_NC
#define DISPLAY_SDA_PIN GPIO_NUM_NC
#define DISPLAY_SCL_PIN GPIO_NUM_NC
#define DISPLAY_WIDTH 128
#define DISPLAY_HEIGHT 128
#define DISPLAY_MIRROR_X false
#define DISPLAY_MIRROR_Y false
#define DISPLAY_SWAP_XY false
#define DISPLAY_OFFSET_X 0
#define DISPLAY_OFFSET_Y 32
#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_NC
#define DISPLAY_BACKLIGHT_OUTPUT_INVERT true
#endif // _BOARD_CONFIG_H_

View File

@ -0,0 +1,13 @@
{
"target": "esp32s3",
"builds": [
{
"name": "atoms3r-echo-pyramid",
"sdkconfig_append": [
"CONFIG_BOARD_TYPE_M5STACK_ATOM_S3R_ECHO_PYRAMID=y",
"CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y",
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\""
]
}
]
}

View File

@ -1,4 +1,4 @@
#include "dual_network_board.h"
#include "wifi_board.h"
#include "codecs/no_audio_codec.h"
#include "display/lcd_display.h"
#include "system_reset.h"
@ -57,7 +57,7 @@ static const gc9a01_lcd_init_cmd_t gc9107_lcd_init_cmds[] = {
#define TAG "ESP32-LCD-MarsbearSupport"
class CompactWifiBoardLCD : public DualNetworkBoard {
class CompactWifiBoardLCD : public WifiBoard {
private:
Button boot_button_;
Button touch_button_;
@ -136,26 +136,14 @@ private:
boot_button_.OnClick([this]() {
auto& app = Application::GetInstance();
if (GetNetworkType() == NetworkType::WIFI) {
if (app.GetDeviceState() == kDeviceStateStarting) {
// cast to WifiBoard
auto& wifi_board = static_cast<WifiBoard&>(GetCurrentBoard());
wifi_board.EnterWifiConfigMode();
return;
}
if (app.GetDeviceState() == kDeviceStateStarting) {
EnterWifiConfigMode();
return;
}
gpio_set_level(BUILTIN_LED_GPIO, 1);
app.ToggleChatState();
});
boot_button_.OnDoubleClick([this]() {
auto& app = Application::GetInstance();
if (app.GetDeviceState() == kDeviceStateStarting || app.GetDeviceState() == kDeviceStateWifiConfiguring) {
SwitchNetworkType();
}
});
asr_button_.OnClick([this]() {
std::string wake_word="你好小智";
Application::GetInstance().WakeWordInvoke(wake_word);
@ -174,8 +162,7 @@ private:
}
public:
CompactWifiBoardLCD() :
DualNetworkBoard(ML307_TX_PIN, ML307_RX_PIN),
CompactWifiBoardLCD() : WifiBoard(),
boot_button_(BOOT_BUTTON_GPIO), touch_button_(TOUCH_BUTTON_GPIO), asr_button_(ASR_BUTTON_GPIO) {
InitializeSpi();
InitializeLcdDisplay();

View File

@ -1,4 +1,4 @@
#include "dual_network_board.h"
#include "wifi_board.h"
#include "codecs/no_audio_codec.h"
#include "system_reset.h"
#include "application.h"
@ -16,7 +16,7 @@
#define TAG "ESP32-MarsbearSupport"
class CompactWifiBoard : public DualNetworkBoard {
class CompactWifiBoard : public WifiBoard {
private:
Button boot_button_;
Button touch_button_;
@ -104,26 +104,14 @@ private:
boot_button_.OnClick([this]() {
auto& app = Application::GetInstance();
if (GetNetworkType() == NetworkType::WIFI) {
if (app.GetDeviceState() == kDeviceStateStarting) {
// cast to WifiBoard
auto& wifi_board = static_cast<WifiBoard&>(GetCurrentBoard());
wifi_board.EnterWifiConfigMode();
return;
}
if (app.GetDeviceState() == kDeviceStateStarting) {
EnterWifiConfigMode();
return;
}
gpio_set_level(BUILTIN_LED_GPIO, 1);
app.ToggleChatState();
});
boot_button_.OnDoubleClick([this]() {
auto& app = Application::GetInstance();
if (app.GetDeviceState() == kDeviceStateStarting || app.GetDeviceState() == kDeviceStateWifiConfiguring) {
SwitchNetworkType();
}
});
asr_button_.OnClick([this]() {
std::string wake_word="你好小智";
Application::GetInstance().WakeWordInvoke(wake_word);
@ -145,7 +133,7 @@ private:
}
public:
CompactWifiBoard() : DualNetworkBoard(ML307_TX_PIN, ML307_RX_PIN), boot_button_(BOOT_BUTTON_GPIO), touch_button_(TOUCH_BUTTON_GPIO), asr_button_(ASR_BUTTON_GPIO)
CompactWifiBoard() : WifiBoard(), boot_button_(BOOT_BUTTON_GPIO), touch_button_(TOUCH_BUTTON_GPIO), asr_button_(ASR_BUTTON_GPIO)
{
InitializeDisplayI2c();
InitializeSsd1306Display();

View File

@ -24,26 +24,6 @@ idf.py menuconfig
Xiaozhi Assistant -> Board Type ->面包板新版接线WiFi+ LCD + Camera
```
**配置摄像头传感器:**
> **注意:** 确认摄像头传感器型号,确定型号在 esp_cam_sensor 支持的范围内。当前板子用的是 OV2640是符合支持范围。
在 menuconfig 中按以下步骤启用对应型号的支持:
1. **导航到传感器配置:**
```
(Top) → Component config → Espressif Camera Sensors Configurations → Camera Sensor Configuration → Select and Set Camera Sensor
```
2. **选择传感器型号:**
- 选中所需的传感器型号OV2640
3. **配置传感器参数:**
- 按 → 进入传感器详细设置
- 启用 **Auto detect**
- 推荐将 **default output format** 调整为 **YUV422** 及合适的分辨率大小
- (目前支持 YUV422、RGB565YUV422 更节省内存空间)
**编译烧入:**
```bash

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