Compare commits

...

92 Commits

Author SHA1 Message Date
Xiaoxia 9e0f8ae611 refactor: centralize board build configuration 2026-07-27 16:21:07 +08:00
Xiaoxia dd7e931c80 fix: resolve build regressions after board moves 2026-07-27 08:14:50 +08:00
Xiaoxia f475b4cbb1 fix: defer board validation during CMake early expansion 2026-07-27 07:24:54 +08:00
Xiaoxia c78d7f2568 refactor: replace release script with build tool 2026-07-27 07:24:51 +08:00
Xiaoxia 177901c974 fix: enable cross-signed certificate verification globally 2026-07-27 07:04:48 +08:00
Xiaoxia 29bf765947 refactor: organize board metadata and directories 2026-07-27 07:04:48 +08:00
Xiaoxia 90311fc8fb refactor: remove unreliable acoustic provisioning (#2152)
Co-authored-by: Xiaoxia <terrence.huang@tenclass.com>
2026-07-27 02:14:53 +08:00
Xiaoxia 3e86f0adaf feat: add ESP32-S31 Function CoreBoard support (#2151)
* feat: add ESP32-S31 Function CoreBoard support

* ci: build ESP32-S31 with IDF 6.1

---------

Co-authored-by: Xiaoxia <terrence.huang@tenclass.com>
2026-07-27 00:46:15 +08:00
javen13 5b55606415 feat: add Waveshare ESP32-S3-Touch-LCD-3.49 V2 (#2143) 2026-07-26 21:17:11 +08:00
Xiaoxia 38bc4f443b Update ESP-SR/P4 support and fix LCDWiki IDF 6 build (#2142)
* feat: update esp-sr and enable ESP32-P4 rev1 builds

* fix: build LCDWiki ES3C35P with ESP-IDF 6

---------

Co-authored-by: Xiaoxia <terrence.huang@tenclass.com>
2026-07-25 00:35:14 +08:00
sinco-z 324c8098a9 fix(taiji-pi-s3): keep round-screen text in safe area (#2136)
Co-authored-by: sinco-z <sinco-z@users.noreply.github.com>
2026-07-23 02:29:53 +08:00
Eivs ac3212d781 Add support for LCD Wiki ES3C35P board configuration and files (#2112)
* Add support for LCD Wiki ES3C35P (3.5" ESP32-S3) board

* fix: update variant counts in test for new ES3C35P board

---------

Co-authored-by: Eivs <eivs@Eivss-MacBook-Pro.local>
2026-07-22 20:46:57 +08:00
Xiaoxia 5f6c09b893 feat: enhance BoxAudioCodec with input gain and reference channel support
- Updated BoxAudioCodec constructor to include parameters for input gain, reference gain channel, and reference gain.
- Modified EnableInput method to set gain for the reference channel if applicable.
- Adjusted CustomAudioCodec initialization to utilize new gain parameters for improved audio processing.
2026-07-19 22:45:03 +08:00
Xiaoxia 5540258abc feat: integrate press-to-talk functionality in lichuang_dev_board
- Added PressToTalkMcpTool to manage press-to-talk and click-to-talk modes.
- Updated button event handling to support listening state transitions based on press-to-talk status.
- Initialized press-to-talk tool during board setup for enhanced user interaction.
2026-07-19 02:43:04 +08:00
Xiaoxia cb06516bea Refine GitHub issue forms (#2128)
Co-authored-by: Xiaoxia <terrence.huang@tenclass.com>
2026-07-19 02:10:46 +08:00
Xiaoxia 3dea6e7f84 Migrate to xiaozhi-fonts 2.0.0 (#2125)
* feat: migrate firmware to xiaozhi-fonts 2.0.0

* fix: restart scrolling subtitles on text change

* docs: add AGENTS.md for project overview, architecture, rules, and commands

- Introduced AGENTS.md to document the XiaoZhi voice-assistant firmware architecture, required rules for development, board configuration process, and validation steps.
- Included detailed sections on project structure, commands for building and testing, and authoritative documentation references.

* feat: finalize glyph push protocol

* fix: remove stale emoji font include

* build: use xiaozhi-fonts 2.0.0

* fix: decouple custom fonts from glyph push

* refactor: improve wake word invocation handling

- Introduced BeginWakeWordInvoke method to streamline the wake word processing flow.
- Updated HandleWakeWordDetectedEvent to utilize the new method, ensuring proper state transitions and scheduling.
- Enhanced error handling to prevent the device from getting stuck in the connecting state.
- Refactored audio channel management to improve responsiveness during wake word detection.

---------

Co-authored-by: Xiaoxia <terrence.huang@tenclass.com>
2026-07-18 21:29:00 +08:00
Xiaoxia 0f6c435f66 Complete ESP-IDF 6 migration hardening (#2123)
* Complete ESP-IDF 6 migration hardening

* Fix PR variant diff selection

* Allow Git diff in CI container

---------

Co-authored-by: Xiaoxia <terrence.huang@tenclass.com>
2026-07-17 16:32:44 +08:00
Xiaoxia 3e78cd73bd v2.4.0: Migrate to IDF 6.0 (#2120)
* Migrate board builds to ESP-IDF 6.0.1

* Document upstream IDF 6 CI blockers

* Use IDF 6 patched registry components

* Fix board-specific ESP-IDF 6 build errors

* Fix remaining ESP-IDF 6 board builds

* Document final ESP-IDF 6 matrix results [skip ci]

* update IDF 6 component releases

* rebase IDF 6 migration and use upstream SSCMA

* Enhance audio input management in AudioService

- Introduced AS_EVENT_AUDIO_INPUT_STOP_REQUEST to manage audio input stopping more effectively.
- Updated AudioService::Start() to clear the new stop request event.
- Modified AudioService::AudioInputTask() to handle the stop request and ensure proper input disabling.
- Adjusted AdcPdmAudioCodec::Start() to reflect lazy input opening, ensuring start/stop operations run in the same task.

* Refactor audio codec management and configuration

- Added output_device_opened_ flag to track the state of the output device in Es8388 and Es8389 codecs.
- Updated EnableOutput method to prevent unnecessary device reopening and added mute functionality to manage audio output more effectively.
- Enhanced error handling in Es8311AudioCodec by ensuring proper closure and deletion of the codec device.
- Adjusted sample rates in board configurations for atk-dnesp32s3-box2 to 24000 Hz and introduced AUDIO_CODEC_USE_MCLK for improved clock management.

* Update build configurations and documentation for ESP-IDF 6 compatibility

- Added container specification for the build workflow using espressif/idf:v6.0.1.
- Updated the version of the espressif/esp_video component to ^2.3.0.
- Enhanced documentation to clarify the support status of ESP32-P4 variants, specifying that legacy Rev < 3 variants are excluded from the IDF 6 matrix.
- Adjusted board configurations to include IDF version constraints and necessary SDK configurations for legacy support.
- Improved handling of YUV formats in EspVideo to maintain compatibility with previous versions.

* Implement playback drained event handling in Application

- Added MAIN_EVENT_PLAYBACK_DRAINED to manage playback state transitions.
- Introduced callbacks for playback drained events in AudioService to trigger listening state changes.
- Refactored Application::Run() to handle deferred listening start based on playback queue status.
- Enhanced audio processing logic to prevent audio truncation during state changes.
- Updated related methods to ensure proper wake word detection configuration during listening mode.

* Fix variant selection shell in CI

* Update project version to 2.4.0 and adjust component dependencies

- Bump project version in CMakeLists.txt to 2.4.0.
- Change espressif/esp_video component version to ^2.0.1 in idf_component.yml.
- Modify AUDIO_INPUT_REFERENCE setting in config.h to false for m5stack-core-s3.
- Remove unnecessary infinite loops in xmini_c3_board.cc during initialization.

---------

Co-authored-by: Xiaoxia <terrence.huang@tenclass.com>
2026-07-17 13:41:52 +08:00
小鹏 2b8e1f34b0 feat(otto): update GIF emoji component to v1.3.0 (#2119)
- Upgrade the Otto GIF emoji component to v1.3.0
- Remove legacy Otto GIF emotion alias mappings
- Use the 21 standard Xiaozhi emotion names directly
2026-07-17 12:53:50 +08:00
laride 467b96a857 chore: temporarily disable touch sensor for ESP-VoCat on IDF v6 (#2118) 2026-07-16 21:40:00 +08:00
Terrence 7df0080e88 Enhance ES8311 codec detection in xmini_c3_board.cc
- Implemented a new function, IsEs8311Present(), to verify the presence of the ES8311 codec by checking its chip ID registers instead of relying solely on I2C bus probing.
- Updated error logging to provide clearer messages when the ES8311 codec is not detected.
- Added a continuous logging loop to indicate waiting for the boot button to be pressed during initialization.
2026-07-15 22:03:25 +08:00
Terrence 1b48ebd786 Update project version to 2.3.0 and modify dependencies in idf_component.yml; enhance DHCP hostname configuration in WifiBoard class 2026-07-15 15:13:21 +08:00
Xiaoxia 03fe29a313 Update README and refactor audio processing dependencies (#2115)
* Update QQ group contact in README.md

* Update dependencies in idf_component.yml and modify AFE audio processor configuration

- Updated versions for several dependencies in idf_component.yml to improve compatibility and performance.
- Changed AFE audio processor initialization to use low-cost mode and adjusted AEC settings for enhanced audio processing.

* Update audio dependencies in idf_component.yml

- Downgraded versions for espressif/esp_audio_effects and espressif/esp_audio_codec to improve compatibility with existing components.

* refactor(audio): unify speech processing engines

---------

Co-authored-by: Xiaoxia <terrence.huang@tenclass.com>
2026-07-15 11:13:06 +08:00
Xiaoxia 0ec696f64f Refactor network configuration for XiaoZhi boards
- Renamed CONFIG_XIAOZHI_USE_ETHERNET to CONFIG_XIAOZHI_NETWORK_ETHERNET for clarity.
- Updated CMakeLists.txt and Kconfig.projbuild to reflect the new configuration option.
- Adjusted conditional compilation in system_info.cc and esp32-p4-nano.cc to use the new network configuration.
- Added a rename file to maintain compatibility with existing sdkconfig files.
2026-07-15 01:58:09 +08:00
Y1hsiaochunnn 7b190b78e4 Merge pull request #2096 from CQ793/esp32-s3-touch-lcd-7c
Esp32 s3 touch lcd 7c
2026-07-07 14:33:36 +08:00
CQ793 b766e8e9b8 Content: fixed config.json 2026-07-07 10:48:14 +08:00
CQ793 b603b00110 Content: fixed C7 format 2026-07-07 10:00:12 +08:00
Xuejie Xiao 8a5981644d Fix board type selection for waveshare esp32-s3-epaper-1.54 (#2101)
Right now when we follow the tips in:
https://github.com/78/xiaozhi-esp32/tree/main/main/boards/waveshare/esp32-s3-epaper-1.54
and run the following command:

python ./scripts/release.py --name esp32-s3-epaper-1.54-v2 waveshare/esp32-s3-epaper-1.54

It will actually build the firmware for v1. This commit fixes it.
2026-07-05 08:00:46 +08:00
CQ793 eb9853e889 Merge remote-tracking branch 'upstream/main' into esp32-s3-touch-lcd-7c 2026-07-03 14:35:28 +08:00
CQ793 81b5e6bb15 Content: add esp32_s3_touch_lcd_7c 2026-07-03 14:17:43 +08:00
CQ793 7a7ac87640 Merge branch 'main' of https://github.com/CQ793/xiaozhi-esp32 2026-07-03 10:39:54 +08:00
Y1hsiaochunnn 009701e71c Merge pull request #2081 from CQ793/fix/esp32c5-touch-lcd-1.69
Content: add Waveshare ESP32-C5-Touch-LCD-1.69
2026-07-02 10:18:31 +08:00
CQ793 2c52a450ae Merge branch '78:main' into fix/esp32c5-touch-lcd-1.69 2026-07-01 17:15:27 +08:00
CQ793 f7f4ac3a50 Content: Corrected formatting issues 2026-07-01 17:11:47 +08:00
almirus 71051062a3 Add ESP-VoCat battery emotes (#2090) 2026-07-01 07:29:52 +08:00
Y1hsiaochunnn af637182dc Merge pull request #2089 from Y1hsiaochunnn/feature/esp32-p4-wifi6-touch-lcd-5
Add Waveshare ESP32-P4-WIFI6-Touch-LCD-5 board
2026-06-30 16:26:59 +08:00
Y1hsiaochunnn 8d171a749c Add Waveshare ESP32-P4-WIFI6-Touch-LCD-5 board 2026-06-30 15:30:22 +08:00
CQ793 01e5cff99e Content: add Waveshare ESP32-C5-Touch-LCD-1.69 2026-06-26 15:32:23 +08:00
Y1hsiaochunnn 0d1ffd3f38 Merge pull request #2067 from Y1hsiaochunnn/feature/esp32-p4-nano-ethernet
Add Ethernet option for ESP32-P4 with ETH
2026-06-18 11:17:44 +08:00
Y1hsiaochunnn 7738eeed81 Add Ethernet option for Waveshare ESP32-P4-NANO 2026-06-18 09:59:27 +08:00
小鹏 a6cc7f77fe Add ElectronBot WebSocket and servo sequence control (#2062)
* Enable WebSocket control server for electron-bot

* Add servo control features to ElectronBot

- Introduced new actions for servo movement and sequences, including ACTION_SERVO_MOVE and ACTION_SERVO_SEQUENCE.
- Implemented methods for clamping servo positions and applying oscillation effects.
- Added GetServoPositions method to retrieve current servo angles.
- Enhanced README.md with detailed AI command examples and servo capabilities.

These changes improve the flexibility and control of the ElectronBot's movements, allowing for more complex actions and better integration with AI functionalities.

* Refactor servo control in ElectronBot

- Removed unnecessary home action logic during servo sequences to improve action fluidity.
- Updated README.md to clarify the action parameters for hand movements, specifically the amplitude for flapping actions.
- Introduced clamping functions for servo angles and amplitudes to ensure safe operation within defined limits.

These changes enhance the control and safety of the ElectronBot's servo movements, allowing for more precise and natural actions.
2026-06-18 07:41:51 +08:00
Create123 0f2f30f2d2 Add M5Stack StopWatch board support. (#2057)
Co-authored-by: luoweiyuan <luoweiyuan@m5stack.com>
2026-06-18 07:38:34 +08:00
Create123 b392c630aa Add M5Stack StickS3 board support. (#2060)
Co-authored-by: luoweiyuan <luoweiyuan@m5stack.com>
2026-06-16 01:05:21 +08:00
Create123 3f4a275aec Add M5Stack AtomS3/AtomS3R st7735 display support. (#2055)
Co-authored-by: luoweiyuan <luoweiyuan@m5stack.com>
2026-06-16 00:57:53 +08:00
施清荃 3f9e5fccae feat(m5stack-cardputer-adv): add battery monitoring support (#2045)
Add ADC battery monitoring for M5Stack Cardputer-Adv board.

Hardware verified from official schematic (Sch_M5CardputerAdv_v1.0):
- Battery voltage divider: GPIO10 / ADC1_CH9
- R9 = 100KΩ (BAT+ to GPIO10)
- R8 = 100KΩ (GPIO10 to GND)

Changes:
- config.h: add BATTERY_ADC_* defines
- m5stack_cardputer_adv.cc: add AdcBatteryMonitor, override GetBatteryLevel()
- README.md: document battery monitoring pinout

Fixes missing battery level reporting in device status and UI.

Co-authored-by: QQ <qq@example.com>
2026-06-13 13:08:04 +08:00
Y1hsiaochunnn b28bfe0f56 Merge pull request #2051 from ggc121238/main
add ESP32-S3-Touch-LCD-1.85B
2026-06-12 16:36:12 +08:00
ggc d60d53fc0b add ESP32-S3-Touch-LCD-1.85B 2026-06-12 14:25:43 +08:00
小鹏 8755a658a8 fix(electron-bot): show chat subtitle on display (#2042)
* 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.

* fix(otto-robot): migrate camera backend and set safe dark default theme

- Migrate `otto-robot` camera backend from `EspVideo` to `Esp32Camera` to improve capture stability after reboot/power cycle.
- Keep runtime sensor detection for both OV2640 and OV3660, and rename PID macros with `OTTO_` prefix to avoid symbol conflicts.
- Configure camera output as `RGB565 + FRAMESIZE_240X240` to match the 240x240 display.
- Rotate OV2640 output by 180 degrees (`VFlip + HMirror`) for correct orientation.
- Simplify `otto-robot` camera sdkconfig options by keeping only:
  - `CONFIG_CAMERA_OV2640=y`
  - `CONFIG_CAMERA_OV3660=y`
- Move Otto default dark theme setup into `OttoEmojiDisplay::SetupUI()` (after base UI init) to avoid boot-time LVGL null-object crash caused by calling `SetTheme()` too early.

* feat(motion): smooth action ending and adaptive homing on otto/electron-bot

Improve servo motion transitions to reduce abrupt returns to home pose.
Replace linear interpolation with ease-out movement, make homing duration adaptive to angle delta, and skip intermediate homing when queued actions are pending to keep multi-action sequences fluid.

* fix(electron-bot): show chat subtitle on display

ElectronEmojiDisplay overrode SetupChatLabel() to delete the parent's
chat_message_label_ (which lives inside bottom_bar_) and recreate it on the
bottom-layer container_ without alignment. As a result the real subtitle label
was hidden behind emoji_box_/top_bar_, while SetChatMessage() only toggled the
now-empty bottom_bar_, so chat subtitles were never visible.

Reuse the parent label created in bottom_bar_ (matching otto-robot) and only
switch to the dark theme in SetupUI(), removing the custom SetupChatLabel().

Co-authored-by: Cursor <cursoragent@cursor.com>

* fix(electron-bot): restore status icons on standby and clean up display

- SetStatus() did not re-show network_label_/battery_label_ when returning
  to STANDBY, so the WiFi and battery icons stayed hidden after listening or
  speaking. Restore them on standby to match otto-robot.
- Remove the unused InitializeElectronEmojis() method and its declaration.
- Drop unused includes (vector, assets.h, emoji_collection.h, lvgl_image.h)
  and add the missing trailing newline in the header.

Co-authored-by: Cursor <cursoragent@cursor.com>

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-07 16:08:46 +08:00
Y1hsiaochunnn 1874c6206c Merge pull request #2039 from Cosmic183123/main
content:add-esp32-s3-touch-amoled-1.8-v2
2026-06-03 11:15:51 +08:00
Cosmic183123 92422bbf99 content:add-esp32-s3-touch-amoled-1.8-v2 2026-06-02 15:19:22 +08:00
CQ793 277cbd10ac add esp32-s3-rgb-matrix (#2026)
* Content: add esp32-s3-rgb-matrix

* Content: fixed config.json error

* fixed-esp-hub75 version

* Fixed introduction error of version yml component
2026-05-29 06:07:08 +08:00
3em0 e182471f8c Validate MQTT goodbye session id (#2023)
Co-authored-by: Codex <codex@local>
2026-05-29 05:55:37 +08:00
hashcodeFF 331d69b83b add quandong support (#2010)
Co-authored-by: shujun <shujun@beithing.com>
2026-05-26 05:42:19 +08:00
Vicat bcc9b599ca Improve Assets::Download (#1986)
* Improve Assets::Download

* fix: improve status logic
2026-05-25 23:01:52 +08:00
Alxy Savin a596121acf fix(esp32-s3-touch-amoled-1.8): keep wake word always-on while charging (#1981)
* fix(esp32-s3-touch-amoled-1.8): keep wake word always-on while charging

The previous logic in GetBatteryLevel() used a static last_discharging
initialized to false. If the device booted on the charger (discharging
already false), the equality check never fired and PowerSaveTimer kept
running, so the device entered light sleep after 60s and stopped wake
word detection — defeating the always-on-while-charging behavior.

Move the tracker to a member with a tri-state initial value (-1 =
unknown) so the very first GetBatteryLevel() tick reconciles the timer
state regardless of boot conditions. Add ESP_LOGI on each transition
so the always-on state is visible in the logs.

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

* fix(esp32-s3-touch-amoled-1.8): keep PowerSaveTimer ticking while charging

The previous patch disabled PowerSaveTimer entirely on the charger,
which also disabled the screen dim and sleep emoji that fire from
OnEnterSleepMode after 60s of idle. Since this board constructs the
timer with cpu_max_freq=-1, it never enters CPU light sleep nor
disables wake word detection in OnEnterSleepMode anyway — the only
charger-incompatible action is the 5-minute pmic_->PowerOff() in
OnShutdownRequest.

Keep the timer running unconditionally and gate just the shutdown
callback on last_discharging_. Also call WakeUp() on the
charging→battery transition so an unplug after 5+ idle minutes
doesn't trigger an immediate power-off.

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

* Revert "fix(esp32-s3-touch-amoled-1.8): keep PowerSaveTimer ticking while charging"

This reverts commit d7a9b619ebfd2bc1573865fa2f85e8d636625718.

* feat(esp32-s3-touch-amoled-1.8): add wake word heartbeat while charging

Brings back the display dim + sleepy emoji UX while plugged in by
keeping PowerSaveTimer running and gating just the shutdown callback
on charging state. To work around the wake word stalling after long
idle (~22 min observed in testing), add a periodic bounce timer that
restarts wake word detection every 5 minutes while idle on the
charger. Skips the bounce mid-conversation, on battery, or when wake
word is already disabled.

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

* fix(esp32-s3-touch-amoled-1.8): bounce wake word on sleep mode entry

Hardware testing shows wake word detection stops responding shortly
after PowerSaveTimer enters sleep mode (display dim + sleepy emoji).
The 5-minute periodic heartbeat doesn't help — the failure happens
well before the first bounce.

Add a preemptive bounce in OnEnterSleepMode while charging, and
shorten the periodic heartbeat to 2 minutes as a safety net for any
later stalls.

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

* fix(esp32-s3-touch-amoled-1.8): use standalone dim timer on charger

Hardware testing showed that the previous approach (keeping
PowerSaveTimer active with wake-word bounces on the charger) still
left wake word unresponsive after sleep mode entered — even with a
preemptive bounce in OnEnterSleepMode and a 2-min periodic heartbeat,
the AFE / audio pipeline kept stalling, and the user had to wake the
device with the boot button.

Switch to a different split:

- On charger: PowerSaveTimer is fully disabled. Its sleep callbacks
  never fire, so wake word detection is undisturbed. A standalone
  dim_timer (1s tick) handles the brightness + sleepy-emoji UX
  independently — on idle 60s it dims, on activity (conversation,
  button) it restores via Application::CanEnterSleepMode() polling.
- On battery: PowerSaveTimer runs as before (60s sleep callbacks,
  5min auto-shutdown), dim_timer stays stopped.

Also drops the wake-word bounce timer entirely — without
PowerSaveTimer's sleep mode firing on the charger there's nothing to
recover from.

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

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-25 23:01:02 +08:00
Almost-LoverY f929f2f142 feat:新增正点原子DNESP32S3B3开发板的AEC支持 (#1994) 2026-05-25 21:36:11 +08:00
小鹏 ed871cdeef feat(motion): smooth action ending and adaptive homing on otto/electron-bot (#2001)
* 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.

* fix(otto-robot): migrate camera backend and set safe dark default theme

- Migrate `otto-robot` camera backend from `EspVideo` to `Esp32Camera` to improve capture stability after reboot/power cycle.
- Keep runtime sensor detection for both OV2640 and OV3660, and rename PID macros with `OTTO_` prefix to avoid symbol conflicts.
- Configure camera output as `RGB565 + FRAMESIZE_240X240` to match the 240x240 display.
- Rotate OV2640 output by 180 degrees (`VFlip + HMirror`) for correct orientation.
- Simplify `otto-robot` camera sdkconfig options by keeping only:
  - `CONFIG_CAMERA_OV2640=y`
  - `CONFIG_CAMERA_OV3660=y`
- Move Otto default dark theme setup into `OttoEmojiDisplay::SetupUI()` (after base UI init) to avoid boot-time LVGL null-object crash caused by calling `SetTheme()` too early.

* feat(motion): smooth action ending and adaptive homing on otto/electron-bot

Improve servo motion transitions to reduce abrupt returns to home pose.
Replace linear interpolation with ease-out movement, make homing duration adaptive to angle delta, and skip intermediate homing when queued actions are pending to keep multi-action sequences fluid.
2026-05-25 20:58:05 +08:00
Y1hsiaochunnn 36200942cc Merge pull request #2004 from shunian11/main
add waveshare esp32-c6-touch-lcd-1.54 and esp32-c6-lcd-0.85
2026-05-22 10:21:48 +08:00
shunian11 b28d3ee448 content : add waveshare esp32-c6-lcd-0.85 2026-05-21 11:20:25 +08:00
shunian11 a441b1a205 content : add waveshare esp32-c6-touch-lcd-1.54 2026-05-21 10:18:50 +08:00
shunian11 f9cafc5261 content : update esp32-s3-touch-lcd-1.54 touch scl_speed_hz 2026-05-21 09:54:25 +08:00
shunian11 9c0a27c8ba content : update ESP32-S3-LCD-0.85 README 2026-05-21 09:43:30 +08:00
小鹏 589485e31c fix(otto-robot): migrate camera backend and set safe dark default theme (#2000)
* 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.

* fix(otto-robot): migrate camera backend and set safe dark default theme

- Migrate `otto-robot` camera backend from `EspVideo` to `Esp32Camera` to improve capture stability after reboot/power cycle.
- Keep runtime sensor detection for both OV2640 and OV3660, and rename PID macros with `OTTO_` prefix to avoid symbol conflicts.
- Configure camera output as `RGB565 + FRAMESIZE_240X240` to match the 240x240 display.
- Rotate OV2640 output by 180 degrees (`VFlip + HMirror`) for correct orientation.
- Simplify `otto-robot` camera sdkconfig options by keeping only:
  - `CONFIG_CAMERA_OV2640=y`
  - `CONFIG_CAMERA_OV3660=y`
- Move Otto default dark theme setup into `OttoEmojiDisplay::SetupUI()` (after base UI init) to avoid boot-time LVGL null-object crash caused by calling `SetTheme()` too early.
2026-05-18 14:51:48 +08:00
liuzhe910422 417f52d759 perf(websocket): switch WiFi to performance mode before connecting (#1985)
* 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
Terrence ba27c12494 fix(no_audio_codec): replace kReadTimeoutTicks with kReadTimeoutMs for clarity and consistency 2026-05-07 22:11:42 +08:00
khoanguyen-3fc 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
Terrence 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
Aayush Pratap Singh 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
Y1hsiaochunnn e12e7351d9 Merge pull request #1958 from rymcu/main
feat(board): add rymcu-bigsmart board support
2026-04-30 17:16:40 +08:00
Hugh-rymcu 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
Terrence 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
Seth Shi 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
Hao Guan 73ad50c732 feat: add M5Stack AtomS3R Echo Pyramid board (#1959)
Made-with: Cursor
2026-04-28 13:55:39 +08:00
Hugh-rymcu 07e2a11253 feat(board): add rymcu-bigsmart board support 2026-04-23 20:44:35 +08:00
Y1hsiaochunnn 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
Cosmic183123 b72945a78a content:fix-ESP32-S3-Touch-AMOLED-2.16-readme.md 2026-04-21 14:41:09 +08:00
Y1hsiaochunnn 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
zqyhimself 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
Cosmic183123 f78c59a954 content:add-waveshare-esp32-s3-touch-amoled-2.16 2026-04-21 09:52:20 +08:00
Copilot 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
zqyhimself 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
Y1hsiaochunnn 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
Tomato Me d2687956cd resolve conflict 2026-04-17 10:48:13 +08:00
Terrence 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
Yeqin Gong 69b1a978e9 Add NULLLAB-AI-VOX3 Board (#1900) 2026-04-16 19:31:53 +08:00
tkpdx01 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
Tomato Me 7dc61300d1 add waveshare esp32-s3-touch-amoled-1.43c 2026-04-14 15:52:40 +08:00
Almost-LoverY 97c0e75eec feat:添加正点原子DNESP32S3B3开发板的小智AI例程 (#1931) 2026-04-13 21:09:11 +08:00
laride efeb3ad119 fix: remove submodule (#1925) 2026-04-10 17:09:39 +08:00
almirus 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
Copilot 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
690 changed files with 29086 additions and 12588 deletions
@@ -0,0 +1,77 @@
name: Build, setup, or flashing problem
description: Report a reproducible problem setting up, building, or flashing the firmware
title: "[Build] "
labels: ['bug']
body:
- type: markdown
attributes:
value: |
This form is for the open-source firmware. For device unbinding, email [xiaozhi.ai@tenclass.com](mailto:xiaozhi.ai@tenclass.com?subject=Device%20unbinding%20request) privately with the device ID and MAC address. Use the [XiaoZhi AI website](https://xiaozhi.me/) for other account and cloud-service requests.
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I have tested a current source version and searched for an existing issue reporting the same problem.
required: true
- type: input
id: board
attributes:
label: Board or device
description: Enter the physical product/model and the selected board target or build variant.
placeholder: ex. Waveshare ESP32-S3-Touch-LCD-3.5B; target waveshare/esp32-s3-touch-lcd-3.5b
validations:
required: true
- type: input
id: source_version
attributes:
label: XiaoZhi source version
description: Paste the complete output of `git describe --tags --always --dirty`. Do not enter "latest".
placeholder: ex. v2.2.6-15-g1a2b3c4
validations:
required: true
- type: input
id: esp_idf_version
attributes:
label: ESP-IDF version
description: Paste the complete output of `idf.py --version`.
placeholder: ex. ESP-IDF v6.0.2
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem and reproduction steps
description: State what you tried, the exact commands or steps, what you expected, what happened, and any relevant local changes.
placeholder: |
Operating system:
Command or steps:
Expected result:
Actual result:
Local changes, if any:
validations:
required: true
- type: textarea
id: logs
attributes:
label: Complete error output
description: Paste the output from the first error through the final failure. Remove credentials, tokens, usernames, and other private information.
placeholder: Paste the complete output here, not only the final error line.
render: shell
validations:
required: true
- type: upload
id: attachments
attributes:
label: Optional files
description: Attach logs or a ZIP containing relevant configuration files. Review files for secrets first.
validations:
required: false
accept: ".log,.txt,.json,.zip"
@@ -1,103 +0,0 @@
name: Installation or build bug report
description: Report installation or build bugs
labels: ['bug']
body:
- type: checkboxes
id: checklist
attributes:
label: Answers checklist.
description: Before submitting a new issue, please follow the checklist and try to find the answer.
options:
- label: I have read the documentation [XiaoZhi AI Programming Guide](https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb) and the issue is not addressed there.
required: true
- label: I have updated my branch (master or release) to the latest version and checked that the issue is present there.
required: true
- label: I have searched the issue tracker for a similar issue and not found a similar issue.
required: true
- type: input
id: xiaozhi_ai_version
attributes:
label: XiaoZhi AI version.
description: On which XiaoZhi AI version does this issue occur on? Run `git describe --tags` to find it.
placeholder: ex. v1.1.0-44-g140aab8
validations:
required: true
- type: dropdown
id: operating_system
attributes:
label: Operating System used.
multiple: false
options:
- Windows
- Linux
- macOS
validations:
required: true
- type: dropdown
id: build
attributes:
label: How did you build your project?
multiple: false
options:
- Command line with CMake
- Command line with idf.py
- CLion IDE
- VS Code IDE/Cursor
- Other (please specify in More Information)
validations:
required: true
- type: dropdown
id: windows_comand_line
attributes:
label: If you are using Windows, please specify command line type.
multiple: false
options:
- PowerShell
- CMD
validations:
required: false
- type: textarea
id: expected
attributes:
label: What is the expected behavior?
description: Please provide a clear and concise description of the expected behavior.
placeholder: I expected it to...
validations:
required: true
- type: textarea
id: actual
attributes:
label: What is the actual behavior?
description: Please describe actual behavior.
placeholder: Instead it...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce.
description: 'How do you trigger this bug? Please walk us through it step by step. If this is build bug, please attach sdkconfig file (from your project folder). Please attach your code here.'
value: |
1. Step
2. Step
3. Step
...
validations:
required: true
- type: textarea
id: debug_logs
attributes:
label: Build or installation Logs.
description: Build or installation log goes here, should contain the backtrace, as well as the reset source if it is a crash.
placeholder: Your log goes here.
render: plain
validations:
required: false
- type: textarea
id: more-info
attributes:
label: More Information.
description: Do you have any other information from investigating this?
placeholder: ex. Any more.
validations:
required: false
+57 -95
View File
@@ -1,115 +1,77 @@
name: Runtime bug report
description: Report runtime bugs
name: Device runtime bug
description: Report a reproducible firmware problem while a device is running
title: "[Bug] "
labels: ['bug']
body:
- type: markdown
attributes:
value: |
This form is for the open-source device firmware. For device unbinding, email [xiaozhi.ai@tenclass.com](mailto:xiaozhi.ai@tenclass.com?subject=Device%20unbinding%20request) privately with the device ID and MAC address. Use the [XiaoZhi AI website](https://xiaozhi.me/) for other account and cloud-service requests.
- type: checkboxes
id: checklist
attributes:
label: Answers checklist.
description: Before submitting a new issue, please follow the checklist and try to find the answer.
label: Before submitting
options:
- label: I have read the documentation [XiaoZhi AI Programming Guide](https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb) and the issue is not addressed there.
required: true
- label: I have updated my firmware to the latest version and checked that the issue is present there.
required: true
- label: I have searched the issue tracker for a similar issue and not found a similar issue.
- label: I have tested the latest applicable firmware and searched for an existing issue reporting the same problem.
required: true
- type: input
id: xiaozhi_ai_firmware_version
id: device
attributes:
label: XiaoZhi AI firmware version.
description: On which firmware version does this issue occur on?
placeholder: ex. v1.2.1_bread-compact-wifi
label: Device or development board
description: Enter the manufacturer, exact product/model, and hardware revision or SKU when known.
placeholder: ex. LilyGO T-Circle-S3 V1.1 or EchoEar N16R8
validations:
required: true
- type: dropdown
id: operating_system
- type: input
id: firmware_version
attributes:
label: Operating System used.
multiple: false
options:
- Windows
- Linux
- macOS
label: Firmware version
description: Paste the `App version:` value from the startup log. Do not enter a board name or "latest".
placeholder: ex. 2.2.6
validations:
required: true
- type: dropdown
id: build
- type: input
id: firmware_source
attributes:
label: How did you build your project?
multiple: false
options:
- Command line with CMake
- Command line with idf.py
- CLion IDE
- VS Code IDE/Cursor
- Other (please specify in More Information)
label: Firmware source or filename
description: State where the firmware came from and include its filename or board target when available.
placeholder: ex. xiaozhi.me, GitHub release echoear.bin, self-built target df-k10, or vendor firmware
validations:
required: true
- type: dropdown
id: windows_comand_line
- type: textarea
id: problem
attributes:
label: If you are using Windows, please specify command line type.
multiple: false
options:
- PowerShell
- CMD
label: Problem and reproduction steps
description: Describe the starting state, exact actions, expected result, actual result, frequency, and any relevant modifications.
placeholder: |
Steps:
Expected result:
Actual result:
Frequency:
Modifications, if any:
validations:
required: true
- type: textarea
id: evidence
attributes:
label: Logs or other evidence
description: Paste relevant startup and failure logs, including a backtrace or reset reason when applicable. If logs are unavailable, briefly say why. Remove credentials, device IDs, MAC addresses, UUIDs, and other private information.
placeholder: Paste relevant logs here, or explain why logs cannot be collected.
render: shell
validations:
required: true
- type: upload
id: attachments
attributes:
label: Optional photos, videos, or log files
description: Attach files that help identify the hardware or demonstrate the failure. Review files for private information first.
validations:
required: false
- type: dropdown
id: power_supply
attributes:
label: Power Supply used.
multiple: false
options:
- USB
- External 5V
- External 3.3V
- Battery
validations:
required: true
- type: textarea
id: expected
attributes:
label: What is the expected behavior?
description: Please provide a clear and concise description of the expected behavior.
placeholder: I expected it to...
validations:
required: true
- type: textarea
id: actual
attributes:
label: What is the actual behavior?
description: Please describe actual behavior.
placeholder: Instead it...
validations:
required: true
- type: textarea
id: steps
attributes:
label: Steps to reproduce.
description: 'How do you trigger this bug? Please walk us through it step by step. Please attach your code here.'
value: |
1. Step
2. Step
3. Step
...
validations:
required: true
- type: textarea
id: debug_logs
attributes:
label: Debug Logs.
description: Debug log goes here, should contain the backtrace, as well as the reset source if it is a crash.
placeholder: Your log goes here.
render: plain
validations:
required: false
- type: textarea
id: more-info
attributes:
label: More Information.
description: Do you have any other information from investigating this?
placeholder: ex. Any more.
validations:
required: false
accept: ".png,.jpg,.jpeg,.gif,.mp4,.mov,.webm,.log,.txt,.zip"
+37 -21
View File
@@ -1,34 +1,50 @@
name: Feature request
description: Suggest an idea for this project.
description: Propose an improvement to a supported device or the common firmware
title: "[Feature] "
labels: ['enhancement']
body:
- type: markdown
attributes:
value: |
* We welcome any ideas or feature requests! Its helpful if you can explain exactly why the feature would be useful.
* There are usually some outstanding feature requests in the [existing issues list](https://github.com/78/xiaozhi-esp32/labels/enhancement), feel free to add comments to them.
* If you would like to contribute, please read the [contributions guide](https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb).
- type: textarea
id: problem-related
Use the **Board support request** form for a new board, hardware revision, or product variant. Account and cloud-product requests belong on the [XiaoZhi AI website](https://xiaozhi.me/).
- type: checkboxes
id: checklist
attributes:
label: Is your feature request related to a problem?
description: Please provide a clear and concise description of what the problem is.
placeholder: ex. I'm always frustrated when ...
label: Before submitting
options:
- label: I have searched the existing feature requests and did not find the same proposal.
required: true
- type: input
id: scope
attributes:
label: Affected device or area
description: Enter the device, board target, or firmware area. Enter "All devices" for a general request.
placeholder: ex. df-k10, audio, display, protocol, or All devices
validations:
required: true
- type: textarea
id: problem
attributes:
label: Problem or use case
description: Explain the current limitation and why the requested change would be useful.
validations:
required: true
- type: textarea
id: solution
attributes:
label: Describe the solution you'd like.
description: Please provide a clear and concise description of what you want to happen.
placeholder: ex. When using XiaoZhi ...
- type: textarea
id: alternatives
attributes:
label: Describe alternatives you've considered.
description: Please provide a clear and concise description of any alternative solutions or features you've considered.
placeholder: ex. Choosing other approach wouldn't work, because ...
label: Requested behavior
description: Describe the result or behavior you would like to see.
validations:
required: true
- type: textarea
id: context
attributes:
label: Additional context.
description: Please add any other context or screenshots about the feature request here.
placeholder: ex. This would work only when ...
label: Additional context
description: Optionally add alternatives, screenshots, diagrams, links, or testing information.
validations:
required: false
@@ -0,0 +1,67 @@
name: Board support request
description: Request support for a new board, product, hardware revision, or SKU
title: "[Board Support] "
labels: ['enhancement']
body:
- type: markdown
attributes:
value: |
Similar-looking products may use different pins and components. Please identify the exact hardware rather than only naming the closest supported board.
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I have searched the repository and existing issues for this exact model and hardware revision.
required: true
- type: textarea
id: hardware
attributes:
label: Exact hardware
description: Provide the manufacturer, model, revision/SKU, main chip and memory, and an official product or hardware documentation link.
placeholder: |
Manufacturer and model:
Revision or SKU:
Chip, flash, and PSRAM:
Official link:
validations:
required: true
- type: textarea
id: request
attributes:
label: Requested support
description: Describe what should work and any known differences from the closest supported board.
validations:
required: true
- type: textarea
id: attempted
attributes:
label: What have you tried?
description: Include the closest board target or firmware, its version, and the result. Enter "None" if no existing target is appropriate.
validations:
required: true
- type: dropdown
id: testing
attributes:
label: Testing available
options:
- I can build, flash, collect logs, and test changes
- I can flash binaries and test changes
- I can provide hardware information only
- I cannot test changes
validations:
required: true
- type: upload
id: attachments
attributes:
label: Optional hardware files, photos, or logs
description: Attach public schematics, board photos, pinouts, or serial logs. Do not upload confidential material or secrets.
validations:
required: false
accept: ".png,.jpg,.jpeg,.pdf,.log,.txt,.json,.zip"
@@ -0,0 +1,42 @@
name: Firmware development question
description: Ask a focused technical question about this repository or supported hardware
title: "[Question] "
labels: ['question']
body:
- type: markdown
attributes:
value: |
This form is for technical questions about the open-source firmware. For device unbinding, email [xiaozhi.ai@tenclass.com](mailto:xiaozhi.ai@tenclass.com?subject=Device%20unbinding%20request) privately with the device ID and MAC address. Other account and cloud-service requests belong on the [XiaoZhi AI website](https://xiaozhi.me/).
- type: checkboxes
id: checklist
attributes:
label: Before submitting
options:
- label: I have searched the repository and existing issues and did not find an answer.
required: true
- type: input
id: context
attributes:
label: Relevant device, version, or code area
description: Enter the device/model, board target, source or firmware version, file, or subsystem that matters to the question.
placeholder: ex. zhengchen-cam on v2.2.6, or main/audio on current main
validations:
required: true
- type: textarea
id: question
attributes:
label: Goal and question
description: Explain what you are trying to accomplish and state the specific technical question.
validations:
required: true
- type: textarea
id: attempted
attributes:
label: What have you tried?
description: Optionally include relevant files, commands, experiments, logs, screenshots, or a minimal example.
validations:
required: false
+6 -6
View File
@@ -1,8 +1,8 @@
blank_issues_enabled: true
blank_issues_enabled: false
contact_links:
- name: 小智 AI 官方网站
- name: Unbind a previously owned device
url: https://github.com/78/xiaozhi-esp32/blob/main/.github/SUPPORT.md#unbind-a-previously-owned-device
about: If the device is bound to another account, email its device ID and MAC address privately. Do not post these identifiers in a public issue.
- name: Other cloud services and account requests
url: https://xiaozhi.me/
about: 激活设备、配置 AI、声纹识别、声音克隆等应有尽有,DIY 属于你自己的小智
- name: 小智 AI 聊天机器人百科全书
url: https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb
about: 开发文档、硬件制作、烧录教程、FAQ尽在小智百科
about: Use the XiaoZhi AI website for activation, agent configuration, voiceprint, voice cloning, and other cloud services
+49
View File
@@ -0,0 +1,49 @@
# Account and Device Support
This GitHub repository tracks the open-source XiaoZhi device firmware. Account recovery, device ownership, and cloud-service requests are handled outside the public issue tracker.
## Unbind a Previously Owned Device
If you purchased or received a used device that is still bound to another person's account:
1. Collect the device ID and MAC address using the instructions below.
2. Email [xiaozhi.ai@tenclass.com](mailto:xiaozhi.ai@tenclass.com?subject=Device%20unbinding%20request) with both identifiers.
3. Use an email subject such as `Device unbinding / 解绑设备 - Device ID XXX - MAC address XXX`.
For multiple devices, attach a list containing the device ID and MAC address for each device.
> **Privacy:** Send device IDs and MAC addresses only by email. Do not post them in a public GitHub issue, discussion, screenshot, or log.
### Why Both Identifiers Are Required
- The **MAC address** uniquely identifies the physical device hardware.
- The **device ID** is obtained by asking the AI running on the device. Providing the value reported by the device helps demonstrate that the requester has the device in hand and can operate it.
Together, these identifiers help the support team locate the correct binding record and avoid unbinding the wrong device.
### Find the Device ID
If the device can connect and have a conversation, ask it:
> What is my device ID?
Record the complete value reported by the device. Both the device ID and MAC address are required for the standard unbinding process. If the device cannot have a conversation or does not return a device ID, explain this in the email; the support team may require other proof of possession. Do not guess or substitute a temporary activation code.
### Find the MAC Address
1. Connect the powered-on device to a computer with a data-capable USB cable.
2. Open the device's serial port with a serial terminal. Developers with an ESP-IDF environment can use `idf.py monitor`.
3. Keep the serial terminal open and restart the device so that the complete startup log is captured.
4. Search the log for a line similar to:
```text
wifi:mode : sta (aa:bb:cc:dd:ee:ff)
```
5. The value inside the parentheses is the Wi-Fi MAC address to include in the email.
If that line does not appear, search the startup log for `MAC` or `mac_address`. If you still cannot identify it, attach the exact device model and explain the situation in the private email. Do not upload the unredacted startup log to a public GitHub issue because it may contain the MAC address, UUID, Wi-Fi name, and other device information.
## Other Account and Cloud-Service Requests
For verification codes, password recovery, activation, agent configuration, voiceprint, voice cloning, and other cloud services, use the [XiaoZhi AI website](https://xiaozhi.me/).
+63 -37
View File
@@ -16,6 +16,8 @@ jobs:
prepare:
name: Determine variants to build
runs-on: ubuntu-latest
container:
image: espressif/idf:v6.0.1
outputs:
variants: ${{ steps.select.outputs.variants }}
steps:
@@ -24,16 +26,17 @@ jobs:
with:
fetch-depth: 0
- name: Install jq
run: sudo apt-get update && sudo apt-get install -y jq
- name: Test build tooling
run: python -m unittest discover -s scripts/tests -v
- id: list
name: Get all variant list
run: |
echo "all_variants=$(python scripts/release.py --list-boards --json)" >> $GITHUB_OUTPUT
echo "all_variants=$(python scripts/build.py --list-boards --json)" >> $GITHUB_OUTPUT
- id: select
name: Select variants based on changes
shell: bash
env:
ALL_VARIANTS: ${{ steps.list.outputs.all_variants }}
run: |
@@ -46,41 +49,20 @@ jobs:
fi
# pull_request 场景
BASE_SHA="${{ github.event.pull_request.base.sha }}"
HEAD_SHA="${{ github.event.pull_request.head.sha }}"
echo "Base: $BASE_SHA, Head: $HEAD_SHA"
# actions/checkout checks out GitHub's synthetic PR merge commit. Its
# first and second parents are the exact base and head commits used
# for this run, even when the event SHAs are not exposed as refs in
# the job container.
git config --global --add safe.directory "$GITHUB_WORKSPACE"
BASE_SHA="HEAD^1"
HEAD_SHA="HEAD^2"
echo "Base: $(git rev-parse "$BASE_SHA"), Head: $(git rev-parse "$HEAD_SHA")"
CHANGED=$(git diff --name-only $BASE_SHA $HEAD_SHA || true)
CHANGED=$(git diff --name-only "$BASE_SHA" "$HEAD_SHA")
echo -e "Changed files:\n$CHANGED"
NEED_ALL=0
declare -A AFFECTED
while IFS= read -r file; do
if [[ "$file" == main/* && "$file" != main/boards/* ]]; then
NEED_ALL=1
fi
if [[ "$file" == main/boards/common/* ]]; then
NEED_ALL=1
fi
if [[ "$file" == main/boards/* ]]; then
board=$(echo "$file" | cut -d '/' -f3)
AFFECTED[$board]=1
fi
done <<< "$CHANGED"
if [[ "$NEED_ALL" -eq 1 ]]; then
echo "variants=$ALL_VARIANTS" >> $GITHUB_OUTPUT
else
if [[ ${#AFFECTED[@]} -eq 0 ]]; then
echo "variants=[]" >> $GITHUB_OUTPUT
else
BOARDS_JSON=$(printf '%s\n' "${!AFFECTED[@]}" | sort -u | jq -R -s -c 'split("\n")[:-1]')
FILTERED=$(echo "$ALL_VARIANTS" | jq -c --argjson boards "$BOARDS_JSON" 'map(select(.board as $b | $boards | index($b)))')
echo "variants=$FILTERED" >> $GITHUB_OUTPUT
fi
fi
SELECTED=$(printf '%s\n' "$CHANGED" | python scripts/build.py --select-changed)
echo "variants=$SELECTED" >> $GITHUB_OUTPUT
build:
name: Build ${{ matrix.full_name }}
@@ -92,7 +74,7 @@ jobs:
include: ${{ fromJson(needs.prepare.outputs.variants) }}
runs-on: ubuntu-latest
container:
image: espressif/idf:v5.5.2
image: espressif/idf:v6.0.1
steps:
- name: Checkout
uses: actions/checkout@v4
@@ -101,7 +83,7 @@ jobs:
shell: bash
run: |
source $IDF_PATH/export.sh
python scripts/release.py ${{ matrix.board }} --name ${{ matrix.name }}
python scripts/build.py ${{ matrix.board }} --name ${{ matrix.name }}
- name: Upload artifacts
uses: actions/upload-artifact@v4
@@ -109,3 +91,47 @@ jobs:
name: xiaozhi_${{ matrix.full_name }}_${{ github.sha }}
path: build/merged-binary.bin
if-no-files-found: error
esp32s31-idf61:
name: Build ESP32-S31 with IDF 6.1
runs-on: ubuntu-latest
container:
image: espressif/idf:release-v6.1
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build ESP32-S31 preview variant
shell: bash
run: |
source $IDF_PATH/export.sh
python scripts/build.py espressif/esp32-s31-function-coreboard-1 \
--name esp32-s31-function-coreboard-1
- name: Upload artifact
uses: actions/upload-artifact@v4
with:
name: xiaozhi_espressif-esp32-s31-function-coreboard-1_${{ github.sha }}
path: build/merged-binary.bin
if-no-files-found: error
blufi-idf6:
name: IDF 6 BluFi option
runs-on: ubuntu-latest
container:
image: espressif/idf:v6.0.1
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build BluFi-enabled firmware
shell: bash
run: |
source $IDF_PATH/export.sh
idf.py -B build-blufi \
-DSDKCONFIG=build-blufi/sdkconfig \
-DSDKCONFIG_DEFAULTS="sdkconfig.defaults;scripts/ci/blufi.sdkconfig.defaults" \
-DIDF_TARGET=esp32s3 \
-DBOARD_NAME=bread-compact-wifi \
-DBOARD_TYPE=bread-compact-wifi \
build
+94
View File
@@ -0,0 +1,94 @@
# AGENTS.md
## Project
XiaoZhi is an ESP-IDF C/C++ voice-assistant firmware supporting many chips, boards, displays, audio devices, and network transports. A build selects exactly one board implementation.
Use ESP-IDF v6.0.2 when possible. IDF 5.5.x is retained only for documented legacy boards.
## Architecture
- `main/application.*`: main event loop, protocol lifecycle, and high-level behavior.
- `main/device_state_machine.*`: legal runtime state transitions.
- `main/boards/common/`: board interfaces and reusable hardware/network helpers.
- `main/boards/**/`: board-specific pins, initialization, and build variants.
- `main/audio/`: codecs, audio tasks, engines, wake words, and queues.
- `main/protocols/`: transport-neutral API plus WebSocket and MQTT/UDP.
- `main/display/` and `main/led/`: reusable UI implementations.
- `main/mcp_server.*`: common device-side MCP tools and dispatch.
- `main/Kconfig.projbuild`: board and feature configuration.
- `main/CMakeLists.txt`: source, board, locale, font, and asset selection.
- `scripts/build.py`: canonical board/variant build entry point.
Read the closest existing implementation before adding a new one. Prefer the narrowest owning layer; do not put board-specific behavior into core modules.
## Required Rules
- Preserve unrelated worktree changes and keep patches focused.
- A build must export exactly one board factory through `DECLARE_BOARD(...)`.
- Never alter an existing board's pins to support different hardware. Add a uniquely named board or release variant; board identity affects OTA compatibility.
- Core code depends on `Board` interfaces, never a concrete board class or board `config.h`.
- Treat camera, backlight, display, LED, battery, and similar capabilities as optional.
- Change runtime state through `Application::SetDeviceState()` and the state machine.
- Callbacks may run outside the main task. Schedule application mutations with `Application::Schedule()` or event bits.
- Do not block the main event loop or audio tasks. Avoid unbounded queues and repeated large allocations in audio paths.
- Keep shared message semantics in `Protocol`; verify both transports when changing its contract.
- Validate network input and preserve `cJSON` ownership. NVS keys are persistent API and require migration when changed.
- Guard target-specific features with Kconfig/component rules. Do not assume every target has PSRAM or S3/P4 resources.
- Do not manually edit generated/vendor output: `build/`, `releases/`, `managed_components/`, `components/`, `sdkconfig*`, `main/assets/lang_config.h`, or generated mmap headers.
- Format only touched C/C++ files with the repository `.clang-format`; avoid unrelated mass formatting.
## Boards and Configuration
Board selection is a coupled chain:
`config.json` -> `scripts/build.py` -> `main/Kconfig.projbuild` -> `main/CMakeLists.txt` -> board source and `config.h`.
When adding a board or variant, update every relevant link in that chain. Include a unique board identity, correct chip target, flash/partition settings, exactly one `DECLARE_BOARD`, and board documentation. Follow `docs/custom-board.md`.
## Commands
Source the intended ESP-IDF environment first:
```sh
source /path/to/esp-idf/export.sh
idf.py --version
```
```sh
# Discover exact board and variant names
python3 scripts/build.py --list-boards
# Canonical variant build
python3 scripts/build.py <board-directory> --name <variant-name>
# Host-side build tests
python3 -m unittest discover -s scripts/tests -v
# Format/check touched files
clang-format -i <files>
clang-format --dry-run -Werror <files>
```
The build script changes local `sdkconfig` and build state. Do not assume the build directory still represents a previous target.
## Validation
- Board-only change: build affected variants and smoke-test changed hardware.
- Core, common-board, audio, protocol, display, dependency, Kconfig, or CMake change: run host tests and build representative affected chip/network paths.
- Protocol changes: verify WebSocket and MQTT/UDP when shared behavior changes.
- Audio changes: verify capture, playback, wake/VAD, interruption, reconnect, and applicable AEC modes.
- UI/assets changes: verify applicable no-display/OLED/LVGL paths and partition size.
- Always report what was tested and what still needs physical hardware. A successful build is not hardware validation.
## Authoritative Documentation
- Overview and SDK policy: `README.md`
- SDK compatibility: `docs/esp-idf-6-migration.md`
- Board guide: `docs/custom-board.md`
- Audio design: `main/audio/README.md`
- Code style: `docs/code_style.md`
- Protocols: `docs/websocket.md`, `docs/mqtt-udp.md`, `docs/mcp-protocol.md`
- CI matrix: `.github/workflows/build.yml`
Keep detailed or fast-changing information in those files, not here. Add a nested `AGENTS.md` only when a subsystem needs specialized instructions.
+1 -1
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.5")
set(PROJECT_VER "2.4.0")
project(xiaozhi)
+19 -19
View File
@@ -12,26 +12,25 @@ As a voice interaction entry, the XiaoZhi AI chatbot leverages the AI capabiliti
<img src="docs/mcp-based-graph.jpg" alt="Control everything via MCP" width="320">
## Version Notes
## Recent Updates
The current v2 version is incompatible with the v1 partition table, so it is not possible to upgrade from v1 to v2 via OTA. For partition table details, see [partitions/v2/README.md](partitions/v2/README.md).
All hardware running v1 can be upgraded to v2 by manually flashing the firmware.
The stable version of v1 is 1.9.2. You can switch to v1 by running `git checkout v1`. The v1 branch will be maintained until February 2026.
- The mainline now targets ESP-IDF v6.0 or later, with v6.0.2 as the preferred stable SDK. The previous 157-variant baseline was validated on ESP-IDF v6.0.1; the current matrix contains 171 variants, of which 170 support IDF 6.0.x and the ESP32-S31 variant requires IDF 6.1 or later.
- MQTT and BluFi cryptographic code has migrated to PSA Crypto. IDF 6 component splits and third-party dependency compatibility have also been addressed.
- Audio pipeline concurrency, MQTT/UDP packet validation, and release-matrix selection have been hardened.
- ESP-IDF v5.5 is retained only for documented legacy boards. ESP32-P4 Rev1 and Rev3 are both supported on IDF 6 with ESP-SR 2.4.7; see the [ESP-IDF 6.0 Migration Guide](docs/esp-idf-6-migration.md) for full compatibility and board-validation details.
### Features Implemented
- Wi-Fi / ML307 Cat.1 4G
- Offline voice wake-up [ESP-SR](https://github.com/espressif/esp-sr)
- Supports two communication protocols ([Websocket](docs/websocket.md) or MQTT+UDP)
- Uses OPUS audio codec
- Voice interaction based on streaming ASR + LLM + TTS architecture
- Wi-Fi, wired Ethernet, USB RNDIS, and ML307/EC801E or NT26 Cat.1 4G networking; supported boards can switch between Wi-Fi and 4G
- Offline voice wake-up with [ESP-SR](https://github.com/espressif/esp-sr), including customizable wake words
- Two communication transports: [WebSocket](docs/websocket.md) and [MQTT + UDP](docs/mqtt-udp.md)
- Opus audio streaming with conventional streaming ASR + LLM + TTS pipelines and Realtime end-to-end voice models; AEC-capable hardware supports realtime full-duplex interaction
- Speaker recognition, identifies the current speaker [3D Speaker](https://github.com/modelscope/3D-Speaker)
- OLED / LCD display, supports emoji display
- OLED / LCD displays with emoji and rich expression support, plus camera vision input on supported boards
- Battery display and power management
- Multi-language support (Chinese, English, Japanese)
- Supports ESP32-C3, ESP32-S3, ESP32-P4 chip platforms
- 38 interface languages, with localized voice prompts where available and English fallback
- ESP32, ESP32-C3, ESP32-C5, ESP32-C6, ESP32-S3, and ESP32-P4 chip platforms
- Wi-Fi provisioning through hotspot or BluFi
- Device-side MCP for device control (Speaker, LED, Servo, GPIO, etc.)
- Cloud-side MCP to extend large model capabilities (smart home control, PC desktop operation, knowledge search, email, etc.)
- Customizable wake words, fonts, emojis, and chat backgrounds with online web-based editing ([Custom Assets Generator](https://github.com/78/xiaozhi-assets-generator))
@@ -48,10 +47,10 @@ Breadboard demo:
![Breadboard Demo](docs/v1/wiring2.jpg)
### Supports 70+ Open Source Hardware (Partial List)
### Supports 138 Board Directories and 171 Release Variants (Partial List)
- <a href="https://oshwhub.com/li-chuang-kai-fa-ban/li-chuang-shi-zhan-pai-esp32-s3-kai-fa-ban" target="_blank" title="LiChuang ESP32-S3 Development Board">LiChuang ESP32-S3 Development Board</a>
- <a href="https://github.com/espressif/esp-box" target="_blank" title="Espressif ESP32-S3-BOX3">Espressif ESP32-S3-BOX3</a>
- <a href="https://github.com/espressif/esp-box" target="_blank" title="Espressif ESP32-S3-BOX-3">Espressif ESP32-S3-BOX-3</a>
- <a href="https://docs.m5stack.com/zh_CN/core/CoreS3" target="_blank" title="M5Stack CoreS3">M5Stack CoreS3</a>
- <a href="https://docs.m5stack.com/en/atom/Atomic%20Echo%20Base" target="_blank" title="AtomS3R + Echo Base">M5Stack AtomS3R + Echo Base</a>
- <a href="https://gf.bilibili.com/item/detail/1108782064" target="_blank" title="Magic Button 2.4">Magic Button 2.4</a>
@@ -115,12 +114,13 @@ The firmware connects to the official [xiaozhi.me](https://xiaozhi.me) server by
### Development Environment
- Cursor or VSCode
- Install ESP-IDF plugin, select SDK version 5.4 or above
- Install the ESP-IDF plugin. [ESP-IDF v6.0.2](https://github.com/espressif/esp-idf/releases/tag/v6.0.2) is preferred; use a stable v6.0 or later release. ESP-IDF v5.5.2 is retained only for legacy board compatibility
- Linux is better than Windows for faster compilation and fewer driver issues
- This project uses Google C++ code style, please ensure compliance when submitting code
### Developer Documentation
- [ESP-IDF 6.0 Migration Guide](docs/esp-idf-6-migration.md) - SDK compatibility, component changes, legacy hardware support, and board validation status
- [Custom Board Guide](docs/custom-board.md) - Learn how to create custom boards for XiaoZhi AI
- [MCP Protocol IoT Control Usage](docs/mcp-usage.md) - Learn how to control IoT devices via MCP protocol
- [MCP Protocol Interaction Flow](docs/mcp-protocol.md) - Device-side MCP protocol implementation
@@ -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/C759fGMBcZ) 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: 1095994019
## 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>
+17 -17
View File
@@ -12,26 +12,25 @@
<img src="docs/mcp-based-graph.jpg" alt="MCPであらゆるものを制御" width="320">
## バージョンノート
## 最近の更新
現在のv2バージョンはv1パーティションテーブルと互換性がないため、v1からv2へOTAでアップグレードすることはできません。パーティションテーブルの詳細については、[partitions/v2/README.md](partitions/v2/README.md)をご参照ください
v1を実行しているすべてのハードウェアは、ファームウェアを手動で書き込むことでv2にアップグレードできます
v1の安定版は1.9.2です。`git checkout v1`でv1に切り替えることができます。v1ブランチは2026年2月まで継続的にメンテナンスされます。
- メインラインはESP-IDF v6.0以降へ移行し、推奨安定版はv6.0.2です。従来の157リリースバリアントはESP-IDF v6.0.1でビルド検証済みです。現在のマトリクスは171バリアントで、そのうち170はIDF 6.0.xに対応し、ESP32-S31バリアントにはIDF 6.1以降が必要です
- MQTTとBluFiの暗号処理をPSA Cryptoへ移行し、IDF 6のコンポーネント分割およびサードパーティ依存関係にも対応しました。
- オーディオパイプラインの並行処理、MQTT/UDPパケット検証、リリースマトリクス選択処理を強化しました
- ESP-IDF v5.5は、文書で明記された旧式ボード向けにのみ残しています。ESP-SR 2.4.7を使用すると、ESP32-P4 Rev1とRev3の両方がIDF 6に対応します。詳細な互換性とボード検証状況は、[ESP-IDF 6.0移行ガイド](docs/esp-idf-6-migration.md)を参照してください。
### 実装済み機能
- Wi-Fi / ML307 Cat.1 4G
- オフライン音声ウェイクアップ [ESP-SR](https://github.com/espressif/esp-sr)
- 2種類の通信プロトコルに対応([Websocket](docs/websocket.md) または MQTT+UDP
- OPUSオーディオコーデックを採用
- ストリーミングASR + LLM + TTSアーキテクチャに基づく音声インタラクション
- Wi-Fi、有線Ethernet、USB RNDIS、およびML307/EC801EまたはNT26 Cat.1 4Gに対応し、一部のボードではWi-Fiと4Gを切り替え可能
- [ESP-SR](https://github.com/espressif/esp-sr)によるオフライン音声ウェイクアップとカスタムウェイクワード
- 2種類の通信方式:[WebSocket](docs/websocket.md)と[MQTT + UDP](docs/mqtt-udp.md)
- Opusオーディオストリーミングにより、従来のストリーミングASR + LLM + TTS構成とRealtimeエンドツーエンド音声モデルの両方に対応。AEC対応ハードウェアではリアルタイム全二重対話が可能
- 話者認識、現在話している人を識別 [3D Speaker](https://github.com/modelscope/3D-Speaker)
- OLED / LCDディスプレイ、表情表示対応
- OLED / LCDディスプレイで絵文字や豊かな感情表現を表示し、一部のボードではカメラによる視覚入力にも対応
- バッテリー表示と電源管理
- 多言語対応(中国語、英語、日本語)
- ESP32-C3、ESP32-S3、ESP32-P4チッププラットフォーム対応
- 38言語の画面表示に対応し、音声プロンプトはローカライズ済みリソースを優先して、未収録時は英語へフォールバック
- ESP32、ESP32-C3、ESP32-C5、ESP32-C6、ESP32-S3、ESP32-P4チッププラットフォーム
- ホットスポットまたはBluFiによるWi-Fiプロビジョニング
- デバイス側MCPによるデバイス制御(音量・明るさ調整、アクション制御など)
- クラウド側MCPで大規模モデル能力を拡張(スマートホーム制御、PCデスクトップ操作、知識検索、メール送受信など)
- カスタマイズ可能なウェイクワード、フォント、絵文字、チャット背景、オンラインWeb編集に対応 ([カスタムアセットジェネレーター](https://github.com/78/xiaozhi-assets-generator))
@@ -48,10 +47,10 @@ Feishuドキュメントチュートリアルをご覧ください:
![ブレッドボードデモ](docs/v1/wiring2.jpg)
### 70種類以上のオープンソースハードウェアに対応(一部のみ表示)
### 138のボードディレクトリと171のリリースバリアントに対応(一部のみ表示)
- <a href="https://oshwhub.com/li-chuang-kai-fa-ban/li-chuang-shi-zhan-pai-esp32-s3-kai-fa-ban" target="_blank" title="立創・実戦派 ESP32-S3 開発ボード">立創・実戦派 ESP32-S3 開発ボード</a>
- <a href="https://github.com/espressif/esp-box" target="_blank" title="楽鑫 ESP32-S3-BOX3">楽鑫 ESP32-S3-BOX3</a>
- <a href="https://github.com/espressif/esp-box" target="_blank" title="楽鑫 ESP32-S3-BOX-3">楽鑫 ESP32-S3-BOX-3</a>
- <a href="https://docs.m5stack.com/zh_CN/core/CoreS3" target="_blank" title="M5Stack CoreS3">M5Stack CoreS3</a>
- <a href="https://docs.m5stack.com/en/atom/Atomic%20Echo%20Base" target="_blank" title="AtomS3R + Echo Base">M5Stack AtomS3R + Echo Base</a>
- <a href="https://gf.bilibili.com/item/detail/1108782064" target="_blank" title="マジックボタン2.4">マジックボタン2.4</a>
@@ -115,12 +114,13 @@ Feishuドキュメントチュートリアルをご覧ください:
### 開発環境
- Cursor または VSCode
- ESP-IDFプラグインをインストールし、SDKバージョン5.4以上を選択
- ESP-IDFプラグインをインストールし、[ESP-IDF v6.0.2](https://github.com/espressif/esp-idf/releases/tag/v6.0.2)を優先して使用してください。v6.0以降の安定版を推奨し、ESP-IDF v5.5.2は旧ハードウェアとの互換性維持にのみ使用します
- LinuxはWindowsよりも優れており、コンパイルが速く、ドライバの問題も少ない
- 本プロジェクトはGoogle C++コードスタイルを採用、コード提出時は準拠を確認してください
### 開発者ドキュメント
- [ESP-IDF 6.0移行ガイド](docs/esp-idf-6-migration.md) - SDK互換性、コンポーネント変更、旧ハードウェア対応、ボード検証状況
- [カスタム開発ボードガイド](docs/custom-board.md) - シャオジーAI用のカスタム開発ボード作成方法
- [MCPプロトコルIoT制御使用法](docs/mcp-usage.md) - MCPプロトコルでIoTデバイスを制御する方法
- [MCPプロトコルインタラクションフロー](docs/mcp-protocol.md) - デバイス側MCPプロトコルの実装方法
+22 -22
View File
@@ -12,26 +12,25 @@
<img src="docs/mcp-based-graph.jpg" alt="通过MCP控制万物" width="320">
### 版本说明
## 近期更新
当前 v2 版本与 v1 版本分区表不兼容,所以无法从 v1 版本通过 OTA 升级到 v2 版本。分区表说明参见 [partitions/v2/README.md](partitions/v2/README.md)
使用 v1 版本的所有硬件,可以通过手动烧录固件来升级到 v2 版本
v1 的稳定版本为 1.9.2,可以通过 `git checkout v1` 来切换到 v1 版本,该分支会持续维护到 2026 年 2 月。
- 项目主线现已迁移到 ESP-IDF v6.0 或以上版本,首选稳定版为 v6.0.2;此前的 157 个发布变体已在 ESP-IDF v6.0.1 上通过构建验证。当前矩阵包含 171 个变体,其中 170 个支持 IDF 6.0.xESP32-S31 变体需要 IDF 6.1 或以上版本
- MQTT 和 BluFi 加密已迁移到 PSA Crypto,同时完成了 IDF 6 组件拆分及第三方依赖兼容处理。
- 加固了音频流水线并发、MQTT/UDP 数据包校验和发布矩阵选择逻辑
- ESP-IDF v5.5 仅保留用于文档明确标注的旧版板卡;使用 ESP-SR 2.4.7 时,ESP32-P4 Rev1 和 Rev3 均支持 IDF 6。完整兼容性和板卡验证状态请参阅 [ESP-IDF 6.0 迁移文档](docs/esp-idf-6-migration.md)。
### 已实现功能
- Wi-Fi / ML307 Cat.1 4G
- 离线语音唤醒 [ESP-SR](https://github.com/espressif/esp-sr)
- 支持两种通信协议([Websocket](docs/websocket.md) 或 MQTT+UDP
- 采用 OPUS 音频编解码
- 基于流式 ASR + LLM + TTS 架构的语音交互
- 支持 Wi-Fi、有线以太网、USB RNDIS,以及 ML307/EC801E 或 NT26 Cat.1 4G 网络;部分硬件支持 Wi-Fi 与 4G 切换
- 基于 [ESP-SR](https://github.com/espressif/esp-sr) 的离线语音唤醒,支持自定义唤醒词
- 支持两种通信传输方式:[WebSocket](docs/websocket_zh.md) 和 [MQTT + UDP](docs/mqtt-udp_zh.md)
- 采用 Opus 音频流,既支持传统的流式 ASR + LLM + TTS 方案,也支持 Realtime 端到端语音模型;具备 AEC 的硬件可实现实时全双工交互
- 声纹识别,识别当前说话人的身份 [3D Speaker](https://github.com/modelscope/3D-Speaker)
- OLED / LCD 显示屏,支持表情显示
- OLED / LCD 显示屏,支持表情和丰富的情绪呈现;部分硬件支持摄像头视觉输入
- 电量显示与电源管理
- 支持多语言(中文、英文、日文)
- 支持 ESP32-C3、ESP32-S3、ESP32-P4 芯片平台
- 提供 38 种界面语言;语音提示优先使用本地化资源,缺失时自动回退到英文
- 支持 ESP32、ESP32-C3、ESP32-C5、ESP32-C6、ESP32-S3、ESP32-P4 芯片平台
- 支持热点和 BluFi 两种 Wi-Fi 配网方式
- 通过设备端 MCP 实现设备控制(音量、灯光、电机、GPIO 等)
- 通过云端 MCP 扩展大模型能力(智能家居控制、PC桌面操作、知识搜索、邮件收发等)
- 自定义唤醒词、字体、表情与聊天背景,支持网页端在线修改 ([自定义Assets生成器](https://github.com/78/xiaozhi-assets-generator))
@@ -48,10 +47,10 @@ v1 的稳定版本为 1.9.2,可以通过 `git checkout v1` 来切换到 v1 版
![面包板效果图](docs/v1/wiring2.jpg)
### 支持 70 多个开源硬件(仅展示部分)
### 支持 138 个板卡目录、171 个固件发布变体(仅展示部分)
- <a href="https://oshwhub.com/li-chuang-kai-fa-ban/li-chuang-shi-zhan-pai-esp32-s3-kai-fa-ban" target="_blank" title="立创·实战派 ESP32-S3 开发板">立创·实战派 ESP32-S3 开发板</a>
- <a href="https://github.com/espressif/esp-box" target="_blank" title="乐鑫 ESP32-S3-BOX3">乐鑫 ESP32-S3-BOX3</a>
- <a href="https://github.com/espressif/esp-box" target="_blank" title="乐鑫 ESP32-S3-BOX-3">乐鑫 ESP32-S3-BOX-3</a>
- <a href="https://docs.m5stack.com/zh_CN/core/CoreS3" target="_blank" title="M5Stack CoreS3">M5Stack CoreS3</a>
- <a href="https://docs.m5stack.com/en/atom/Atomic%20Echo%20Base" target="_blank" title="AtomS3R + Echo Base">M5Stack AtomS3R + Echo Base</a>
- <a href="https://gf.bilibili.com/item/detail/1108782064" target="_blank" title="神奇按钮 2.4">神奇按钮 2.4</a>
@@ -115,17 +114,18 @@ v1 的稳定版本为 1.9.2,可以通过 `git checkout v1` 来切换到 v1 版
### 开发环境
- Cursor 或 VSCode
- 安装 ESP-IDF 插件,选择 SDK 版本 5.4 或以上
- 安装 ESP-IDF 插件,首选 [ESP-IDF v6.0.2](https://github.com/espressif/esp-idf/releases/tag/v6.0.2),建议使用 v6.0 或以上的稳定版本;ESP-IDF v5.5.2 仅保留用于旧版硬件兼容
- Linux 比 Windows 更好,编译速度快,也免去驱动问题的困扰
- 本项目使用 Google C++ 代码风格,提交代码时请确保符合规范
### 开发者文档
- [自定义开发板指南](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)
- [ESP-IDF 6.0 迁移文档](docs/esp-idf-6-migration.md) - SDK 兼容性、组件变更、旧版硬件支持和板卡验证状态
- [自定义开发板指南](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)
## 大模型配置
+25 -28
View File
@@ -1,37 +1,34 @@
# BluFi 配网(集成 esp-wifi-connect
# BluFi Provisioning (with `esp-wifi-connect`)
本文档说明如何在小智固件中启用和使用 BluFiBLE WiFi 配网),并结合项目内置的 `esp-wifi-connect` 组件完成 Wi‑Fi 连接与存储。官方
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
View File
@@ -0,0 +1,37 @@
# BluFi 配网(集成 esp-wifi-connect
本文档说明如何在小智固件中启用和使用 BluFi(BLE Wi‑Fi 配网),并结合项目内置的 `esp-wifi-connect` 组件完成 Wi‑Fi 连接与存储。官方
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 或自研客户端)连接设备,发送 Wi‑Fi SSID/密码,手机端可以通过blufi协议获取设备端扫描到的WiFi列表。
2) 设备侧在 `ESP_BLUFI_EVENT_REQ_CONNECT_TO_AP` 中将凭据写入 `SsidManager`(存储到 NVS,属于 `esp-wifi-connect` 组件)。
3) 随后启动 `WifiStation` 扫描并连接;状态通过 BluFi 返回。
4) 配网成功后设备会自动连接新 Wi‑Fi;失败则返回失败状态。
## 使用步骤
1. 配置:在 menuconfig 开启 `ESP-BluFi`。编译并烧录固件。
2. 触发配网:设备首次启动且没有已保存的 Wi‑Fi 时会自动进入配网。
3. 手机端操作:打开 EspBlufi App(或其他 BluFi 客户端),搜索并连接设备,可以选择是否加密,按提示输入 Wi‑Fi SSID/密码并发送。
4. 观察结果:
- 成功:BluFi 报告连接成功,设备自动连接 Wi‑Fi。
- 失败:BluFi 返回失败状态,可重新发送或检查路由器。
## 注意事项
- BluFi 配网不支持与热点配网同时开启。如果热点配网已经启动,则默认使用热点配网。请在 menuconfig 中只保留一种配网方式。
- 若多次测试,建议清除或覆盖存储的 SSID(`wifi` 命名空间),避免旧配置干扰。
- 如果使用自定义 BluFi 客户端,需遵循官方协议帧格式,参考上文官方文档链接。
- 官方文档中已提供EspBlufi APP下载地址
- 由于IDF5.5.2的blufi接口发生变化,5.5.2版本编译后蓝牙名称为"Xiaozhi-Blufi",5.5.1版本中蓝牙名称为"BLUFI_DEVICE"
+47 -48
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
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。
+238 -176
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/build.py [board-directory]` to build 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` - reported board type and release configuration consumed by CMake and `scripts/build.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,17 @@ mkdir main/boards/my-custom-board
#### config.json
`config.json`中定义编译配置,这个文件用于 `scripts/release.py` 脚本自动化编译:
`config.json` defines the compatibility-sensitive reported type and drives `scripts/build.py`:
```json
{
"target": "esp32s3", // 目标芯片型号: esp32, esp32s3, esp32c3, esp32c6, esp32p4等
"type": "my-custom-board",
"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 +107,51 @@ mkdir main/boards/my-custom-board
}
```
**配置项说明:**
- `target`: 目标芯片型号,必须与硬件匹配
- `name`: 编译输出的固件包名称,建议与目录名一致
- `sdkconfig_append`: 额外的 sdkconfig 配置项数组,会追加到默认配置中
**Fields**:
- `type`: compatibility-sensitive board family reported by the firmware. Keep it stable after release.
- `target`: target chip, must match the real hardware (`esp32`, `esp32s3`, `esp32c3`, `esp32c6`, `esp32p4`, ...).
- `name`: compatibility-sensitive firmware variant name reported by release builds; typically matches `type`.
- `sdkconfig_append`: extra sdkconfig lines merged into the defaults.
Both `type` and `name` must contain only lowercase letters, digits, periods
(`.`), and hyphens (`-`). Underscores, spaces, and uppercase letters are not
allowed.
**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_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_LANGUAGE_EN_US=y" // 英语
"CONFIG_LANGUAGE_ZH_CN=y" // 简体中文
// 唤醒词配置
"CONFIG_USE_DEVICE_AEC=y" // 启用设备端 AEC
"CONFIG_WAKE_WORD_DISABLED=y" // 禁用唤醒词
// Audio pipeline
"CONFIG_USE_DEVICE_AEC=y" // enable on-device AEC
```
### 3. 编写板级初始化代码
The project defaults to 16MB flash and `partitions/v2/16m.csv` on applicable
targets. Do not repeat values that already match the effective project and
target defaults; use `sdkconfig_append` only for actual board-specific
overrides.
创建一个`my_custom_board.cc`文件,实现开发板的所有初始化逻辑。
Do not select a language or a specific wake word in a board `config.json`.
Those are user build options and must be configured consistently through
`menuconfig` or build-script parameters so CLI, agent, and online builds can
share the same interface.
一个基本的开发板类定义包含以下几个部分:
### 3. Implement the Board Class
1. **类定义**:继承自`WifiBoard``Ml307Board`
2. **初始化函数**:包括I2C、显示屏、按钮、IoT等组件的初始化
3. **虚函数重写**:如`GetAudioCodec()``GetDisplay()``GetBacklight()`
4. **注册开发板**:使用`DECLARE_BOARD`宏注册开发板
Create `my_custom_board.cc` containing the board-level implementation.
A basic board class has:
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 +174,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 +190,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 +201,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 +212,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 +231,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 +258,258 @@ 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_DIR "my-custom-board")
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 `build.py` (recommended)
如果你的开发板目录下有 `config.json` 文件,可以使用此脚本自动完成配置和编译:
If the board directory contains a `config.json`, you can configure and build it automatically:
```bash
python scripts/release.py my-custom-board
python scripts/build.py my-custom-board
```
此脚本会自动:
- 读取 `config.json` 中的 `target` 配置并设置目标芯片
- 应用 `sdkconfig_append` 中的编译选项
- 完成编译并打包固件
Language and wake-word selection are user build options:
### 6. 创建README.md
```bash
python scripts/build.py my-custom-board \
--language en-US \
--wake-word wn9_jarvis_tts
```
在README.md中说明开发板的特性、硬件要求、编译和烧录步骤:
`--language` accepts a locale listed under `main/assets/locales/`.
`--wake-word` accepts an ESP-SR model name, `nihaoxiaozhi` (which selects the
compatible model for the target), or `disabled`. ESP32-C3/C5/C6 targets support
WakeNet9s (`wn9s_*`) models; ESP32-S3/P4/S31 builds automatically use the AFE
wake-word engine.
Query the accepted values in text or machine-readable form:
## 常见开发板组件
```bash
python scripts/build.py --list-languages
python scripts/build.py --list-languages --json
python scripts/build.py --list-wake-words
python scripts/build.py --list-wake-words --json
```
### 1. 显示屏
The wake-word list is read from the currently resolved ESP-SR component, so
run `idf.py reconfigure` first if `managed_components/` has not been populated.
项目支持多种显示屏驱动,包括:
The script:
- Prints help when run without arguments. Use `--list-boards` to list board
types and variants.
- Prompts for a variant when the selected board has multiple builds. In
non-interactive environments, pass `--name <variant>`.
- Reads `target` from `config.json`. It calls `idf.py set-target` only when the
target changes, then regenerates `sdkconfig` from defaults and the selected
build's `sdkconfig_append`.
- Passes the selected build's `name` as the reported firmware variant name.
- Builds `build/merged-binary.bin` without creating a ZIP by default. Pass
`--zip` to recreate `releases/v<version>_<name>.zip`.
### 6. Write the README
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/`.
For a board in a manufacturer sub-directory, add the same value to `config.json`, for example `"manufacturer": "waveshare"`. The firmware reports it as `board.manufacturer` together with `board.type` and `board.name`. Flat community boards without this field report an empty manufacturer string.
Set `BOARD_DIR` to the complete path relative to `main/boards/`:
```cmake
elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_NANO)
set(BOARD_DIR "waveshare/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)
```
The build system loads sources from `main/boards/${BOARD_DIR}/` and reads the reported board type from that directory's `config.json`. If `config.json` or its top-level `type` is absent, the full `BOARD_DIR` with `/` replaced by `-` is used as the fallback type.
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.
- `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
+490
View File
@@ -0,0 +1,490 @@
# 自定义开发板指南
本指南介绍如何为小智AI语音聊天机器人项目定制一个新的开发板初始化程序。小智AI支持70多种ESP32系列开发板,每个开发板的初始化代码都放在对应的目录下。
## 重要提示
> **警告**: 对于自定义开发板,当IO配置与原有开发板不同时,切勿直接覆盖原有开发板的配置编译固件。必须创建新的开发板类型,或者通过config.json文件中的builds配置不同的name和sdkconfig宏定义来区分。使用 `python scripts/build.py [开发板目录名字]` 来编译固件。
>
> 如果直接覆盖原有配置,将来OTA升级时,您的自定义固件可能会被原有开发板的标准固件覆盖,导致您的设备无法正常工作。每个开发板有唯一的标识和对应的固件升级通道,保持开发板标识的唯一性非常重要。
## 目录结构
每个开发板的目录结构通常包含以下文件:
- `xxx_board.cc` - 主要的板级初始化代码,实现了板子相关的初始化和功能
- `config.h` - 板级配置文件,定义了硬件管脚映射和其他配置项
- `config.json` - 上报开发板类型及发布配置,供 CMake 和 `scripts/build.py` 使用
- `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`中定义兼容性相关的上报类型和自动化编译配置:
```json
{
"type": "my-custom-board", // 固件上报的开发板类型,发布后应保持稳定
"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\""
]
}
]
}
```
**配置项说明:**
- `manufacturer`: 厂商子目录名称;使用厂商目录时必须填写,并作为 `board.manufacturer` 上报。平铺社区板可以省略,上报为空字符串
- `type`: 固件上报的开发板系列类型,发布后应保持稳定
- `target`: 目标芯片型号,必须与硬件匹配
- `name`: release 构建上报的固件变体名称,通常与 `type` 一致
- `sdkconfig_append`: 额外的 sdkconfig 配置项数组,会追加到默认配置中
`type``name` 只能包含小写字母、数字、点(`.`)和连字符(`-`),
不允许使用下划线、空格或大写字母。
**常用的 sdkconfig_append 配置:**
```json
// Flash 大小
"CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y" // 4MB Flash
"CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y" // 8MB Flash
// 分区表
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/4m.csv\"" // 4MB 分区表
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\"" // 8MB 分区表
// 音频处理
"CONFIG_USE_DEVICE_AEC=y" // 启用设备端 AEC
```
对于适用的目标芯片,项目默认使用 16MB Flash 和
`partitions/v2/16m.csv`。如果板子配置与项目及目标芯片的有效默认值相同,
不要在 `sdkconfig_append` 中重复填写;这里应只保留板子确实需要的覆盖项。
不要在板子的 `config.json` 中选择语言或具体唤醒词。这些属于用户构建选项,
应统一通过 `menuconfig` 或构建脚本参数配置,方便 CLI、Agent 和在线编译接口
共用同一套参数。
### 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_DIR "my-custom-board") # 相对于 main/boards 的完整路径
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
```
#### 方法二:使用 build.py 脚本(推荐)
如果你的开发板目录下有 `config.json` 文件,可以使用此脚本自动完成配置和编译:
```bash
python scripts/build.py my-custom-board
```
语言和唤醒词属于用户构建参数:
```bash
python scripts/build.py my-custom-board \
--language en-US \
--wake-word wn9_jarvis_tts
```
`--language` 接受 `main/assets/locales/` 下已有的 locale`--wake-word`
接受 ESP-SR 模型名、`nihaoxiaozhi`(自动选择与目标芯片兼容的模型)或
`disabled`。ESP32-C3/C5/C6 仅支持 WakeNet9s`wn9s_*`)模型;
ESP32-S3/P4/S31 构建会自动使用 AFE 唤醒引擎。
可以用文本或 JSON 格式查询可用值:
```bash
python scripts/build.py --list-languages
python scripts/build.py --list-languages --json
python scripts/build.py --list-wake-words
python scripts/build.py --list-wake-words --json
```
唤醒词列表从当前已解析的 ESP-SR 组件读取。如果尚未生成
`managed_components/`,请先运行 `idf.py reconfigure`。
此脚本会自动:
- 不传参数时打印帮助;使用 `--list-boards` 列出所有开发板类型和变体
- 如果开发板有多个变体,交互式提示选择;非交互环境使用 `--name <变体>`
- 读取 `config.json` 中的 `target`,仅在目标芯片变化时调用
`idf.py set-target`,然后根据 defaults 和所选变体的
`sdkconfig_append` 重新生成 `sdkconfig`
- 将所选 build 的 `name` 作为固件上报的变体名称
- 默认生成 `build/merged-binary.bin`,不创建 ZIP;指定 `--zip` 时会重新生成
`releases/v<版本>_<名称>.zip`
### 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
+257
View File
@@ -0,0 +1,257 @@
# ESP-IDF 6.0 Migration and Board Compatibility Status
> Last updated: 2026-07-24
> Validated SDK: ESP-IDF v6.0.1
> Scope: 138 board directories and 171 supported build variants defined by `main/boards/**/config.json`.
## Current Status
The current release matrix contains 171 variants: 170 select on IDF 6.0.x, while the ESP32-S31 variant requires IDF 6.1 or later. The previous 157-variant baseline passed the latest complete GitHub Actions matrix on ESP-IDF 6.0.1. ESP-SR 2.4.7 adds IDF 6 support for ESP32-P4 Rev < 3, so the 14 unsuffixed Rev1 variants now join their existing `-p4x` Rev3 counterparts in the IDF 6 matrix. Component versions that use ranges are resolved from the Component Registry at build time; until per-target lock snapshots are committed, this is a source-reproducible build rather than a bit-for-bit dependency-reproducible build.
| Status | Variants | Meaning |
|---|---:|---|
| ✅ Previously matrix-validated | 157 | All 157 baseline builds completed in GitHub Actions with ESP-IDF 6.0.1; this does not imply hardware or complete peripheral validation |
| 🟡 Net variants added since full-matrix validation | 14 | Includes the 14 newly enabled P4 Rev1 variants and the IDF 6.1-only ESP32-S31 variant, after retired low-use variants were removed; a complete current-matrix CI run remains pending |
| 🟡 Feature-degraded subset | 1 | `esp-vocat` builds on IDF 6, but its PCB capacitive slider/button support is disabled pending compatible touch-sensor components |
| 🔴 Build-blocked | 0 | No supported release variant remains blocked at compile or link time |
There are no known remaining IDF 6 build blockers. [`78/esp_lcd_nv3023 1.0.1`](https://components.espressif.com/components/78/esp_lcd_nv3023/versions/1.0.1) and [`wvirgil123/sscma_client 1.0.3`](https://components.espressif.com/components/wvirgil123/sscma_client/versions/1.0.3/readme) are consumed directly from the Component Registry, so local copies under the ignored `components/` directory are not required. ESP32-P4 Rev1 and Rev3 now share the IDF 6 build path while retaining separate artifact names and silicon-selection settings.
Representative local full-build results are shown below. Firmware size and free space are reported by ESP-IDF 6.0.1 `check_sizes.py`. The authoritative per-variant compatibility result is the full GitHub Actions matrix in the next section.
| Chip | Build variant | Application size | Free space in smallest app partition |
|---|---|---:|---:|
| ESP32 | `bread-compact-esp32` | `0x240560` | 23% |
| ESP32-C3 | `espressif-esp-hi` | `0x20ef10` | 30% |
| ESP32-C3 | `xmini-c3` | `0x2370f0` | 44% |
| ESP32-C5 | `waveshare-esp32-c5-touch-lcd-1.69` | `0x27cd30` | 37% |
| ESP32-C6 | `waveshare-esp32-c6-lcd-0.85` | `0x280ee0` | 15% |
| ESP32-S3 | `doit-s3-aibox` | `0x21edf0` | 46% |
| ESP32-S3 | `lilygo-t-display-s3-pro-mvsrlora` | `0x2adce0` | 32% |
| ESP32-S3 | `lilygo-t-circle-s3` | `0x2a32f0` | 33% |
| ESP32-S3 | `zhengchen-1.54tft-wifi` | `0x2abdc0` | 32% |
| ESP32-S3 | `kevin-yuying-313lcd` | `0x2c4990` | 30% |
| ESP32-S3 | `otto-robot` | `0x37ff70` | 11% |
| ESP32-S3 | `espressif-esp-vocat` | `0x2742f0` | 38% |
| ESP32-S3 | `sensecap-watcher` | `0x2faff0` | 25% |
| ESP32-P4 Rev1 | `espressif-esp32-p4-function-ev-board` | `0x345a90` | 17% |
| ESP32-P4 Rev3 | `espressif-esp32-p4x-function-ev-board` | `0x34a150` | 16% |
| ESP32-P4 v3.x | `m5stack-tab5-p4x` | `0x380790` | 11% |
The old negative result for `esp32-p4-function-ev-board` came from ESP-SR 2.4.6. ESP-SR 2.4.7 supplies the missing ESP32-P4 Rev < 3 libraries for IDF 6, so the project no longer version-gates the P4 variants.
For backward-compatibility regression coverage, `xmini-c3` also completed a full build with ESP-IDF 5.5.4 (application size `0x234920`, 44% free in the smallest app partition). The legacy `esp32-p4-function-ev-board` release variant subsequently completed the same 5.5.4 release flow (application size `0x38a130`, 10% free), including merged-binary packaging. This confirms that the compatibility changes for I2S port numbering, LCD I2C configuration, the UHCI DMA dependency, and the pre-v3 P4 selection did not break the existing 5.5 build chain.
## Full-Matrix CI Validation
GitHub Actions run [29534954031](https://github.com/78/xiaozhi-esp32/actions/runs/29534954031) built the then-current matrix with the `espressif/idf:v6.0.1` container. The matrix-generation job and all 157 board builds passed. This historical run predates the current 170-variant IDF 6.0.x matrix.
Results by chip target:
| Chip target | Variants | Passed | Blocked |
|---|---:|---:|---:|
| ESP32 | 7 | 7 | 0 |
| ESP32-C3 | 9 | 9 | 0 |
| ESP32-C5 | 4 | 4 | 0 |
| ESP32-C6 | 9 | 9 | 0 |
| ESP32-S3 | 114 | 114 | 0 |
| ESP32-P4 v3.x | 14 | 14 | 0 |
| **Total** | **157** | **157** | **0** |
`esp-vocat` remains feature-degraded because IDF 6 builds omit the PCB capacitive slider/button path; its CST816 display touch remains enabled.
## ESP32-P4 Silicon Scope and Naming
The IDF 6 release matrix supports both ESP32-P4 Rev < 3 and Rev >= 3 silicon. The two silicon families keep distinct artifact names and sdkconfig settings:
| SDK | ESP32-P4 Rev < 3 | ESP32-P4 Rev >= 3 |
|---|---|---|
| ESP-IDF < 6 | Original `esp32-p4-` name; adds `CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y` and `CONFIG_ESP32P4_REV_MIN_100=y` | Uses `esp32-p4x-` when the variant starts with the chip name; otherwise retains the established `-p4x` suffix |
| ESP-IDF >= 6 | Original `esp32-p4-` name; adds `CONFIG_ESP32P4_SELECTS_REV_LESS_V3=y` and `CONFIG_ESP32P4_REV_MIN_100=y` | Uses `esp32-p4x-` when the variant starts with the chip name; otherwise retains the established `-p4x` suffix |
ESP-IDF 5.5 and 6.0.x each select 170 variants: 14 P4X artifacts, 14 Rev < 3 P4 artifacts with their original names, and 142 non-P4 variants. ESP-IDF 6.1 or later additionally selects the ESP32-S31 build, for 171 variants in total.
Espressif's current chip-identification table lists v0.0, v1.0, v1.3, v3.0, v3.1, and v3.2, with no v4.0 revision. The public errata history added v3.0/v3.1 information on 2026-02-12 and v3.2 information on 2026-04-20. ESP-IDF 6.0 release notes explicitly describe support for "ESP32-P4 Version3 silicon." See the official [chip revision identification](https://docs.espressif.com/projects/esp-chip-errata/en/latest/esp32p4/01-chip-identification/index.html), [errata revision history](https://docs.espressif.com/projects/esp-chip-errata/en/latest/esp32p4/revision-history/index.html), and [ESP-IDF releases](https://github.com/espressif/esp-idf/releases).
## Component Compatibility
| Component/module | Version or approach for IDF 6 | Status | Notes |
|---|---|---|---|
| `78/uart-uhci` | `0.3.2` | ✅ Upstream support | The registry release compiles under IDF 6.0.1 in GitHub Actions |
| `78/uart-eth-modem` | `0.6.0` | ✅ Upstream support | Pinned because it has the required RF-test event API while supporting both ESP-IDF 5.5.2+ and 6.0.1; version 0.6.1 requires IDF 6.0.1+ |
| `espressif/mqtt` | `1.0.0` | ✅ Upstream support | MQTT moved from a built-in SDK component to a Component Manager dependency in IDF 6 |
| `78/esp-ml307` | `3.6.6` + project-level `espressif/mqtt` | ✅ Builds in CI | The upstream source compiles under IDF 6.0.1 when the project supplies the MQTT component moved out of IDF |
| `espressif/esp_hosted` / `esp_wifi_remote` | `2.12.11` / `1.6.2` | ✅ Upstream support | Used for ESP32-P4 Hosted Wi-Fi |
| `espressif/esp_video` | `^2.0.1` | ✅ Upstream support | Currently resolves to 2.3.0 on S3 and 2.0.1 on P4 due to BSP constraints |
| P4 BSP / LCD drivers | BSP `5.2.3`, EK79007/ST7701 `2.0.x` | ✅ Upstream support | Resolves IDF 6 DPI, color-field, and split-driver-component issues |
| `espressif/esp_lcd_st77916` | `2.0.2` | ✅ Upstream support | Major version 2 uses the IDF 6 panel I/O definitions |
| `espressif/esp_lcd_spd2010` | `^2.0.0` (resolved `2.0.0~1`) | ✅ Upstream support | Major version 2 declares ESP-IDF 6 compatibility |
| `espressif/esp_lcd_co5300` | `2.1.0` | ✅ Upstream support | Used with explicit IDF 6-compatible QSPI I/O configuration in board code |
| `esp_emote_expression` | `1.0.2` | ✅ Upstream support | Replaces the previous dependency on the built-in `json` component |
| `wvirgil123/sscma_client` | [`1.0.3`](https://components.espressif.com/components/wvirgil123/sscma_client/versions/1.0.3/readme) | ✅ Upstream support | Uses `espressif/cjson` and the split driver components on IDF 6 while retaining the legacy component names on older IDF releases |
| `espressif/servo` / `espfriends/servo_dog_ctrl` | `1.0.0` / `0.2.0` | ✅ Upstream support | Uses official registry releases; compilation and linking were validated in the full IDF 6.0.1 build of `esp-hi`, with no local override required |
| `78/esp_lcd_nv3023` | [`1.0.1`](https://components.espressif.com/components/78/esp_lcd_nv3023/versions/1.0.1) | ✅ Upstream-based registry release | Mirrors MakerM0 upstream commit `15dae953`; adds IDF 6 color-order and GPIO compatibility while retaining older IDF branches; validated through the registry in a clean `magiclick-c3` full build |
| `llgok/cpp_bus_driver` | Excluded from the IDF 6 baseline | ⚪ Waiting for upstream | Version `2.1.0` still requires local patches for IDF 6. It is used only by the `LILYGO T-Display-P4` source, and that board has no release `config.json`, so the component has been removed until upstream support is available |
| MQTT protocol AES-CTR | PSA Crypto | ✅ Ported | Replaces the legacy AES context API removed by IDF 6 / Mbed TLS 4 |
| BluFi security negotiation (conditional path) | PSA FFDH + SHA-256 + AES-CTR | ✅ Ported | Uses the ESP-IDF 6 security scheme with ffdhe3072 and passed local full ESP32-S3 builds with BluFi enabled on IDF 5.5.4 and 6.0.1. Legacy 1024-bit BluFi clients are not compatible and must be upgraded |
| `espressif/bmi270_sensor` | [`0.1.2`](https://components.espressif.com/components/espressif/bmi270_sensor/versions/0.1.2/readme?language=en) | ✅ Upstream support | Provides IDF 6.0 prebuilt libraries for ESP32-C5 and ESP32-S3; validated by a full `esp-spot-c5` build |
| `touch_slider_sensor` / `touch_button_sensor` | Disabled for IDF 6 | 🟡 Feature gap | Their manifests require IDF < 6.0, so ESP Vocat omits its PCB capacitive slider/button path on IDF 6. CST816 display touch is unaffected |
| ESP32-P4 Rev < 3 / `espressif/esp-sr` | `~2.4.7` | ✅ Upstream support | The 14 Rev1 variants are available on IDF 5.5 and IDF 6; P4X-specific names continue to select Rev >= 3 |
## Per-Board Progress
In the table below, "Board" is the source directory and "Build variant" is the firmware name used by the build script. Different chip revisions of the same board may have different status, so they are split into separate rows when necessary.
| Chip | Board | Build variant | IDF 6.0 status | Current validation | Blocker/next step |
|---|---|---|---|---|---|
| `esp32` | `atommatrix-echo-base` | `atommatrix-echo-base` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32` | `bread-compact-esp32` | `bread-compact-esp32` | ✅ Full build passed | IDF 6.0.1 `idf.py build` | Hardware smoke/peripheral regression pending |
| `esp32` | `bread-compact-esp32` | `bread-compact-esp32-128x32` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32` | `bread-compact-esp32-lcd` | `bread-compact-esp32-lcd` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32` | `wdmomo/esp32-cgc` | `wdmomo-esp32-cgc` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32` | `wdmomo/esp32-cgc-144` | `wdmomo-esp32-cgc-144` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32` | `waveshare/esp32-touch-lcd-3.5` | `waveshare-esp32-touch-lcd-3.5` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c3` | `espressif/esp-hi` | `espressif-esp-hi` | ✅ Full build passed | IDF 6.0.1 `idf.py build`; `servo_dog_ctrl 0.2.0` | Hardware smoke, servo, and audio peripheral regression pending |
| `esp32c3` | `kevin/c3` | `kevin-c3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c3` | `lckfb/szpi-esp32c3` | `lckfb-lichuang-c3-dev` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c3` | `magiclick-c3` | `magiclick-c3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c3` | `magiclick-c3-v2` | `magiclick-c3-v2` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c3` | `surfer-c3-1.14tft` | `surfer-c3-1.14tft` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c3` | `xmini-c3` | `xmini-c3` | ✅ Full build passed | IDF 6.0.1 `idf.py build` | Hardware smoke/peripheral regression pending |
| `esp32c3` | `xmini-c3-4g` | `xmini-c3-4g` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c3` | `xmini-c3-v3` | `xmini-c3-v3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c5` | `espressif/esp-sensairshuttle` | `espressif-esp-sensairshuttle` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c5` | `espressif/esp-spot` | `espressif-esp-spot-c5` | ✅ Full build passed | IDF 6.0.1 `idf.py build`; `bmi270_sensor 0.1.2` | Hardware smoke, BMI270, audio, and power-management regression pending |
| `esp32c5` | `movecall-moji2-esp32c5` | `movecall-moji2-esp32c5` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c5` | `waveshare/esp32-c5-touch-lcd-1.69` | `waveshare-esp32-c5-touch-lcd-1.69` | ✅ Full build passed | IDF 6.0.1 `idf.py build` | Hardware smoke/peripheral regression pending |
| `esp32c6` | `waveshare/esp32-c6-lcd-0.85` | `waveshare-esp32-c6-lcd-0.85` | ✅ Full build passed | IDF 6.0.1 `idf.py build` | Hardware smoke/peripheral regression pending |
| `esp32c6` | `waveshare/esp32-c6-lcd-1.69` | `waveshare-esp32-c6-lcd-1.69` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c6` | `waveshare/esp32-c6-touch-amoled-1.32` | `waveshare-esp32-c6-touch-amoled-1.32` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c6` | `waveshare/esp32-c6-touch-amoled-1.43` | `waveshare-esp32-c6-touch-amoled-1.43` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c6` | `waveshare/esp32-c6-touch-amoled-1.8` | `waveshare-esp32-c6-touch-amoled-1.8` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c6` | `waveshare/esp32-c6-touch-amoled-2.06` | `waveshare-esp32-c6-touch-amoled-2.06` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c6` | `waveshare/esp32-c6-touch-amoled-2.16` | `waveshare-esp32-c6-touch-amoled-2.16` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c6` | `waveshare/esp32-c6-touch-lcd-1.54` | `waveshare-esp32-c6-touch-lcd-1.54` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32c6` | `waveshare/esp32-c6-touch-lcd-1.83` | `waveshare-esp32-c6-touch-lcd-1.83` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `xorigin/aipi-lite` | `xorigin-aipi-lite` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `alientek/atk-dnesp32s3` | `alientek-atk-dnesp32s3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `alientek/atk-dnesp32s3-box` | `alientek-atk-dnesp32s3-box` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `alientek/atk-dnesp32s3-box0` | `alientek-atk-dnesp32s3-box0` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `alientek/atk-dnesp32s3-box2-4g` | `alientek-atk-dnesp32s3-box2-4g` | ✅ Build and hardware validated | GitHub Actions IDF 6.0.1 full build; maintainer hardware validation | Complete for the IDF 6 migration scope |
| `esp32s3` | `alientek/atk-dnesp32s3-box2-wifi` | `alientek-atk-dnesp32s3-box2-wifi` | ✅ Build and hardware validated | GitHub Actions IDF 6.0.1 full build; maintainer hardware validation | Complete for the IDF 6 migration scope |
| `esp32s3` | `alientek/atk-dnesp32s3-box3` | `alientek-atk-dnesp32s3-box3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `atom-echos3r` | `atom-echos3r` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `atoms3-echo-base` | `atoms3-echo-base` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `atoms3r-cam-m12-echo-base` | `atoms3r-cam-m12-echo-base` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `atoms3r-echo-base` | `atoms3r-echo-base` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `atoms3r-echo-pyramid` | `atoms3r-echo-pyramid` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `bread-compact-ml307` | `bread-compact-ml307`<br>`bread-compact-ml307-128x64` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `bread-compact-nt26` | `bread-compact-nt26` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `bread-compact-wifi` | `bread-compact-wifi`<br>`bread-compact-wifi-128x64` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `df-k10` | `df-k10` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `df-s3-ai-cam` | `df-s3-ai-cam` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `doit-s3-aibox` | `doit-s3-aibox` | ✅ Full build passed | IDF 6.0.1 `idf.py build` | Hardware smoke/peripheral regression pending |
| `esp32s3` | `du-chatx` | `du-chatx` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `electron-bot` | `electron-bot` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `espressif/esp32-s3-box` | `espressif-esp-box` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `espressif/esp32-s3-box-3` | `espressif-esp-box-3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `espressif/esp32-s3-box-lite` | `espressif-esp-box-lite` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `espressif/esp32-s3-lcd-ev-board` | `espressif-esp-s3-lcd-ev-board-1p4`<br>`espressif-esp-s3-lcd-ev-board-1p5` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `espressif/esp32-s3-lcd-ev-board-2` | `espressif-esp32-s3-lcd-ev-board-2` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `espressif/esp-sparkbot` | `espressif-esp-sparkbot` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `espressif/esp-vocat` | `espressif-esp-vocat` | 🟡 Full build passed; feature-degraded | Local ESP-IDF 6.0.1 full build after rebasing onto `origin/main` | PCB capacitive slider/button disabled on IDF 6; CST816 display touch remains enabled; hardware regression pending |
| `esp32s3` | `espressif/esp32-s3-korvo-2-v3.0` | `espressif-esp32s3-korvo2-v3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `espressif/esp32-s3-korvo-2-v3.0-rndis` | `espressif-esp32s3-korvo2-v3-rndis` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `freenove-esp32s3-display-2.8-lcd` | `freenove-esp32s3-display-2.8-lcd` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `genjutech-s3-1.54tft` | `genjutech-s3-1.54tft` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `hu-087` | `hu-087` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `jiuchuan-s3` | `jiuchuan-s3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `kevin/box-2` | `kevin-box-2` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `kevin/sp-v4-dev` | `kevin-sp-v4-dev` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `kevin/yuying-313lcd` | `kevin-yuying-313lcd` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `labplus/ledong-v2` | `labplus-ledong-v2` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `labplus/mpython-v3` | `labplus-mpython-v3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `lceda-course-examples/eda-robot-pro` | `lceda-course-examples-eda-robot-pro` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `lceda-course-examples/eda-super-bear` | `lceda-course-examples-eda-super-bear` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `lceda-course-examples/eda-tv-pro` | `lceda-course-examples-eda-tv-pro` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `lckfb/szpi-esp32s3` | `lckfb-lichuang-dev` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `lilygo-t-cameraplus-s3` | `lilygo-t-cameraplus-s3`<br>`lilygo-t-cameraplus-s3-v1.2` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `lilygo-t-circle-s3` | `lilygo-t-circle-s3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `lilygo-t-display-s3-pro-mvsrlora` | `lilygo-t-display-s3-pro-mvsrlora` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `m5stack-cardputer-adv` | `m5stack-cardputer-adv` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `m5stack-core-s3` | `m5stack-core-s3` | ✅ Build and hardware validated | GitHub Actions IDF 6.0.1 full build; maintainer hardware validation | Complete for the IDF 6 migration scope |
| `esp32s3` | `m5stack-stick-s3` | `m5stack-stick-s3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `m5stack-stopwatch` | `m5stack-stopwatch` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `magiclick-2p4` | `magiclick-2p4` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `magiclick-2p5` | `magiclick-2p5` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `minsi-k08-dual` | `minsi-k08-dual` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `mixgo-nova` | `mixgo-nova` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `movecall-cuican-esp32s3` | `movecall-cuican-esp32s3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `movecall-moji-esp32s3` | `movecall-moji-esp32s3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `nulllab-ai-vox-v3` | `nulllab-ai-vox-v3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `otto-robot` | `otto-robot` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `quandong-s3-dev` | `quandong-s3-dev` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `rymcu/bigsmart` | `rymcu-bigsmart` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `sensecap-watcher` | `sensecap-watcher` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `sp-esp32-s3-1.28-box` | `sp-esp32-s3-1.28-box` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `sp-esp32-s3-1.54-muma` | `sp-esp32-s3-1.54-muma` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `taiji-pi-s3` | `taiji-pi-s3`<br>`taiji-pi-s3-pdm` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-audio-board` | `waveshare-esp32-s3-audio-board` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-cam` | `waveshare-esp32-s3-cam-2`<br>`waveshare-esp32-s3-cam-2.8`<br>`waveshare-esp32-s3-cam-3.5`<br>`waveshare-esp32-s3-cam-1.83` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-epaper-1.54` | `waveshare-esp32-s3-epaper-1.54-v2`<br>`waveshare-esp32-s3-epaper-1.54-v1` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-epaper-3.97` | `waveshare-esp32-s3-epaper-3.97` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-lcd-0.85` | `waveshare-esp32-s3-lcd-0.85` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-rgb-matrix` | `waveshare-esp32-s3-rgb-matrix` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-rlcd-4.2` | `waveshare-esp32-s3-rlcd-4.2` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-amoled-1.32` | `waveshare-esp32-s3-touch-amoled-1.32` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-amoled-1.43c` | `waveshare-esp32-s3-touch-amoled-1.43c` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-amoled-1.75` | `waveshare-esp32-s3-touch-amoled-1.75`<br>`waveshare-esp32-s3-touch-amoled-1.75c` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-amoled-1.8` | `waveshare-esp32-s3-touch-amoled-1.8` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-amoled-1.8-v2` | `waveshare-esp32-s3-touch-amoled-1.8-v2` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-amoled-2.06` | `waveshare-esp32-s3-touch-amoled-2.06` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-amoled-2.16` | `waveshare-esp32-s3-touch-amoled-2.16` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-1.46` | `waveshare-esp32-s3-touch-lcd-1.46` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-1.54` | `waveshare-esp32-s3-touch-lcd-1.54` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-1.83` | `waveshare-esp32-s3-touch-lcd-1.83` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-1.85` | `waveshare-esp32-s3-touch-lcd-1.85` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-1.85b` | `waveshare-esp32-s3-touch-lcd-1.85b` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-1.85c` | `waveshare-esp32-s3-touch-lcd-1.85c` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-3.49` | `waveshare-esp32-s3-touch-lcd-3.49` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-3.5` | `waveshare-esp32-s3-touch-lcd-3.5` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-3.5b` | `waveshare-esp32-s3-touch-lcd-3.5b` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-4.3c` | `waveshare-esp32-s3-touch-lcd-4.3c` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-4b` | `waveshare-esp32-s3-touch-lcd-4b` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `waveshare/esp32-s3-touch-lcd-7c` | `waveshare-esp32-s3-touch-lcd-7c` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `xingzhi-abs-2.0` | `xingzhi-abs-2.0` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `xingzhi-cube-0.85tft-ml307` | `xingzhi-cube-0.85tft-ml307` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `xingzhi-cube-0.85tft-wifi` | `xingzhi-cube-0.85tft-wifi` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `xingzhi-cube-0.96oled-ml307` | `xingzhi-cube-0.96oled-ml307` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `xingzhi-cube-0.96oled-wifi` | `xingzhi-cube-0.96oled-wifi` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `xingzhi-cube-1.54tft-ml307` | `xingzhi-cube-1.54tft-ml307`<br>`xingzhi-cube-1.54tft-ml307-wechatui` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `xingzhi-cube-1.54tft-wifi` | `xingzhi-cube-1.54tft-wifi` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `xingzhi-metal-1.54-wifi` | `xingzhi-metal-1.54-wifi` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `yunliao-s3` | `yunliao-s3` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `zhengchen-1.54tft-ml307` | `zhengchen-1.54tft-ml307` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `zhengchen-1.54tft-wifi` | `zhengchen-1.54tft-wifi` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `zhengchen-cam` | `zhengchen-cam` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32s3` | `zhengchen-cam-ml307` | `zhengchen-cam-ml307` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32p4` | `espressif/esp32-p4-function-ev-board` | `espressif-esp32-p4-function-ev-board`<br>`espressif-esp32-p4x-function-ev-board` | ✅ Full builds passed | IDF 6.0.1 canonical release builds with ESP-SR 2.4.7 for Rev1 and Rev3 | Hardware smoke/peripheral regression pending |
| `esp32p4` | `m5stack-tab5` | `m5stack-tab5-p4x` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32p4` | `waveshare/esp32-p4-nano` | `waveshare-esp32-p4x-nano-10.1-a` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32p4` | `waveshare/esp32-p4-wifi6-touch-lcd` | `waveshare-esp32-p4x-wifi6-touch-lcd-4b`<br>`waveshare-esp32-p4x-wifi6-touch-lcd-4.3`<br>`waveshare-esp32-p4x-wifi6-touch-lcd-5`<br>`waveshare-esp32-p4x-wifi6-touch-lcd-7b`<br>`waveshare-esp32-p4x-wifi6-touch-lcd-3.4c`<br>`waveshare-esp32-p4x-wifi6-touch-lcd-4c`<br>`waveshare-esp32-p4x-wifi6-touch-lcd-7`<br>`waveshare-esp32-p4x-wifi6-touch-lcd-8`<br>`waveshare-esp32-p4x-wifi6-touch-lcd-10.1` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
| `esp32p4` | `waveshare/esp32-p4-wifi6-touch-lcd-3.5` | `waveshare-esp32-p4x-wifi6-touch-lcd-3.5` | ✅ Full build passed | GitHub Actions IDF 6.0.1 full build | Hardware smoke/peripheral regression pending |
## Next Steps and Acceptance Criteria
1. Run the IDF 6 BluFi CI job, then hardware-test at least one P4 v1.3 device on the legacy SDK path. When IDF 6-compatible releases of `touch_slider_sensor` and `touch_button_sensor` become available, re-enable and hardware-test the ESP Vocat PCB capacitive slider/button path.
2. For every green variant, complete a minimal hardware smoke test covering boot, networking, audio input/output, display/touch when present, camera when present, and 4G/Ethernet when present.
3. Perform a physical negative test for the `xmini-c3`/`xmini-c3-v3` firmware guard. CI proves that both images compile; it does not prove that a wrong image is safely rejected. Acceptance requires flashing each wrong image to a sacrificial or recoverable board and verifying that startup stops before any board-specific power or peripheral initialization can cause damage.
4. Keep third-party experiments under the ignored `components/` directory out of the migration branch. The reproducible baseline must use the published `78/esp_lcd_nv3023 1.0.1` and `wvirgil123/sscma_client 1.0.3` packages. `espfriends/servo_dog_ctrl 0.2.0` is an upstream registry dependency and needs no local override.
## Reproduction
```bash
source ~/.espressif/v6.0.1/esp-idf/export.sh
python scripts/build.py <board> --name <variant>
```
This document is a migration-status snapshot. Build compatibility and hardware compatibility are tracked separately. Add "hardware validated" to a board only after validation on physical hardware is complete.
+185
View File
@@ -0,0 +1,185 @@
# Dynamic Text Glyph Push Extension
This document defines version 1 of the `glyph_push` protocol extension. The extension lets a server
send bitmap glyphs that are missing from a device's installed text font. It applies equally to the
WebSocket and MQTT/UDP transports because capability advertisement and incoming JSON handling are
implemented in the shared protocol layer.
The extension supplements text rendering only. It does not change the text, TTS audio, or STT
semantics of the containing message.
## 1. Capability advertisement
The device advertises support in its client `hello` message:
```json
{
"type": "hello",
"version": 1,
"features": {
"mcp": true,
"glyph_push": true
},
"text_font": {
"bundle": "noto-v1",
"charset": "common",
"size": 20,
"bpp": 4
}
}
```
`features.glyph_push` indicates support for this extension. A server must treat a missing or false
value as unsupported. The `v` field in each pushed payload carries the extension version.
The `text_font` object describes the exact font data installed on the device:
| Field | Type | Meaning |
|---|---|---|
| `bundle` | string | Explicit font bundle identifier. It changes when glyph metrics, rendering behavior, character sets, or wire compatibility change. |
| `charset` | string | Installed character set. Version 1 devices report `basic` or `common`. |
| `size` | number | Text font pixel profile used by the firmware. |
| `bpp` | number | Bits per pixel of the text font bitmap, currently `1` or `4`. |
`basic` is the font linked into the firmware. The standard XiaoZhi assets report `common` after
loading their common font from the assets partition. The server must use the values from each
device's hello message rather than inferring them from the board model.
An OTA assets package may replace the text font with a different size, bpp, character set, or font
family. The firmware still loads any structurally valid CBIN font. When the package also provides
complete `text_font_meta` fields, the device advertises those active runtime values and validates
glyph pushes against them. A legacy or custom package without compatible glyph metadata continues
to use its custom font, emoji, colors, and background, but advertises `glyph_push: false` and omits
`text_font`. This prevents incompatible fallback glyphs without restricting theme customization.
## 2. Server glyph payload
The server may attach a `glyph_push` object to either of these server-to-device messages:
- a TTS message with `"type": "tts"` and `"state": "sentence_start"`;
- an STT message with `"type": "stt"`.
Example:
```json
{
"type": "tts",
"state": "sentence_start",
"text": "𠮷野家",
"glyph_push": {
"v": 1,
"bundle": "noto-v1",
"size": 20,
"bpp": 4,
"glyphs": [
{
"codepoint": 134071,
"adv_w": 320,
"box_w": 20,
"box_h": 20,
"ofs_x": 0,
"ofs_y": 0,
"bitmap": "<base64-encoded bitmap>"
}
]
}
}
```
The payload header must match the device capability exactly:
| Field | Requirement |
|---|---|
| `v` | Must be `1`. |
| `bundle` | Must equal `text_font.bundle`. |
| `size` | Must equal `text_font.size`. |
| `bpp` | Must equal `text_font.bpp`. |
| `glyphs` | The partial glyph batch being pushed, containing at most 64 entries. |
Each item uses the LVGL native bitmap-font metrics:
| Field | Meaning |
|---|---|
| `codepoint` | Unicode code point from `1` through `0x10FFFF`. |
| `adv_w` | Horizontal advance in LVGL fixed-point units with four fractional bits (one pixel is 16 units). |
| `box_w`, `box_h` | Bitmap dimensions in pixels. Each dimension must be from 0 through 64. |
| `ofs_x`, `ofs_y` | Signed 16-bit glyph offsets relative to the text baseline and cursor position. |
| `bitmap` | Base64 encoding of the uncompressed LVGL plain bitmap. |
The decoded bitmap length must be exactly:
```text
ceil(box_w * box_h * bpp / 8)
```
The bitmap must use the same plain, zero-stride layout as the matching Noto full-bundle CBIN font.
Servers should extract and forward the bitmap and metrics directly from that CBIN profile instead of
rasterizing an unrelated font at request time.
The sum of decoded bitmap lengths in one payload must not exceed 64 KiB. If any header, glyph, or
bitmap is invalid, the device rejects the entire glyph payload but still displays the message text
using its installed fonts. A PSRAM device may also use fallback glyphs cached by earlier messages.
## 3. Server selection algorithm
For every connection that advertises `glyph_push: true`, the server should:
1. Resolve the full font bundle identified by `text_font.bundle`.
2. Select the CBIN profile matching `text_font.size` and `text_font.bpp`.
3. Decode the message text into Unicode code points.
4. Remove control characters, duplicates, and code points already present in
`text_font.charset`.
5. Extract the remaining glyphs from the full bundle.
6. Apply the per-message limits and attach one `glyph_push` object to the text message.
7. Omit `glyph_push` when no missing glyph is available.
The installed text font is searched before the dynamic fallback font. Pushed glyphs therefore fill
missing code points; they do not override glyphs in `basic` or `common`.
The full font bundle may be shared by all device connections in a server process. Per-connection
work is limited to using the capability tuple `(bundle, charset, size, bpp)` to select which glyphs
are missing and which profile to read.
## 4. Device cache behavior
All glyphs in one message are inserted first, followed by a single fallback-font rebuild. The device
never rebuilds the font once per glyph.
On a device with initialized PSRAM:
- bitmap, cmap, descriptor, and cache-entry storage is allocated in PSRAM;
- glyphs are retained across messages;
- the cache holds at most 256 glyphs and 64 KiB of decoded bitmap data;
- the least recently inserted or updated entries are evicted when a limit is exceeded.
On a device without PSRAM:
- storage uses internal RAM;
- only the current message's glyph batch is retained;
- the next text message replaces or clears the previous batch.
This distinction does not affect the protocol. A server can send the glyphs needed by each message
without knowing whether the device has PSRAM.
## 5. Compatibility and versioning
The server must not send glyphs when any of these conditions is true:
- `features.glyph_push` is absent or not supported by the server;
- the server does not have the advertised bundle;
- no full-font profile matches the advertised size and bpp;
- the glyph data cannot satisfy the version 1 validation rules.
Fallback is automatic: messages without `glyph_push`, and messages whose glyph payload is rejected,
are still processed normally.
When the font generator changes metrics, bitmap layout, source fonts, character sets, or rendering
behavior, publish a new explicit bundle identifier. Do not serve glyphs from one bundle under
another bundle's identifier even if their size and bpp happen to match.
## 6. Security requirements
Glyph payloads are untrusted network input. Implementations must validate the complete payload before
mutating a live font, bound both item count and decoded size, verify base64 decoded length, and reject
invalid code points or metrics. Servers should also bound their own per-message work and avoid
sending glyphs already covered by the advertised charset.
+174
View File
@@ -0,0 +1,174 @@
# 动态文字 Glyph Push 扩展
本文档定义 `glyph_push` 协议扩展的版本 1。服务器可以通过该扩展向设备下发本地文字
字库中缺失的位图 glyph。能力声明和 JSON 消息处理位于公共协议层,因此 WebSocket 与
MQTT/UDP 使用完全相同的扩展格式。
该扩展只补充文字渲染能力,不改变消息中的文本、TTS 音频或 STT 语义。
## 1. 能力声明
设备在客户端 `hello` 消息中声明能力:
```json
{
"type": "hello",
"version": 1,
"features": {
"mcp": true,
"glyph_push": true
},
"text_font": {
"bundle": "noto-v1",
"charset": "common",
"size": 20,
"bpp": 4
}
}
```
`features.glyph_push` 表示设备支持该扩展。字段缺失或为 false 时,服务器必须视为设备不支持。
每次推送 payload 中的 `v` 字段负责表达扩展版本。
`text_font` 描述设备实际安装的文字字库:
| 字段 | 类型 | 含义 |
|---|---|---|
| `bundle` | string | 显式的字体 bundle 标识。glyph 度量、渲染方式、字符集或线格式变化时需要更换。 |
| `charset` | string | 已安装字符集。版本 1 设备报告 `basic``common`。 |
| `size` | number | 固件使用的文字字体像素规格。 |
| `bpp` | number | 字体位图的每像素位数,目前为 `1``4`。 |
`basic` 是链接进固件的字库。标准小智 assets 从分区加载 common 字库后会报告 `common`
服务器必须使用每个连接在 hello 中报告的实际值,不能根据板型推测。
OTA assets 可以把文字字体替换为不同字号、bpp、字符集或字体家族的 CBIN 字体。固件仍会
加载任何结构有效的 CBIN 字体;如果 assets 同时提供完整的 `text_font_meta`,设备会声明当前
实际使用的运行时字体参数,并据此校验 glyph push。旧版或自定义 assets 缺少兼容的 glyph
metadata 时,自定义字体、表情、颜色和背景仍然正常使用,但设备会声明 `glyph_push: false`
并省略 `text_font`,从而只禁止不兼容的 fallback glyph,不限制主题定制能力。
## 2. 服务器下发格式
服务器可以在以下消息中附加 `glyph_push` 对象:
- `"type": "tts"``"state": "sentence_start"` 的 TTS 消息;
- `"type": "stt"` 的 STT 消息。
示例:
```json
{
"type": "tts",
"state": "sentence_start",
"text": "𠮷野家",
"glyph_push": {
"v": 1,
"bundle": "noto-v1",
"size": 20,
"bpp": 4,
"glyphs": [
{
"codepoint": 134071,
"adv_w": 320,
"box_w": 20,
"box_h": 20,
"ofs_x": 0,
"ofs_y": 0,
"bitmap": "<base64 编码的位图>"
}
]
}
}
```
payload 头必须与设备能力完全匹配:
| 字段 | 要求 |
|---|---|
| `v` | 必须为 `1`。 |
| `bundle` | 必须等于 `text_font.bundle`。 |
| `size` | 必须等于 `text_font.size`。 |
| `bpp` | 必须等于 `text_font.bpp`。 |
| `glyphs` | 本次增量推送的 glyph 数组,最多 64 项。 |
每个 item 使用 LVGL 原生位图字体度量:
| 字段 | 含义 |
|---|---|
| `codepoint` | `1``0x10FFFF` 的 Unicode code point。 |
| `adv_w` | 带 4 位小数的 LVGL 定点水平 advance16 个单位等于 1 像素。 |
| `box_w``box_h` | 位图宽高,单项范围为 0 到 64 像素。 |
| `ofs_x``ofs_y` | 相对于文字基线和光标位置的有符号 16 位偏移。 |
| `bitmap` | 未压缩 LVGL plain 位图的 Base64 编码。 |
解码后的位图长度必须严格等于:
```text
ceil(box_w * box_h * bpp / 8)
```
位图必须与对应 Noto full bundle CBIN 字体使用相同的 plain、零 stride 布局。服务器应直接
提取相同 profile 的 CBIN 位图和度量,不应临时使用其他字体重新光栅化。
单条 payload 的解码位图总长度不得超过 64 KiB。任意头字段、glyph 或位图无效时,设备会
拒绝整批 glyph,但仍使用本地字体显示消息文本;有 PSRAM 的设备还可能使用此前缓存的
fallback glyph。
## 3. 服务器选择流程
对于声明 `glyph_push: true` 的每个连接,服务器应:
1. 根据 `text_font.bundle` 找到对应 full 字体 bundle。
2. 根据 `text_font.size``text_font.bpp` 选择 CBIN profile。
3. 将消息文本解码为 Unicode code point。
4. 去掉控制字符、重复字符及 `text_font.charset` 已包含的字符。
5. 从 full bundle 提取剩余 glyph。
6. 执行单消息限制,并将一个 `glyph_push` 对象附加到文字消息。
7. 没有可用的缺失 glyph 时省略 `glyph_push`
设备先查询本地文字字体,再查询动态 fallback。因此 glyph push 只补充缺字,不会覆盖
`basic``common` 中已有的 glyph。
同一服务器进程中的所有设备连接可以共享 full 字体 bundle 服务。每个连接只需根据
`(bundle, charset, size, bpp)` 能力组合判断缺字并选择 profile。
## 4. 设备缓存行为
同一消息中的全部 glyph 会先加入缓存,随后只执行一次 fallback 字体 rebuild,不会每加入
一个 glyph 就 rebuild 一次。
设备有已初始化的 PSRAM 时:
- bitmap、cmap、descriptor 和缓存条目存放在 PSRAM
- glyph 跨消息保留;
- 缓存上限为 256 个 glyph 和 64 KiB 解码位图;
- 超限时淘汰最早插入或更新的条目。
设备没有 PSRAM 时:
- 数据使用内部 RAM
- 只保留当前消息的 glyph 批次;
- 下一条文字消息会替换或清空上一批 glyph。
该差异不改变协议。服务器无需知道设备是否有 PSRAM,可以为每条消息发送它所需的 glyph。
## 5. 兼容与版本管理
以下任一条件成立时,服务器不得发送 glyph:
- `features.glyph_push` 缺失、为 false,或服务器不支持该扩展;
- 服务器没有设备声明的 bundle;
- 找不到匹配 size 和 bpp 的 full 字体 profile
- glyph 数据不满足版本 1 的校验要求。
兼容降级是自动的:没有 `glyph_push`,或 glyph payload 被拒绝时,设备仍会正常处理消息。
字体生成器的度量、位图布局、源字体、字符集或渲染方式变化时,应发布新的显式 bundle
标识。即使 size 和 bpp 相同,也不能用一个 bundle 的标识下发另一个 bundle 的 glyph。
## 6. 安全要求
glyph payload 是不可信网络输入。实现必须先完整校验 payload,再修改正在使用的字体;限制
item 数量和解码总长度;验证 Base64 解码长度;拒绝非法 code point 或度量。服务器也应限制
单消息处理量,避免重复发送设备声明字符集已经包含的 glyph。
+214 -213
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
View File
@@ -0,0 +1,269 @@
# MCP (Model Context Protocol) 交互流程
NOTICE: AI 辅助生成, 在实现后台服务时, 请参照代码确认细节!!
本项目中的 MCP 协议用于后台 API(MCP 客户端)与 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` 以及各个工具的实现。
+120 -50
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
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)。
+212 -187
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,15 @@ sequenceDiagram
"version": 3,
"transport": "udp",
"features": {
"mcp": true
"mcp": true,
"aec": true,
"glyph_push": true
},
"text_font": {
"bundle": "noto-v1",
"charset": "common",
"size": 20,
"bpp": 4
},
"audio_params": {
"format": "opus",
@@ -89,7 +98,11 @@ sequenceDiagram
}
```
#### 3.2.2 服务器响应 Hello
`features.mcp` is always set; `features.aec` is set when `CONFIG_USE_SERVER_AEC` is enabled.
`features.glyph_push` and `text_font` advertise the shared dynamic text-glyph extension described in
[Dynamic Text Glyph Push Extension](glyph-push.md).
#### 3.2.2 Server -> Device
```json
{
@@ -111,17 +124,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 +144,7 @@ sequenceDiagram
}
```
2. **Abort 消息**
2. **Abort**
```json
{
"session_id": "xxx",
@@ -140,7 +153,7 @@ sequenceDiagram
}
```
3. **MCP 消息**
3. **MCP**
```json
{
"session_id": "xxx",
@@ -148,12 +161,12 @@ sequenceDiagram
"payload": {
"jsonrpc": "2.0",
"id": 1,
"result": {...}
"result": {}
}
}
```
4. **Goodbye 消息**
4. **Goodbye**
```json
{
"session_id": "xxx",
@@ -161,71 +174,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 +259,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 +282,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.
+403
View File
@@ -0,0 +1,403 @@
# 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,
"glyph_push": true
},
"text_font": {
"bundle": "noto-v1",
"charset": "common",
"size": 20,
"bpp": 4
},
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60
}
}
```
`features.glyph_push``text_font` 声明 WebSocket 与 MQTT/UDP 共用的动态文字扩展,详见
[动态文字 Glyph Push 扩展](glyph-push_zh.md)。
#### 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 传输保证音频数据的实时性
该协议适用于对实时性要求较高的语音交互场景,但需要在网络复杂度和传输性能之间做出权衡。
+256 -213
View File
@@ -1,32 +1,40 @@
以下是一份基于代码实现整理的 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,
"glyph_push": true
},
"text_font": {
"bundle": "noto-v1",
"charset": "common",
"size": 20,
"bpp": 4
},
"transport": "websocket",
"audio_params": {
@@ -37,13 +45,14 @@
}
}
```
- 其中 `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.
- `"glyph_push": true` and `text_font` advertise the optional dynamic text-glyph extension. See [Dynamic Text Glyph Push Extension](glyph-push.md).
- `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 +66,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 +161,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 +178,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 +188,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 +225,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 +267,8 @@ WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及
```
6. **System**
- 系统控制命令,常用于远程升级更新。
- 例:
- System-level control, often used for remote upgrades / management.
- Example:
```json
{
"session_id": "xxx",
@@ -270,153 +276,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 +471,7 @@ stateDiagram
}
```
2. **服务器 → 设备端**(握手应答)
2. **Server -> Device** (handshake ack)
```json
{
"type": "hello",
@@ -441,7 +484,7 @@ stateDiagram
}
```
3. **设备端 → 服务器**(开始监听)
3. **Device -> Server** (start listening)
```json
{
"session_id": "xxx",
@@ -450,18 +493,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 +512,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 +522,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.
+503
View File
@@ -0,0 +1,503 @@
以下是一份基于代码实现整理的 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,
"glyph_push": true
},
"text_font": {
"bundle": "noto-v1",
"charset": "common",
"size": 20,
"bpp": 4
},
"transport": "websocket",
"audio_params": {
"format": "opus",
"sample_rate": 16000,
"channels": 1,
"frame_duration": 60
}
}
```
- 其中 `features` 字段为可选,内容根据设备编译配置自动生成。例如:`"mcp": true` 表示支持 MCP 协议。
- `"glyph_push": true` 和 `text_font` 声明可选的动态文字 glyph 扩展,详见[动态文字 Glyph Push 扩展](glyph-push_zh.md)。
- `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 里进行额外鉴权。
服务器与设备端需提前约定各类消息的字段含义、时序逻辑以及错误处理规则,方能保证通信顺畅。上述信息可作为基础文档,便于后续对接、开发或扩展。
+760 -606
View File
File diff suppressed because it is too large Load Diff
+481 -380
View File
File diff suppressed because it is too large Load Diff
+278 -203
View File
@@ -1,25 +1,24 @@
#include "application.h"
#include "assets.h"
#include "assets/lang_config.h"
#include "audio_codec.h"
#include "board.h"
#include "display.h"
#include "system_info.h"
#include "audio_codec.h"
#include "mqtt_protocol.h"
#include "websocket_protocol.h"
#include "assets/lang_config.h"
#include "mcp_server.h"
#include "assets.h"
#include "mqtt_protocol.h"
#include "settings.h"
#include "system_info.h"
#include "text_glyph_payload.h"
#include "websocket_protocol.h"
#include <cstring>
#include <esp_log.h>
#include <cJSON.h>
#include <driver/gpio.h>
#include <esp_log.h>
#include <arpa/inet.h>
#include <font_awesome.h>
#include <cJSON.h>
#include <cstring>
#define TAG "Application"
Application::Application() {
event_group_ = xEventGroupCreate();
@@ -33,16 +32,16 @@ Application::Application() {
aec_mode_ = kAecOff;
#endif
esp_timer_create_args_t clock_timer_args = {
.callback = [](void* arg) {
Application* app = (Application*)arg;
xEventGroupSetBits(app->event_group_, MAIN_EVENT_CLOCK_TICK);
},
.arg = this,
.dispatch_method = ESP_TIMER_TASK,
.name = "clock_timer",
.skip_unhandled_events = true
};
esp_timer_create_args_t clock_timer_args = {.callback =
[](void* arg) {
Application* app = (Application*)arg;
xEventGroupSetBits(app->event_group_,
MAIN_EVENT_CLOCK_TICK);
},
.arg = this,
.dispatch_method = ESP_TIMER_TASK,
.name = "clock_timer",
.skip_unhandled_events = true};
esp_timer_create(&clock_timer_args, &clock_timer_handle_);
}
@@ -54,9 +53,7 @@ Application::~Application() {
vEventGroupDelete(event_group_);
}
bool Application::SetDeviceState(DeviceState state) {
return state_machine_.TransitionTo(state);
}
bool Application::SetDeviceState(DeviceState state) { return state_machine_.TransitionTo(state); }
void Application::Initialize() {
auto& board = Board::GetInstance();
@@ -83,6 +80,9 @@ void Application::Initialize() {
callbacks.on_vad_change = [this](bool speaking) {
xEventGroupSetBits(event_group_, MAIN_EVENT_VAD_CHANGE);
};
callbacks.on_playback_drained = [this]() {
xEventGroupSetBits(event_group_, MAIN_EVENT_PLAYBACK_DRAINED);
};
audio_service_.SetCallbacks(callbacks);
// Add state change listeners
@@ -101,7 +101,7 @@ void Application::Initialize() {
// Set network event callback for UI updates and network state handling
board.SetNetworkEventCallback([this](NetworkEvent event, const std::string& data) {
auto display = Board::GetInstance().GetDisplay();
switch (event) {
case NetworkEvent::Scanning:
display->ShowNotification(Lang::Strings::SCANNING_WIFI, 30000);
@@ -141,13 +141,16 @@ void Application::Initialize() {
display->SetStatus(Lang::Strings::DETECTING_MODULE);
break;
case NetworkEvent::ModemErrorNoSim:
Alert(Lang::Strings::ERROR, Lang::Strings::PIN_ERROR, "triangle_exclamation", Lang::Sounds::OGG_ERR_PIN);
Alert(Lang::Strings::ERROR, Lang::Strings::PIN_ERROR, "warning",
Lang::Sounds::OGG_ERR_PIN);
break;
case NetworkEvent::ModemErrorRegDenied:
Alert(Lang::Strings::ERROR, Lang::Strings::REG_ERROR, "triangle_exclamation", Lang::Sounds::OGG_ERR_REG);
Alert(Lang::Strings::ERROR, Lang::Strings::REG_ERROR, "warning",
Lang::Sounds::OGG_ERR_REG);
break;
case NetworkEvent::ModemErrorInitFailed:
Alert(Lang::Strings::ERROR, Lang::Strings::MODEM_INIT_ERROR, "triangle_exclamation", Lang::Sounds::OGG_EXCLAMATION);
Alert(Lang::Strings::ERROR, Lang::Strings::MODEM_INIT_ERROR, "warning",
Lang::Sounds::OGG_EXCLAMATION);
break;
case NetworkEvent::ModemErrorTimeout:
display->SetStatus(Lang::Strings::REGISTERING_NETWORK);
@@ -166,27 +169,20 @@ void Application::Run() {
// Set the priority of the main task to 10
vTaskPrioritySet(nullptr, 10);
const EventBits_t ALL_EVENTS =
MAIN_EVENT_SCHEDULE |
MAIN_EVENT_SEND_AUDIO |
MAIN_EVENT_WAKE_WORD_DETECTED |
MAIN_EVENT_VAD_CHANGE |
MAIN_EVENT_CLOCK_TICK |
MAIN_EVENT_ERROR |
MAIN_EVENT_NETWORK_CONNECTED |
MAIN_EVENT_NETWORK_DISCONNECTED |
MAIN_EVENT_TOGGLE_CHAT |
MAIN_EVENT_START_LISTENING |
MAIN_EVENT_STOP_LISTENING |
MAIN_EVENT_ACTIVATION_DONE |
MAIN_EVENT_STATE_CHANGED;
const EventBits_t ALL_EVENTS =
MAIN_EVENT_SCHEDULE | MAIN_EVENT_SEND_AUDIO | MAIN_EVENT_WAKE_WORD_DETECTED |
MAIN_EVENT_VAD_CHANGE | MAIN_EVENT_CLOCK_TICK | MAIN_EVENT_ERROR |
MAIN_EVENT_NETWORK_CONNECTED | MAIN_EVENT_NETWORK_DISCONNECTED | MAIN_EVENT_TOGGLE_CHAT |
MAIN_EVENT_START_LISTENING | MAIN_EVENT_STOP_LISTENING | MAIN_EVENT_ACTIVATION_DONE |
MAIN_EVENT_STATE_CHANGED | MAIN_EVENT_PLAYBACK_DRAINED;
while (true) {
auto bits = xEventGroupWaitBits(event_group_, ALL_EVENTS, pdTRUE, pdFALSE, portMAX_DELAY);
if (bits & MAIN_EVENT_ERROR) {
SetDeviceState(kDeviceStateIdle);
Alert(Lang::Strings::ERROR, last_error_message_.c_str(), "circle_xmark", Lang::Sounds::OGG_EXCLAMATION);
Alert(Lang::Strings::ERROR, last_error_message_.c_str(), "cancel",
Lang::Sounds::OGG_EXCLAMATION);
}
if (bits & MAIN_EVENT_NETWORK_CONNECTED) {
@@ -205,6 +201,16 @@ void Application::Run() {
HandleStateChangedEvent();
}
if (bits & MAIN_EVENT_PLAYBACK_DRAINED) {
// Deferred listening start (auto mode): the playback queue has
// drained, so it is now safe to enable voice processing.
if (pending_listening_start_ && GetDeviceState() == kDeviceStateListening &&
audio_service_.IsPlaybackIdle()) {
pending_listening_start_ = false;
StartListeningAudio();
}
}
if (bits & MAIN_EVENT_TOGGLE_CHAT) {
HandleToggleChatEvent();
}
@@ -220,6 +226,12 @@ void Application::Run() {
if (bits & MAIN_EVENT_SEND_AUDIO) {
while (auto packet = audio_service_.PopPacketFromSendQueue()) {
if (protocol_ && !protocol_->SendAudio(std::move(packet))) {
// Drop the remaining packets. Leaving them in the queue would
// stall the Opus codec task (it waits for queue space), which in
// turn deadlocks the whole audio input pipeline, as no new
// MAIN_EVENT_SEND_AUDIO event would ever be triggered again.
while (audio_service_.PopPacketFromSendQueue())
;
break;
}
}
@@ -249,10 +261,12 @@ void Application::Run() {
clock_ticks_++;
auto display = Board::GetInstance().GetDisplay();
display->UpdateStatusBar();
// Print debug info every 10 seconds
if (clock_ticks_ % 10 == 0) {
SystemInfo::PrintHeapStats();
// SystemInfo::PrintTaskList();
// SystemInfo::PrintTaskCpuUsage(pdMS_TO_TICKS(1000));
}
}
}
@@ -270,12 +284,14 @@ void Application::HandleNetworkConnectedEvent() {
return;
}
xTaskCreate([](void* arg) {
Application* app = static_cast<Application*>(arg);
app->ActivationTask();
app->activation_task_handle_ = nullptr;
vTaskDelete(NULL);
}, "activation", 4096 * 2, this, 2, &activation_task_handle_);
xTaskCreate(
[](void* arg) {
Application* app = static_cast<Application*>(arg);
app->ActivationTask();
app->activation_task_handle_ = nullptr;
vTaskDelete(NULL);
},
"activation", 4096 * 2, this, 2, &activation_task_handle_);
}
// Update the status bar immediately to show the network state
@@ -286,7 +302,8 @@ void Application::HandleNetworkConnectedEvent() {
void Application::HandleNetworkDisconnectedEvent() {
// Close current conversation when network disconnected
auto state = GetDeviceState();
if (state == kDeviceStateConnecting || state == kDeviceStateListening || state == kDeviceStateSpeaking) {
if (state == kDeviceStateConnecting || state == kDeviceStateListening ||
state == kDeviceStateSpeaking) {
ESP_LOGI(TAG, "Closing audio channel due to network disconnection");
protocol_->CloseAudioChannel();
}
@@ -352,7 +369,7 @@ void Application::CheckAssetsVersion() {
ESP_LOGW(TAG, "Assets partition is disabled for board %s", BOARD_NAME);
return;
}
Settings settings("assets", true);
// Check if there is a new assets need to be downloaded
std::string download_url = settings.GetString("download_url");
@@ -362,27 +379,29 @@ void Application::CheckAssetsVersion() {
char message[256];
snprintf(message, sizeof(message), Lang::Strings::FOUND_NEW_ASSETS, download_url.c_str());
Alert(Lang::Strings::LOADING_ASSETS, message, "cloud_arrow_down", Lang::Sounds::OGG_UPGRADE);
Alert(Lang::Strings::LOADING_ASSETS, message, "cloud_download", Lang::Sounds::OGG_UPGRADE);
// Wait for the audio service to be idle for 3 seconds
vTaskDelay(pdMS_TO_TICKS(3000));
SetDeviceState(kDeviceStateUpgrading);
board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
display->SetChatMessage("system", Lang::Strings::PLEASE_WAIT);
bool success = assets.Download(download_url, [this, display](int progress, size_t speed) -> void {
char buffer[32];
snprintf(buffer, sizeof(buffer), "%d%% %uKB/s", progress, speed / 1024);
Schedule([display, message = std::string(buffer)]() {
display->SetChatMessage("system", message.c_str());
bool success =
assets.Download(download_url, [this, display](int progress, size_t speed) -> void {
char buffer[32];
snprintf(buffer, sizeof(buffer), "%d%% %uKB/s", progress, speed / 1024);
Schedule([display, message = std::string(buffer)]() {
display->SetChatMessage("system", message.c_str());
});
});
});
board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
vTaskDelay(pdMS_TO_TICKS(1000));
if (!success) {
Alert(Lang::Strings::ERROR, Lang::Strings::DOWNLOAD_ASSETS_FAILED, "circle_xmark", Lang::Sounds::OGG_EXCLAMATION);
Alert(Lang::Strings::ERROR, Lang::Strings::DOWNLOAD_ASSETS_FAILED, "cancel",
Lang::Sounds::OGG_EXCLAMATION);
vTaskDelay(pdMS_TO_TICKS(2000));
SetDeviceState(kDeviceStateActivating);
return;
@@ -392,13 +411,13 @@ void Application::CheckAssetsVersion() {
// Apply assets
assets.Apply();
display->SetChatMessage("system", "");
display->SetEmotion("microchip_ai");
display->SetEmotion("robot_2");
}
void Application::CheckNewVersion() {
const int MAX_RETRY = 10;
int retry_count = 0;
int retry_delay = 10; // Initial retry delay in seconds
int retry_delay = 10; // Initial retry delay in seconds
auto& board = Board::GetInstance();
while (true) {
@@ -414,27 +433,30 @@ void Application::CheckNewVersion() {
}
char error_message[128];
snprintf(error_message, sizeof(error_message), "code=%d, url=%s", err, ota_->GetCheckVersionUrl().c_str());
snprintf(error_message, sizeof(error_message), "code=%d, url=%s", err,
ota_->GetCheckVersionUrl().c_str());
char buffer[256];
snprintf(buffer, sizeof(buffer), Lang::Strings::CHECK_NEW_VERSION_FAILED, retry_delay, error_message);
Alert(Lang::Strings::ERROR, buffer, "cloud_slash", Lang::Sounds::OGG_EXCLAMATION);
snprintf(buffer, sizeof(buffer), Lang::Strings::CHECK_NEW_VERSION_FAILED, retry_delay,
error_message);
Alert(Lang::Strings::ERROR, buffer, "cloud_off", Lang::Sounds::OGG_EXCLAMATION);
ESP_LOGW(TAG, "Check new version failed, retry in %d seconds (%d/%d)", retry_delay, retry_count, MAX_RETRY);
ESP_LOGW(TAG, "Check new version failed, retry in %d seconds (%d/%d)", retry_delay,
retry_count, MAX_RETRY);
for (int i = 0; i < retry_delay; i++) {
vTaskDelay(pdMS_TO_TICKS(1000));
if (GetDeviceState() == kDeviceStateIdle) {
break;
}
}
retry_delay *= 2; // Double the retry delay
retry_delay *= 2; // Double the retry delay
continue;
}
retry_count = 0;
retry_delay = 10; // Reset retry delay
retry_delay = 10; // Reset retry delay
if (ota_->HasNewVersion()) {
if (UpgradeFirmware(ota_->GetFirmwareUrl(), ota_->GetFirmwareVersion())) {
return; // This line will never be reached after reboot
return; // This line will never be reached after reboot
}
// If upgrade failed, continue to normal operation
}
@@ -486,29 +508,29 @@ void Application::InitializeProtocol() {
protocol_ = std::make_unique<MqttProtocol>();
}
protocol_->OnConnected([this]() {
DismissAlert();
});
protocol_->OnConnected([this]() { DismissAlert(); });
protocol_->OnNetworkError([this](const std::string& message) {
last_error_message_ = message;
xEventGroupSetBits(event_group_, MAIN_EVENT_ERROR);
});
protocol_->OnIncomingAudio([this](std::unique_ptr<AudioStreamPacket> packet) {
if (GetDeviceState() == kDeviceStateSpeaking) {
audio_service_.PushPacketToDecodeQueue(std::move(packet));
}
});
protocol_->OnAudioChannelOpened([this, codec, &board]() {
board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE);
if (protocol_->server_sample_rate() != codec->output_sample_rate()) {
ESP_LOGW(TAG, "Server sample rate %d does not match device output sample rate %d, resampling may cause distortion",
protocol_->server_sample_rate(), codec->output_sample_rate());
ESP_LOGW(TAG,
"Server sample rate %d does not match device output sample rate %d, "
"resampling may cause distortion",
protocol_->server_sample_rate(), codec->output_sample_rate());
}
});
protocol_->OnAudioChannelClosed([this, &board]() {
board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
Schedule([this]() {
@@ -517,12 +539,19 @@ void Application::InitializeProtocol() {
SetDeviceState(kDeviceStateIdle);
});
});
protocol_->OnIncomingJson([this, display](const cJSON* root) {
// Parse JSON data
auto type = cJSON_GetObjectItem(root, "type");
if (!cJSON_IsString(type)) {
ESP_LOGW(TAG, "Incoming JSON message has no type");
return;
}
if (strcmp(type->valuestring, "tts") == 0) {
auto state = cJSON_GetObjectItem(root, "state");
if (!cJSON_IsString(state)) {
return;
}
if (strcmp(state->valuestring, "start") == 0) {
Schedule([this]() {
aborted_ = false;
@@ -541,8 +570,15 @@ void Application::InitializeProtocol() {
} else if (strcmp(state->valuestring, "sentence_start") == 0) {
auto text = cJSON_GetObjectItem(root, "text");
if (cJSON_IsString(text)) {
std::vector<TextGlyph> glyphs;
uint8_t bpp = 0;
if (!TextGlyphPayload::Parse(root, glyphs, bpp)) {
glyphs.clear();
}
ESP_LOGI(TAG, "<< %s", text->valuestring);
Schedule([display, message = std::string(text->valuestring)]() {
Schedule([display, message = std::string(text->valuestring),
glyphs = std::move(glyphs), bpp]() {
display->AddTextGlyphs(glyphs, bpp);
display->SetChatMessage("assistant", message.c_str());
});
}
@@ -550,8 +586,15 @@ void Application::InitializeProtocol() {
} else if (strcmp(type->valuestring, "stt") == 0) {
auto text = cJSON_GetObjectItem(root, "text");
if (cJSON_IsString(text)) {
std::vector<TextGlyph> glyphs;
uint8_t bpp = 0;
if (!TextGlyphPayload::Parse(root, glyphs, bpp)) {
glyphs.clear();
}
ESP_LOGI(TAG, ">> %s", text->valuestring);
Schedule([display, message = std::string(text->valuestring)]() {
Schedule([display, message = std::string(text->valuestring),
glyphs = std::move(glyphs), bpp]() {
display->AddTextGlyphs(glyphs, bpp);
display->SetChatMessage("user", message.c_str());
});
}
@@ -573,9 +616,7 @@ void Application::InitializeProtocol() {
ESP_LOGI(TAG, "System command: %s", command->valuestring);
if (strcmp(command->valuestring, "reboot") == 0) {
// Do a reboot if user requests a OTA update
Schedule([this]() {
Reboot();
});
Schedule([this]() { Reboot(); });
} else {
ESP_LOGW(TAG, "Unknown system command: %s", command->valuestring);
}
@@ -585,7 +626,8 @@ void Application::InitializeProtocol() {
auto message = cJSON_GetObjectItem(root, "message");
auto emotion = cJSON_GetObjectItem(root, "emotion");
if (cJSON_IsString(status) && cJSON_IsString(message) && cJSON_IsString(emotion)) {
Alert(status->valuestring, message->valuestring, emotion->valuestring, Lang::Sounds::OGG_VIBRATION);
Alert(status->valuestring, message->valuestring, emotion->valuestring,
Lang::Sounds::OGG_VIBRATION);
} else {
ESP_LOGW(TAG, "Alert command requires status, message and emotion");
}
@@ -594,9 +636,10 @@ void Application::InitializeProtocol() {
auto payload = cJSON_GetObjectItem(root, "payload");
ESP_LOGI(TAG, "Received custom message: %s", cJSON_PrintUnformatted(root));
if (cJSON_IsObject(payload)) {
Schedule([this, display, payload_str = std::string(cJSON_PrintUnformatted(payload))]() {
display->SetChatMessage("system", payload_str.c_str());
});
Schedule(
[this, display, payload_str = std::string(cJSON_PrintUnformatted(payload))]() {
display->SetChatMessage("system", payload_str.c_str());
});
} else {
ESP_LOGW(TAG, "Invalid custom message format: missing payload");
}
@@ -605,7 +648,7 @@ void Application::InitializeProtocol() {
ESP_LOGW(TAG, "Unknown message type: %s", type->valuestring);
}
});
protocol_->Start();
}
@@ -614,32 +657,27 @@ void Application::ShowActivationCode(const std::string& code, const std::string&
char digit;
const std::string_view& sound;
};
static const std::array<digit_sound, 10> digit_sounds{{
digit_sound{'0', Lang::Sounds::OGG_0},
digit_sound{'1', Lang::Sounds::OGG_1},
digit_sound{'2', Lang::Sounds::OGG_2},
digit_sound{'3', Lang::Sounds::OGG_3},
digit_sound{'4', Lang::Sounds::OGG_4},
digit_sound{'5', Lang::Sounds::OGG_5},
digit_sound{'6', Lang::Sounds::OGG_6},
digit_sound{'7', Lang::Sounds::OGG_7},
digit_sound{'8', Lang::Sounds::OGG_8},
digit_sound{'9', Lang::Sounds::OGG_9}
}};
static const std::array<digit_sound, 10> digit_sounds{
{digit_sound{'0', Lang::Sounds::OGG_0}, digit_sound{'1', Lang::Sounds::OGG_1},
digit_sound{'2', Lang::Sounds::OGG_2}, digit_sound{'3', Lang::Sounds::OGG_3},
digit_sound{'4', Lang::Sounds::OGG_4}, digit_sound{'5', Lang::Sounds::OGG_5},
digit_sound{'6', Lang::Sounds::OGG_6}, digit_sound{'7', Lang::Sounds::OGG_7},
digit_sound{'8', Lang::Sounds::OGG_8}, digit_sound{'9', Lang::Sounds::OGG_9}}};
// This sentence uses 9KB of SRAM, so we need to wait for it to finish
Alert(Lang::Strings::ACTIVATION, message.c_str(), "link", Lang::Sounds::OGG_ACTIVATION);
for (const auto& digit : code) {
auto it = std::find_if(digit_sounds.begin(), digit_sounds.end(),
[digit](const digit_sound& ds) { return ds.digit == digit; });
[digit](const digit_sound& ds) { return ds.digit == digit; });
if (it != digit_sounds.end()) {
audio_service_.PlaySound(it->sound);
}
}
}
void Application::Alert(const char* status, const char* message, const char* emotion, const std::string_view& sound) {
void Application::Alert(const char* status, const char* message, const char* emotion,
const std::string_view& sound) {
ESP_LOGW(TAG, "Alert [%s] %s: %s", emotion, status, message);
auto display = Board::GetInstance().GetDisplay();
display->SetStatus(status);
@@ -659,21 +697,15 @@ void Application::DismissAlert() {
}
}
void Application::ToggleChatState() {
xEventGroupSetBits(event_group_, MAIN_EVENT_TOGGLE_CHAT);
}
void Application::ToggleChatState() { xEventGroupSetBits(event_group_, MAIN_EVENT_TOGGLE_CHAT); }
void Application::StartListening() {
xEventGroupSetBits(event_group_, MAIN_EVENT_START_LISTENING);
}
void Application::StartListening() { xEventGroupSetBits(event_group_, MAIN_EVENT_START_LISTENING); }
void Application::StopListening() {
xEventGroupSetBits(event_group_, MAIN_EVENT_STOP_LISTENING);
}
void Application::StopListening() { xEventGroupSetBits(event_group_, MAIN_EVENT_STOP_LISTENING); }
void Application::HandleToggleChatEvent() {
auto state = GetDeviceState();
if (state == kDeviceStateActivating) {
SetDeviceState(kDeviceStateIdle);
return;
@@ -697,9 +729,7 @@ void Application::HandleToggleChatEvent() {
if (!protocol_->IsAudioChannelOpened()) {
SetDeviceState(kDeviceStateConnecting);
// Schedule to let the state change be processed first (UI update)
Schedule([this, mode]() {
ContinueOpenAudioChannel(mode);
});
Schedule([this, mode]() { ContinueOpenAudioChannel(mode); });
return;
}
SetListeningMode(mode);
@@ -716,8 +746,15 @@ void Application::ContinueOpenAudioChannel(ListeningMode mode) {
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 to idle so the device is not stuck in the connecting
// state (not every failure path reports a network error)
SetDeviceState(kDeviceStateIdle);
return;
}
}
@@ -727,7 +764,7 @@ void Application::ContinueOpenAudioChannel(ListeningMode mode) {
void Application::HandleStartListeningEvent() {
auto state = GetDeviceState();
if (state == kDeviceStateActivating) {
SetDeviceState(kDeviceStateIdle);
return;
@@ -741,14 +778,12 @@ void Application::HandleStartListeningEvent() {
ESP_LOGE(TAG, "Protocol not initialized");
return;
}
if (state == kDeviceStateIdle) {
if (!protocol_->IsAudioChannelOpened()) {
SetDeviceState(kDeviceStateConnecting);
// Schedule to let the state change be processed first (UI update)
Schedule([this]() {
ContinueOpenAudioChannel(kListeningModeManualStop);
});
Schedule([this]() { ContinueOpenAudioChannel(kListeningModeManualStop); });
return;
}
SetListeningMode(kListeningModeManualStop);
@@ -760,7 +795,7 @@ void Application::HandleStartListeningEvent() {
void Application::HandleStopListeningEvent() {
auto state = GetDeviceState();
if (state == kDeviceStateAudioTesting) {
audio_service_.EnableAudioTesting(false);
SetDeviceState(kDeviceStateWifiConfiguring);
@@ -783,24 +818,12 @@ void Application::HandleWakeWordDetectedEvent() {
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);
// 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;
}
// Channel already opened, continue directly
ContinueWakeWordInvoke(wake_word);
BeginWakeWordInvoke(wake_word);
} else if (state == kDeviceStateSpeaking || state == kDeviceStateListening) {
AbortSpeaking(kAbortReasonWakeWordDetected);
// Clear send queue to avoid sending residues to server
while (audio_service_.PopPacketFromSendQueue());
while (audio_service_.PopPacketFromSendQueue())
;
if (state == kDeviceStateListening) {
protocol_->SendStartListening(GetDefaultListeningMode());
@@ -819,15 +842,46 @@ void Application::HandleWakeWordDetectedEvent() {
}
}
void Application::BeginWakeWordInvoke(const std::string& wake_word) {
// Must run in the main task with the device in idle state
audio_service_.EncodeWakeWord();
// Always pass through the connecting state, even if the audio channel is
// already opened. ContinueWakeWordInvoke() rejects any other state, so
// skipping this transition would silently drop the wake word invocation.
if (!SetDeviceState(kDeviceStateConnecting)) {
// Wake word detection was stopped by the detection itself; restore it
// so the device does not become unresponsive to wake words.
audio_service_.EnableWakeWordDetection(true);
return;
}
if (!protocol_->IsAudioChannelOpened()) {
// 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;
}
// Channel already opened, continue directly
ContinueWakeWordInvoke(wake_word);
}
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 to idle so the device is not stuck in the connecting
// state (not every failure path reports a network error), and
// wake word detection is re-enabled by the idle state handler.
SetDeviceState(kDeviceStateIdle);
return;
}
}
@@ -852,18 +906,21 @@ void Application::ContinueWakeWordInvoke(const std::string& wake_word) {
void Application::HandleStateChangedEvent() {
DeviceState new_state = state_machine_.GetState();
clock_ticks_ = 0;
// Any state change invalidates a pending deferred listening start;
// the Listening case below re-arms it when needed.
pending_listening_start_ = false;
auto& board = Board::GetInstance();
auto display = board.GetDisplay();
auto led = board.GetLed();
led->OnStateChanged();
switch (new_state) {
case kDeviceStateUnknown:
case kDeviceStateIdle:
display->SetStatus(Lang::Strings::STANDBY);
display->ClearChatMessages(); // Clear messages first
display->SetEmotion("neutral"); // Then set emotion (wechat mode checks child count)
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;
@@ -878,29 +935,17 @@ void Application::HandleStateChangedEvent() {
// Make sure the audio processor is running
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();
// For auto mode, wait for the playback queue to drain before enabling
// voice processing. This prevents audio truncation when STOP arrives
// late due to network jitter. Instead of blocking the main loop here,
// defer the start until MAIN_EVENT_PLAYBACK_DRAINED arrives.
if (listening_mode_ == kListeningModeAutoStop && !audio_service_.IsPlaybackIdle()) {
pending_listening_start_ = true;
} else {
StartListeningAudio();
}
// Send the start listening command
protocol_->SendStartListening(listening_mode_);
audio_service_.EnableVoiceProcessing(true);
}
#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;
audio_service_.PlaySound(Lang::Sounds::OGG_POPUP);
} else {
ConfigureWakeWordForListening();
}
break;
case kDeviceStateSpeaking:
@@ -923,6 +968,36 @@ void Application::HandleStateChangedEvent() {
}
}
void Application::StartListeningAudio() {
// Runs in the main loop, either directly from HandleStateChangedEvent or
// deferred via MAIN_EVENT_PLAYBACK_DRAINED once the playback queue drains.
if (GetDeviceState() != kDeviceStateListening) {
return;
}
// Send the start listening command
protocol_->SendStartListening(listening_mode_);
audio_service_.EnableVoiceProcessing(true);
ConfigureWakeWordForListening();
// Play popup sound after ResetDecoder (in EnableVoiceProcessing) has been called
if (play_popup_on_listening_) {
play_popup_on_listening_ = false;
audio_service_.PlaySound(Lang::Sounds::OGG_POPUP);
}
}
void Application::ConfigureWakeWordForListening() {
#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
}
void Application::Schedule(std::function<void()>&& callback) {
{
std::lock_guard<std::mutex> lock(mutex_);
@@ -975,7 +1050,8 @@ bool Application::UpgradeFirmware(const std::string& url, const std::string& ver
}
ESP_LOGI(TAG, "Starting firmware upgrade from URL: %s", upgrade_url.c_str());
Alert(Lang::Strings::OTA_UPGRADE, Lang::Strings::UPGRADING, "download", Lang::Sounds::OGG_UPGRADE);
Alert(Lang::Strings::OTA_UPGRADE, Lang::Strings::UPGRADING, "download",
Lang::Sounds::OGG_UPGRADE);
vTaskDelay(pdMS_TO_TICKS(3000));
SetDeviceState(kDeviceStateUpgrading);
@@ -997,17 +1073,19 @@ bool Application::UpgradeFirmware(const std::string& url, const std::string& ver
if (!upgrade_success) {
// Upgrade failed, restart audio service and continue running
ESP_LOGE(TAG, "Firmware upgrade failed, restarting audio service and continuing operation...");
audio_service_.Start(); // Restart audio service
board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER); // Restore power save level
Alert(Lang::Strings::ERROR, Lang::Strings::UPGRADE_FAILED, "circle_xmark", Lang::Sounds::OGG_EXCLAMATION);
ESP_LOGE(TAG,
"Firmware upgrade failed, restarting audio service and continuing operation...");
audio_service_.Start(); // Restart audio service
board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER); // Restore power save level
Alert(Lang::Strings::ERROR, Lang::Strings::UPGRADE_FAILED, "cancel",
Lang::Sounds::OGG_EXCLAMATION);
vTaskDelay(pdMS_TO_TICKS(3000));
return false;
} else {
// Upgrade success, reboot immediately
ESP_LOGI(TAG, "Firmware upgrade successful, rebooting...");
display->SetChatMessage("system", "Upgrade successful, rebooting...");
vTaskDelay(pdMS_TO_TICKS(1000)); // Brief pause to show message
vTaskDelay(pdMS_TO_TICKS(1000)); // Brief pause to show message
Reboot();
return true;
}
@@ -1019,25 +1097,18 @@ void Application::WakeWordInvoke(const std::string& wake_word) {
}
auto state = GetDeviceState();
if (state == kDeviceStateIdle) {
audio_service_.EncodeWakeWord();
if (!protocol_->IsAudioChannelOpened()) {
SetDeviceState(kDeviceStateConnecting);
// Schedule to let the state change be processed first (UI update)
Schedule([this, wake_word]() {
ContinueWakeWordInvoke(wake_word);
});
return;
}
// Channel already opened, continue directly
ContinueWakeWordInvoke(wake_word);
} else if (state == kDeviceStateSpeaking) {
Schedule([this]() {
AbortSpeaking(kAbortReasonNone);
if (state == kDeviceStateIdle) {
// May be called from outside the main task (e.g. board button
// callbacks), so schedule the invocation instead of running it here
Schedule([this, wake_word]() {
if (GetDeviceState() == kDeviceStateIdle) {
BeginWakeWordInvoke(wake_word);
}
});
} else if (state == kDeviceStateListening) {
} else if (state == kDeviceStateSpeaking) {
Schedule([this]() { AbortSpeaking(kAbortReasonNone); });
} else if (state == kDeviceStateListening) {
Schedule([this]() {
if (protocol_) {
protocol_->CloseAudioChannel();
@@ -1063,12 +1134,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);
}
});
}
@@ -1078,18 +1156,18 @@ void Application::SetAecMode(AecMode mode) {
auto& board = Board::GetInstance();
auto display = board.GetDisplay();
switch (aec_mode_) {
case kAecOff:
audio_service_.EnableDeviceAec(false);
display->ShowNotification(Lang::Strings::RTC_MODE_OFF);
break;
case kAecOnServerSide:
audio_service_.EnableDeviceAec(false);
display->ShowNotification(Lang::Strings::RTC_MODE_ON);
break;
case kAecOnDeviceSide:
audio_service_.EnableDeviceAec(true);
display->ShowNotification(Lang::Strings::RTC_MODE_ON);
break;
case kAecOff:
audio_service_.EnableDeviceAec(false);
display->ShowNotification(Lang::Strings::RTC_MODE_OFF);
break;
case kAecOnServerSide:
audio_service_.EnableDeviceAec(false);
display->ShowNotification(Lang::Strings::RTC_MODE_ON);
break;
case kAecOnDeviceSide:
audio_service_.EnableDeviceAec(true);
display->ShowNotification(Lang::Strings::RTC_MODE_ON);
break;
}
// If the AEC mode is changed, close the audio channel
@@ -1099,9 +1177,7 @@ void Application::SetAecMode(AecMode mode) {
});
}
void Application::PlaySound(const std::string_view& sound) {
audio_service_.PlaySound(sound);
}
void Application::PlaySound(const std::string_view& sound) { audio_service_.PlaySound(sound); }
void Application::ResetProtocol() {
Schedule([this]() {
@@ -1113,4 +1189,3 @@ void Application::ResetProtocol() {
protocol_.reset();
});
}
+9
View File
@@ -10,6 +10,7 @@
#include <mutex>
#include <deque>
#include <memory>
#include <functional>
#include "protocol.h"
#include "ota.h"
@@ -31,6 +32,7 @@
#define MAIN_EVENT_START_LISTENING (1 << 10)
#define MAIN_EVENT_STOP_LISTENING (1 << 11)
#define MAIN_EVENT_STATE_CHANGED (1 << 12)
#define MAIN_EVENT_PLAYBACK_DRAINED (1 << 13)
enum AecMode {
@@ -108,6 +110,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 +139,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
bool pending_listening_start_ = false; // Waiting for playback to drain before starting listening (auto mode)
int clock_ticks_ = 0;
TaskHandle_t activation_task_handle_ = nullptr;
@@ -154,7 +160,10 @@ private:
void HandleActivationDoneEvent();
void HandleWakeWordDetectedEvent();
void ContinueOpenAudioChannel(ListeningMode mode);
void BeginWakeWordInvoke(const std::string& wake_word);
void ContinueWakeWordInvoke(const std::string& wake_word);
void StartListeningAudio();
void ConfigureWakeWordForListening();
// Activation task (runs in background)
void ActivationTask();
+241 -129
View File
@@ -1,33 +1,37 @@
#include "assets.h"
#include "application.h"
#include "board.h"
#include "display.h"
#include "application.h"
#include "lvgl_theme.h"
#include "emote_display.h"
#include "expression_emote.h"
#include "lvgl_theme.h"
#if HAVE_LVGL
#include "display/lcd_display.h"
#include <spi_flash_mmap.h>
#include "display/lcd_display.h"
#include "display/lvgl_display/lvgl_display.h"
#endif
#include <esp_heap_caps.h>
#include <esp_log.h>
#include <esp_timer.h>
#include <esp_heap_caps.h>
#include <cbin_font.h>
#include <noto_font_bundle.h>
#include <cstring>
#define TAG "Assets"
#define PARTITION_LABEL "assets"
struct mmap_assets_table {
char asset_name[32]; /*!< Name of the asset */
uint32_t asset_size; /*!< Size of the asset */
uint32_t asset_offset; /*!< Offset of the asset */
uint16_t asset_width; /*!< Width of the asset */
uint16_t asset_height; /*!< Height of the asset */
char asset_name[32]; /*!< Name of the asset */
uint32_t asset_size; /*!< Size of the asset */
uint32_t asset_offset; /*!< Offset of the asset */
uint16_t asset_width; /*!< Width of the asset */
uint16_t asset_height; /*!< Height of the asset */
};
Assets::Assets() {
UseBuiltInTextFontCapability();
#if HAVE_LVGL
strategy_ = std::make_unique<Assets::LvglStrategy>();
#else
@@ -37,12 +41,11 @@ Assets::Assets() {
InitializePartition();
}
Assets::~Assets() {
UnApplyPartition();
}
Assets::~Assets() { UnApplyPartition(); }
bool Assets::FindPartition(Assets* assets) {
assets->partition_ = esp_partition_find_first(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, PARTITION_LABEL);
assets->partition_ = esp_partition_find_first(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY,
PARTITION_LABEL);
if (assets->partition_ == nullptr) {
ESP_LOGI(TAG, "No assets partition found");
return false;
@@ -59,11 +62,24 @@ bool Assets::InitializePartition() {
}
void Assets::UnApplyPartition() {
UseBuiltInTextFontCapability();
if (strategy_) {
strategy_->UnApplyPartition(this);
}
}
void Assets::UseBuiltInTextFontCapability() {
text_font_capability_ = {
.glyph_push = true,
.bundle = NOTO_FONT_BUNDLE_ID,
.charset = "basic",
.size = TEXT_FONT_SIZE,
.bpp = TEXT_FONT_BPP,
};
}
void Assets::DisableTextFontGlyphPush() { text_font_capability_ = {}; }
bool Assets::GetAssetData(const std::string& name, void*& ptr, size_t& size) {
return strategy_ ? strategy_->GetAssetData(this, name, ptr, size) : false;
}
@@ -140,11 +156,14 @@ bool Assets::LvglStrategy::InitializePartition(Assets* assets) {
ESP_LOGI(TAG, "The storage free size is %ld KB", storage_size / 1024);
ESP_LOGI(TAG, "The partition size is %ld KB", assets->partition_->size / 1024);
if (storage_size < assets->partition_->size) {
ESP_LOGE(TAG, "The free size %ld KB is less than assets partition required %ld KB", storage_size / 1024, assets->partition_->size / 1024);
ESP_LOGE(TAG, "The free size %ld KB is less than assets partition required %ld KB",
storage_size / 1024, assets->partition_->size / 1024);
return false;
}
esp_err_t err = esp_partition_mmap(assets->partition_, 0, assets->partition_->size, ESP_PARTITION_MMAP_DATA, (const void**)&mmap_root_, &mmap_handle_);
esp_err_t err =
esp_partition_mmap(assets->partition_, 0, assets->partition_->size, ESP_PARTITION_MMAP_DATA,
(const void**)&mmap_root_, &mmap_handle_);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to mmap assets partition: %s", esp_err_to_name(err));
return false;
@@ -157,7 +176,8 @@ bool Assets::LvglStrategy::InitializePartition(Assets* assets) {
uint32_t stored_len = *(uint32_t*)(mmap_root_ + 8);
if (stored_len > assets->partition_->size - 12) {
ESP_LOGD(TAG, "The stored_len (0x%lx) is greater than the partition size (0x%lx) - 12", stored_len, assets->partition_->size);
ESP_LOGD(TAG, "The stored_len (0x%lx) is greater than the partition size (0x%lx) - 12",
stored_len, assets->partition_->size);
return false;
}
@@ -167,7 +187,8 @@ bool Assets::LvglStrategy::InitializePartition(Assets* assets) {
ESP_LOGI(TAG, "The checksum calculation time is %d ms", int((end_time - start_time) / 1000));
if (calculated_checksum != stored_chksum) {
ESP_LOGE(TAG, "The calculated checksum (0x%lx) does not match the stored checksum (0x%lx)", calculated_checksum, stored_chksum);
ESP_LOGE(TAG, "The calculated checksum (0x%lx) does not match the stored checksum (0x%lx)",
calculated_checksum, stored_chksum);
return false;
}
@@ -175,10 +196,9 @@ bool Assets::LvglStrategy::InitializePartition(Assets* assets) {
for (uint32_t i = 0; i < stored_files; i++) {
auto item = (const mmap_assets_table*)(mmap_root_ + 12 + i * sizeof(mmap_assets_table));
auto asset = Asset{
.size = static_cast<size_t>(item->asset_size),
.offset = static_cast<size_t>(12 + sizeof(mmap_assets_table) * stored_files + item->asset_offset)
};
auto asset = Asset{.size = static_cast<size_t>(item->asset_size),
.offset = static_cast<size_t>(
12 + sizeof(mmap_assets_table) * stored_files + item->asset_offset)};
assets_[item->asset_name] = asset;
}
return checksum_valid_;
@@ -192,17 +212,19 @@ void Assets::LvglStrategy::UnApplyPartition(Assets* assets) {
}
checksum_valid_ = false;
assets_.clear();
(void)assets; // Unused parameter
(void)assets; // Unused parameter
}
bool Assets::LvglStrategy::GetAssetData(Assets* assets, const std::string& name, void*& ptr, size_t& size) {
bool Assets::LvglStrategy::GetAssetData(Assets* assets, const std::string& name, void*& ptr,
size_t& size) {
auto asset = assets_.find(name);
if (asset == assets_.end()) {
return false;
}
auto data = (const char*)(mmap_root_ + asset->second.offset);
if (data[0] != 'Z' || data[1] != 'Z') {
ESP_LOGE(TAG, "The asset %s is not valid with magic %02x%02x", name.c_str(), data[0], data[1]);
ESP_LOGE(TAG, "The asset %s is not valid with magic %02x%02x", name.c_str(), data[0],
data[1]);
return false;
}
@@ -228,7 +250,8 @@ bool Assets::LvglStrategy::Apply(Assets* assets, bool refresh_display_theme) {
cJSON* version = cJSON_GetObjectItem(root, "version");
if (cJSON_IsNumber(version)) {
if (version->valuedouble > 1) {
ESP_LOGE(TAG, "The assets version %d is not supported, please upgrade the firmware", version->valueint);
ESP_LOGE(TAG, "The assets version %d is not supported, please upgrade the firmware",
version->valueint);
return false;
}
}
@@ -244,15 +267,39 @@ bool Assets::LvglStrategy::Apply(Assets* assets, bool refresh_display_theme) {
std::string fonts_text_file = font->valuestring;
if (assets->GetAssetData(fonts_text_file, ptr, size)) {
auto text_font = std::make_shared<LvglCBinFont>(ptr);
if (text_font->font() == nullptr) {
ESP_LOGE(TAG, "Failed to load fonts.bin");
return false;
}
if (light_theme != nullptr) {
light_theme->set_text_font(text_font);
}
if (dark_theme != nullptr) {
dark_theme->set_text_font(text_font);
auto display = dynamic_cast<LvglDisplay*>(Board::GetInstance().GetDisplay());
if (text_font->font() == nullptr || display == nullptr ||
!display->SetTextFont(text_font)) {
ESP_LOGW(TAG, "Ignoring invalid text font asset %s", fonts_text_file.c_str());
} else {
assets->DisableTextFontGlyphPush();
cJSON* metadata = cJSON_GetObjectItem(root, "text_font_meta");
cJSON* charset = cJSON_GetObjectItem(metadata, "charset");
cJSON* font_size = cJSON_GetObjectItem(metadata, "size");
cJSON* font_bpp = cJSON_GetObjectItem(metadata, "bpp");
cJSON* bundle = cJSON_GetObjectItem(metadata, "bundle");
bool supports_glyph_push =
cJSON_IsString(charset) &&
(std::strcmp(charset->valuestring, "basic") == 0 ||
std::strcmp(charset->valuestring, "common") == 0) &&
cJSON_IsNumber(font_size) && font_size->valueint > 0 &&
font_size->valuedouble == font_size->valueint && cJSON_IsNumber(font_bpp) &&
font_bpp->valuedouble == font_bpp->valueint &&
(font_bpp->valueint == 1 || font_bpp->valueint == 4) &&
font_bpp->valueint == text_font->bpp() && cJSON_IsString(bundle) &&
bundle->valuestring[0] != '\0' && std::strlen(bundle->valuestring) <= 64;
if (supports_glyph_push) {
assets->text_font_capability_ = {
.glyph_push = true,
.bundle = bundle->valuestring,
.charset = charset->valuestring,
.size = font_size->valueint,
.bpp = font_bpp->valueint,
};
} else {
ESP_LOGW(TAG, "Loaded custom text font without compatible glyph push metadata");
}
}
} else {
ESP_LOGE(TAG, "The font file %s is not found", fonts_text_file.c_str());
@@ -269,12 +316,14 @@ bool Assets::LvglStrategy::Apply(Assets* assets, bool refresh_display_theme) {
cJSON* name = cJSON_GetObjectItem(emoji, "name");
cJSON* file = cJSON_GetObjectItem(emoji, "file");
cJSON* eaf = cJSON_GetObjectItem(emoji, "eaf");
if (cJSON_IsString(name) && cJSON_IsString(file) && (NULL== eaf)) {
if (cJSON_IsString(name) && cJSON_IsString(file) && (NULL == eaf)) {
if (!assets->GetAssetData(file->valuestring, ptr, size)) {
ESP_LOGE(TAG, "Emoji %s image file %s is not found", name->valuestring, file->valuestring);
ESP_LOGE(TAG, "Emoji %s image file %s is not found", name->valuestring,
file->valuestring);
continue;
}
custom_emoji_collection->AddEmoji(name->valuestring, new LvglRawImage(ptr, size));
custom_emoji_collection->AddEmoji(name->valuestring,
new LvglRawImage(ptr, size));
}
}
}
@@ -284,6 +333,7 @@ bool Assets::LvglStrategy::Apply(Assets* assets, bool refresh_display_theme) {
if (dark_theme != nullptr) {
dark_theme->set_emoji_collection(custom_emoji_collection);
}
Board::GetInstance().GetDisplay()->SetEmojiCollection(custom_emoji_collection);
}
cJSON* skin = cJSON_GetObjectItem(root, "skin");
@@ -297,12 +347,15 @@ bool Assets::LvglStrategy::Apply(Assets* assets, bool refresh_display_theme) {
light_theme->set_text_color(LvglTheme::ParseColor(text_color->valuestring));
}
if (cJSON_IsString(background_color)) {
light_theme->set_background_color(LvglTheme::ParseColor(background_color->valuestring));
light_theme->set_chat_background_color(LvglTheme::ParseColor(background_color->valuestring));
light_theme->set_background_color(
LvglTheme::ParseColor(background_color->valuestring));
light_theme->set_chat_background_color(
LvglTheme::ParseColor(background_color->valuestring));
}
if (cJSON_IsString(background_image)) {
if (!assets->GetAssetData(background_image->valuestring, ptr, size)) {
ESP_LOGE(TAG, "The background image file %s is not found", background_image->valuestring);
ESP_LOGE(TAG, "The background image file %s is not found",
background_image->valuestring);
return false;
}
auto background_image = std::make_shared<LvglCBinImage>(ptr);
@@ -318,12 +371,15 @@ bool Assets::LvglStrategy::Apply(Assets* assets, bool refresh_display_theme) {
dark_theme->set_text_color(LvglTheme::ParseColor(text_color->valuestring));
}
if (cJSON_IsString(background_color)) {
dark_theme->set_background_color(LvglTheme::ParseColor(background_color->valuestring));
dark_theme->set_chat_background_color(LvglTheme::ParseColor(background_color->valuestring));
dark_theme->set_background_color(
LvglTheme::ParseColor(background_color->valuestring));
dark_theme->set_chat_background_color(
LvglTheme::ParseColor(background_color->valuestring));
}
if (cJSON_IsString(background_image)) {
if (!assets->GetAssetData(background_image->valuestring, ptr, size)) {
ESP_LOGE(TAG, "The background image file %s is not found", background_image->valuestring);
ESP_LOGE(TAG, "The background image file %s is not found",
background_image->valuestring);
return false;
}
auto background_image = std::make_shared<LvglCBinImage>(ptr);
@@ -352,11 +408,11 @@ bool Assets::LvglStrategy::Apply(Assets* assets, bool refresh_display_theme) {
}
}
}
cJSON_Delete(root);
return true;
}
#endif // HAVE_LVGL
#endif // HAVE_LVGL
bool Assets::EmoteStrategy::InitializePartition(Assets* assets) {
assets->partition_valid_ = false;
@@ -371,12 +427,14 @@ bool Assets::EmoteStrategy::InitializePartition(Assets* assets) {
if (emote_display && emote_display->GetEmoteHandle() != nullptr) {
const emote_data_t data = {
.type = EMOTE_SOURCE_PARTITION,
.source = {
.partition_label = PARTITION_LABEL,
},
.flags = {
.mmap_enable = true, //must be true here!!!
},
.source =
{
.partition_label = PARTITION_LABEL,
},
.flags =
{
.mmap_enable = true, // must be true here!!!
},
};
ret = emote_mount_assets(emote_display->GetEmoteHandle(), &data);
} else {
@@ -392,16 +450,18 @@ void Assets::EmoteStrategy::UnApplyPartition(Assets* assets) {
if (emote_display && emote_display->GetEmoteHandle() != nullptr) {
emote_unmount_assets(emote_display->GetEmoteHandle());
}
(void)assets; // Unused parameter
(void)assets; // Unused parameter
}
bool Assets::EmoteStrategy::GetAssetData(Assets* assets, const std::string& name, void*& ptr, size_t& size) {
bool Assets::EmoteStrategy::GetAssetData(Assets* assets, const std::string& name, void*& ptr,
size_t& size) {
auto display = Board::GetInstance().GetDisplay();
auto* emote_display = dynamic_cast<emote::EmoteDisplay*>(display);
if (emote_display && emote_display->GetEmoteHandle() != nullptr) {
const uint8_t* data = nullptr;
size_t data_size = 0;
if (ESP_OK == emote_get_asset_data_by_name(emote_display->GetEmoteHandle(), name.c_str(), &data, &data_size)) {
if (ESP_OK == emote_get_asset_data_by_name(emote_display->GetEmoteHandle(), name.c_str(),
&data, &data_size)) {
ptr = const_cast<void*>(static_cast<const void*>(data));
size = data_size;
return true;
@@ -409,7 +469,7 @@ bool Assets::EmoteStrategy::GetAssetData(Assets* assets, const std::string& name
ESP_LOGE(TAG, "Failed to get asset data by name: %s", name.c_str());
return false;
}
(void)assets; // Unused parameter
(void)assets; // Unused parameter
return false;
}
@@ -425,16 +485,13 @@ bool Assets::EmoteStrategy::Apply(Assets* assets, bool refresh_display_theme) {
return true;
}
bool Assets::Download(std::string url, std::function<void(int progress, size_t speed)> progress_callback) {
bool Assets::Download(std::string url,
std::function<void(int progress, size_t speed)> progress_callback) {
ESP_LOGI(TAG, "Downloading new version of assets from %s", url.c_str());
// 取消当前资源分区的内存映射
UnApplyPartition();
// 下载新的资源文件
auto network = Board::GetInstance().GetNetwork();
auto http = network->CreateHttp(0);
if (!http->Open("GET", url)) {
ESP_LOGE(TAG, "Failed to open HTTP connection");
return false;
@@ -446,113 +503,168 @@ bool Assets::Download(std::string url, std::function<void(int progress, size_t s
}
size_t content_length = http->GetBodyLength();
if (content_length == 0) {
ESP_LOGE(TAG, "Failed to get content length");
return false;
}
if (content_length > partition_->size) {
ESP_LOGE(TAG, "Assets file size (%u) is larger than partition size (%lu)", content_length, partition_->size);
ESP_LOGE(TAG, "Assets file size (%u) is larger than partition size (%lu)", content_length,
partition_->size);
return false;
}
constexpr size_t HEADER_SIZE = 12;
if (content_length < HEADER_SIZE) {
ESP_LOGE(TAG, "Content length (%u) is smaller than header size (%u)", content_length,
HEADER_SIZE);
return false;
}
// 定义扇区大小为4KB(ESP32的标准扇区大小)
const size_t SECTOR_SIZE = esp_partition_get_main_flash_sector_size();
// 计算需要擦除的扇区数量
size_t sectors_to_erase = (content_length + SECTOR_SIZE - 1) / SECTOR_SIZE; // 向上取整
size_t total_erase_size = sectors_to_erase * SECTOR_SIZE;
ESP_LOGI(TAG, "Sector size: %u, content length: %u, sectors to erase: %u, total erase size: %u",
SECTOR_SIZE, content_length, sectors_to_erase, total_erase_size);
// 写入新的资源文件到分区,一边erase一边写入
char* buffer = (char*)heap_caps_malloc(SECTOR_SIZE, MALLOC_CAP_INTERNAL);
if (buffer == nullptr) {
using BufferPtr = std::unique_ptr<char, decltype(&heap_caps_free)>;
BufferPtr buffer(static_cast<char*>(heap_caps_malloc(SECTOR_SIZE, MALLOC_CAP_INTERNAL)),
&heap_caps_free);
if (!buffer) {
ESP_LOGE(TAG, "Failed to allocate buffer");
return false;
}
// Unapply the partition
UnApplyPartition();
size_t sectors_to_erase = (content_length + SECTOR_SIZE - 1) / SECTOR_SIZE;
size_t total_erase_size = sectors_to_erase * SECTOR_SIZE;
ESP_LOGI(TAG,
"Sector size: %u, content length: %u, "
"sectors to erase: %u, total erase size: %u",
SECTOR_SIZE, content_length, sectors_to_erase, total_erase_size);
size_t total_written = 0;
size_t recent_written = 0;
size_t current_sector = 0;
auto last_calc_time = esp_timer_get_time();
int64_t last_calc_time = esp_timer_get_time();
uint8_t header_buf[HEADER_SIZE];
size_t header_collected = 0;
bool success = false;
while (true) {
int ret = http->Read(buffer, SECTOR_SIZE);
int ret = http->Read(buffer.get(), 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;
}
if (ret == 0) {
break;
}
// 检查是否需要擦除新的扇区
size_t write_end_offset = total_written + ret;
size_t needed_sectors = (write_end_offset + SECTOR_SIZE - 1) / SECTOR_SIZE;
// 擦除需要的新扇区
while (current_sector < needed_sectors) {
size_t sector_start = current_sector * SECTOR_SIZE;
size_t sector_end = (current_sector + 1) * SECTOR_SIZE;
// 确保擦除范围不超过分区大小
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;
if (ret == 0) {
// End of data
success = true;
break;
}
size_t buf_pos = 0;
// Collect header
if (header_collected < HEADER_SIZE) {
size_t need = HEADER_SIZE - header_collected;
size_t take = std::min(static_cast<size_t>(ret), need);
memcpy(header_buf + header_collected, buffer.get(), take);
header_collected += take;
buf_pos += take;
}
// Write payload
if ((size_t)ret > buf_pos) {
size_t write_len = (size_t)ret - buf_pos;
size_t write_end_offset = HEADER_SIZE + total_written + write_len;
size_t needed_sectors = (write_end_offset + SECTOR_SIZE - 1) / SECTOR_SIZE;
// Erase sectors
bool erase_failed = false;
while (current_sector < needed_sectors) {
size_t sector_start = current_sector * SECTOR_SIZE;
size_t sector_end = sector_start + SECTOR_SIZE;
if (sector_end > partition_->size) {
ESP_LOGE(TAG, "Sector end (%u) exceeds partition size (%lu)", sector_end,
partition_->size);
erase_failed = true;
break;
}
ESP_LOGD(TAG, "Erasing sector %u (offset: %u, size: %u)", current_sector,
sector_start, SECTOR_SIZE);
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));
erase_failed = true;
break;
}
current_sector++;
}
ESP_LOGD(TAG, "Erasing sector %u (offset: %u, size: %u)", current_sector, sector_start, SECTOR_SIZE);
esp_err_t err = esp_partition_erase_range(partition_, sector_start, SECTOR_SIZE);
if (erase_failed) {
break;
}
esp_err_t err = esp_partition_write(partition_, HEADER_SIZE + total_written,
buffer.get() + buf_pos, write_len);
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;
ESP_LOGE(TAG, "Failed to write to assets partition at offset %u: %s",
(unsigned int)(HEADER_SIZE + total_written), esp_err_to_name(err));
break;
}
current_sector++;
total_written += write_len;
recent_written += write_len;
}
// 写入数据到分区
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;
}
// Calculate progress
if (esp_timer_get_time() - last_calc_time >= 1000000 ||
(header_collected + total_written) == content_length) {
size_t progress = (header_collected + total_written) * 100 / content_length;
size_t speed = recent_written;
ESP_LOGI(TAG, "Progress: %u%% (%u/%u), Speed: %u B/s, Sectors erased: %u", progress,
(unsigned int)(header_collected + total_written), (unsigned int)content_length,
(unsigned int)speed, (unsigned int)current_sector);
total_written += ret;
recent_written += ret;
// 计算进度和速度
if (esp_timer_get_time() - last_calc_time >= 1000000 || total_written == content_length || ret == 0) {
size_t progress = total_written * 100 / content_length;
size_t speed = recent_written; // 每秒的字节数
ESP_LOGI(TAG, "Progress: %u%% (%u/%u), Speed: %u B/s, Sectors erased: %u",
progress, total_written, content_length, speed, current_sector);
if (progress_callback) {
progress_callback(progress, speed);
}
last_calc_time = esp_timer_get_time();
recent_written = 0; // 重置最近写入的字节数
recent_written = 0;
}
}
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);
// Check if the downloaded size matches the expected size
if (success && (header_collected + total_written != content_length)) {
ESP_LOGE(TAG, "Downloaded size (%u) does not match expected size (%u)",
(unsigned int)(header_collected + total_written), (unsigned int)content_length);
success = false;
}
// Write header
if (success) {
esp_err_t err = esp_partition_write(partition_, 0, header_buf, HEADER_SIZE);
if (err != ESP_OK) {
ESP_LOGE(TAG, "Failed to write assets header to partition: %s", esp_err_to_name(err));
success = false;
}
}
if (!success) {
ESP_LOGE(TAG, "Assets download failed");
return false;
}
ESP_LOGI(TAG, "Assets download completed, total written: %u bytes, total sectors erased: %u",
total_written, current_sector);
ESP_LOGI(TAG,
"Header written, assets download completed, total written: %u bytes, total sectors "
"erased: %u",
(unsigned int)(header_collected + total_written), (unsigned int)current_sector);
// 重新初始化资源分区
// Re-initialize the assets partition
if (!InitializePartition()) {
ESP_LOGE(TAG, "Failed to re-initialize assets partition");
return false;
+27 -10
View File
@@ -1,12 +1,12 @@
#ifndef ASSETS_H
#define ASSETS_H
#include <string>
#include <functional>
#include <memory>
#include <string>
#include <cJSON.h>
#include <esp_partition.h>
#include <cJSON.h>
#include <model_path.h>
#include <map>
#include <string>
@@ -20,6 +20,14 @@ struct Asset {
size_t offset;
};
struct TextFontCapability {
bool glyph_push = false;
std::string bundle;
std::string charset;
int size = 0;
int bpp = 0;
};
class Assets {
public:
static Assets& GetInstance() {
@@ -28,12 +36,14 @@ public:
}
~Assets();
bool Download(std::string url, std::function<void(int progress, size_t speed)> progress_callback);
bool Download(std::string url,
std::function<void(int progress, size_t speed)> progress_callback);
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_; }
inline std::string default_assets_url() const { return default_assets_url_; }
inline TextFontCapability text_font_capability() const { return text_font_capability_; }
private:
Assets();
@@ -44,22 +54,27 @@ private:
void UnApplyPartition();
static bool FindPartition(Assets* assets);
static bool LoadSrmodelsFromIndex(Assets* assets, cJSON* root = nullptr);
void UseBuiltInTextFontCapability();
void DisableTextFontGlyphPush();
class AssetStrategy {
public:
virtual ~AssetStrategy() = default;
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;
virtual bool GetAssetData(Assets* assets, const std::string& name, void*& ptr,
size_t& size) = 0;
};
class LvglStrategy : public AssetStrategy {
public:
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;
bool GetAssetData(Assets* assets, const std::string& name, void*& ptr,
size_t& size) override;
private:
static uint32_t CalculateChecksum(const char* data, uint32_t length);
std::map<std::string, Asset> assets_;
@@ -67,15 +82,16 @@ private:
const char* mmap_root_ = nullptr;
bool checksum_valid_ = false;
};
class EmoteStrategy : public AssetStrategy {
public:
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;
bool GetAssetData(Assets* assets, const std::string& name, void*& ptr,
size_t& size) override;
};
// Strategy instance
std::unique_ptr<AssetStrategy> strategy_;
@@ -83,6 +99,7 @@ protected:
const esp_partition_t* partition_ = nullptr;
bool partition_valid_ = false;
std::string default_assets_url_;
TextFontCapability text_font_capability_;
srmodel_list_t* models_list_ = nullptr;
};
+52 -68
View File
@@ -1,88 +1,72 @@
# Audio Service Architecture
The audio service is a core component responsible for managing all audio-related functionalities, including capturing audio from the microphone, processing it, encoding/decoding, and playing back audio through the speaker. It is designed to be modular and efficient, running its main operations in dedicated FreeRTOS tasks to ensure real-time performance.
`AudioService` owns one input engine and keeps codec I/O, Opus encoding/decoding,
and network queues independent from the chip-specific speech pipeline.
## Key Components
## Input engines
- **`AudioService`**: The central orchestrator. It initializes and manages all other audio components, tasks, and data queues.
- **`AudioCodec`**: A hardware abstraction layer (HAL) for the physical audio codec chip. It handles the raw I2S communication for audio input and output.
- **`AudioProcessor`**: Performs real-time audio processing on the microphone input stream. This typically includes Acoustic Echo Cancellation (AEC), noise suppression, and Voice Activity Detection (VAD). `AfeAudioProcessor` is the default implementation, utilizing the ESP-ADF Audio Front-End.
- **`WakeWord`**: Detects keywords (e.g., "你好,小智", "Hi, ESP") from the audio stream. It runs independently from the main audio processor until a wake word is detected.
- **`OpusEncoderWrapper` / `OpusDecoderWrapper`**: Manages the encoding of PCM audio to the Opus format and decoding Opus packets back to PCM. Opus is used for its high compression and low latency, making it ideal for voice streaming.
- **`OpusResampler`**: A utility to convert audio streams between different sample rates (e.g., resampling from the codec's native sample rate to the required 16kHz for processing).
The old `AudioProcessor + WakeWord` combinations have been replaced by a single
`AudioEngine` interface. `AudioInputTask` reads PCM once and feeds exactly one
engine.
## Threading Model
| Target | Engine | Wake word | Uplink processing |
| --- | --- | --- | --- |
| ESP32-S3 / ESP32-P4 / ESP32-S31 | `AfeAudioEngine` | WakeNet inside AFE, or MultiNet fed from AFE output | FD AEC + VAD when audio processing is enabled |
| ESP32 / ESP32-C3 / ESP32-C5 / ESP32-C6 | `LiteAudioEngine` | Standalone WakeNet when configured | Raw mono PCM |
The service operates on three primary tasks to handle the different stages of the audio pipeline concurrently:
`AfeAudioEngine` owns a single FD AFE instance. WakeNet and voice uplink share
that instance, so enabling both no longer creates two AFE pipelines. For custom
MultiNet wake words, AFE fetch output is passed to `CustomWakeWord`; MultiNet is
not created on the smaller targets.
1. **`AudioInputTask`**: Solely responsible for reading raw PCM data from the `AudioCodec`. It then feeds this data to either the `WakeWord` engine or the `AudioProcessor` based on the current state.
2. **`AudioOutputTask`**: Responsible for playing audio. It retrieves decoded PCM data from the `audio_playback_queue_` and sends it to the `AudioCodec` to be played on the speaker.
3. **`OpusCodecTask`**: A worker task that handles both encoding and decoding. It fetches raw audio from `audio_encode_queue_`, encodes it into Opus packets, and places them in the `audio_send_queue_`. Concurrently, it fetches Opus packets from `audio_decode_queue_`, decodes them into PCM, and places the result in the `audio_playback_queue_`.
The AFE configuration currently uses `FD_LOW_COST` AEC with
`AEC_NLP_LEVEL_VERYAGGR`. WebRTC/NSNet noise suppression is intentionally
disabled because the project does not ship an NSNet model.
## Data Flow
When wake-word audio upload is enabled, the most recent two seconds of PCM are
stored in a single 64 KB PSRAM ring buffer. WakeNet and MultiNet share the same
cache implementation, and the encoder reads it one Opus frame at a time. This
avoids the previous per-chunk internal-SRAM allocations and temporary PCM
concatenation buffer.
There are two primary data flows: audio input (uplink) and audio output (downlink).
### 1. Audio Input (Uplink) Flow
This flow captures audio from the microphone, processes it, encodes it, and prepares it for sending to a server.
## Input data flow
```mermaid
graph TD
subgraph Device
Mic[("Microphone")] -->|I2S| Codec(AudioCodec)
subgraph AudioInputTask
Codec -->|Raw PCM| Read(ReadAudioData)
Read -->|16kHz PCM| Processor(AudioProcessor)
end
subgraph OpusCodecTask
Processor -->|Clean PCM| EncodeQueue(audio_encode_queue_)
EncodeQueue --> Encoder(OpusEncoder)
Encoder -->|Opus Packet| SendQueue(audio_send_queue_)
end
SendQueue --> |"PopPacketFromSendQueue()"| App(Application Layer)
end
App -->|Network| Server((Cloud Server))
flowchart LR
Mic[Microphone] --> Codec[AudioCodec]
Codec --> Input[AudioInputTask]
Input --> Engine[One AudioEngine]
Engine --> Wake[Wake-word event]
Engine --> PCM[16 kHz mono PCM]
PCM --> EncodeQueue[audio_encode_queue_]
EncodeQueue --> Opus[OpusCodecTask]
Opus --> SendQueue[audio_send_queue_]
SendQueue --> App[Application / network]
```
- The `AudioInputTask` continuously reads raw PCM data from the `AudioCodec`.
- This data is fed into an `AudioProcessor` for cleaning (AEC, VAD).
- The processed PCM data is pushed into the `audio_encode_queue_`.
- The `OpusCodecTask` picks up the PCM data, encodes it into Opus format, and pushes the resulting packet to the `audio_send_queue_`.
- The application can then retrieve these Opus packets and send them over the network.
Wake-word detection and voice processing are independent runtime states on the
same engine. On AFE targets, AEC stays active while wake-word detection is active
so playback reference remains available for wake-up during device playback. It
also stays active during voice processing when device AEC is requested.
### 2. Audio Output (Downlink) Flow
This flow receives encoded audio data, decodes it, and plays it on the speaker.
## Output data flow
```mermaid
graph TD
Server((Cloud Server)) -->|Network| App(Application Layer)
subgraph Device
App -->|"PushPacketToDecodeQueue()"| DecodeQueue(audio_decode_queue_)
subgraph OpusCodecTask
DecodeQueue -->|Opus Packet| Decoder(OpusDecoder)
Decoder -->|PCM| PlaybackQueue(audio_playback_queue_)
end
subgraph AudioOutputTask
PlaybackQueue -->|PCM| Codec(AudioCodec)
end
Codec -->|I2S| Speaker[("Speaker")]
end
flowchart LR
App[Application / network] --> DecodeQueue[audio_decode_queue_]
DecodeQueue --> Opus[OpusCodecTask]
Opus --> PlaybackQueue[audio_playback_queue_]
PlaybackQueue --> Output[AudioOutputTask]
Output --> Codec[AudioCodec]
Codec --> Speaker[Speaker]
```
- The application receives Opus packets from the network and pushes them into the `audio_decode_queue_`.
- The `OpusCodecTask` retrieves these packets, decodes them back into PCM data, and pushes the data to the `audio_playback_queue_`.
- The `AudioOutputTask` takes the PCM data from the queue and sends it to the `AudioCodec` for playback.
## Tasks and power management
## Power Management
- `AudioInputTask` reads codec input and feeds the selected engine.
- `AudioOutputTask` drains decoded PCM to the codec output.
- `OpusCodecTask` encodes uplink PCM and decodes downlink packets.
- `AfeAudioEngine` has its own AFE fetch task on S3/P4/S31.
To conserve energy, the audio codec's input (ADC) and output (DAC) channels are automatically disabled after a period of inactivity (`AUDIO_POWER_TIMEOUT_MS`). A timer (`audio_power_timer_`) periodically checks for activity and manages the power state. The channels are automatically re-enabled when new audio needs to be captured or played.
The audio power timer still enables and disables codec ADC/DAC channels based on
activity; the engine refactor does not change that policy.
+10
View File
@@ -4,6 +4,7 @@
#include <freertos/FreeRTOS.h>
#include <freertos/event_groups.h>
#include <driver/i2s_std.h>
#include <esp_idf_version.h>
#include <vector>
#include <string>
@@ -14,6 +15,15 @@
#define AUDIO_CODEC_DMA_DESC_NUM 6
#define AUDIO_CODEC_DMA_FRAME_NUM 240
// ESP-IDF 6 removed i2s_port_t and changed i2s_chan_config_t::id to an integer.
// Keep numeric I2S controller IDs usable on targets where IDF 5 does not expose
// every value through the target-specific i2s_port_t enum (for example ESP32-C3).
#if ESP_IDF_VERSION >= ESP_IDF_VERSION_VAL(6, 0, 0)
#define XIAOZHI_I2S_PORT(port) (port)
#else
#define XIAOZHI_I2S_PORT(port) static_cast<i2s_port_t>(port)
#endif
class AudioCodec {
public:
AudioCodec();
@@ -1,35 +1,34 @@
#include "audio_debugger.h"
#include "sdkconfig.h"
#if CONFIG_USE_AUDIO_DEBUGGER
#include <esp_log.h>
#include <arpa/inet.h>
#include <unistd.h>
#include <errno.h>
#include <cstring>
#include <errno.h>
#include <esp_log.h>
#include <string>
#include <unistd.h>
#endif
#define TAG "AudioDebugger"
AudioDebugger::AudioDebugger() {
#if CONFIG_USE_AUDIO_DEBUGGER
udp_sockfd_ = socket(AF_INET, SOCK_DGRAM, 0);
if (udp_sockfd_ >= 0) {
// 解析配置的服务器地址 "IP:PORT"
std::string server_addr = CONFIG_AUDIO_DEBUG_UDP_SERVER;
size_t colon_pos = server_addr.find(':');
if (colon_pos != std::string::npos) {
std::string ip = server_addr.substr(0, colon_pos);
int port = std::stoi(server_addr.substr(colon_pos + 1));
memset(&udp_server_addr_, 0, sizeof(udp_server_addr_));
udp_server_addr_.sin_family = AF_INET;
udp_server_addr_.sin_port = htons(port);
inet_pton(AF_INET, ip.c_str(), &udp_server_addr_.sin_addr);
ESP_LOGI(TAG, "Initialized server address: %s", CONFIG_AUDIO_DEBUG_UDP_SERVER);
} else {
ESP_LOGW(TAG, "Invalid server address: %s, should be IP:PORT", CONFIG_AUDIO_DEBUG_UDP_SERVER);
@@ -55,7 +54,7 @@ void AudioDebugger::Feed(const std::vector<int16_t>& data) {
#if CONFIG_USE_AUDIO_DEBUGGER
if (udp_sockfd_ >= 0) {
ssize_t sent = sendto(udp_sockfd_, data.data(), data.size() * sizeof(int16_t), 0,
(struct sockaddr*)&udp_server_addr_, sizeof(udp_server_addr_));
reinterpret_cast<struct sockaddr*>(&udp_server_addr_), sizeof(udp_server_addr_));
if (sent < 0) {
ESP_LOGW(TAG, "Failed to send audio data to %s: %d", CONFIG_AUDIO_DEBUG_UDP_SERVER, errno);
} else {
@@ -64,5 +63,3 @@ void AudioDebugger::Feed(const std::vector<int16_t>& data) {
}
#endif
}
@@ -1,11 +1,11 @@
#ifndef AUDIO_DEBUGGER_H
#define AUDIO_DEBUGGER_H
#include <vector>
#include <cstdint>
#include <vector>
#include <sys/socket.h>
#include <netinet/in.h>
#include <sys/socket.h>
class AudioDebugger {
public:
@@ -19,4 +19,4 @@ private:
struct sockaddr_in udp_server_addr_;
};
#endif
#endif
+40
View File
@@ -0,0 +1,40 @@
#ifndef AUDIO_ENGINE_H
#define AUDIO_ENGINE_H
#include <cstddef>
#include <cstdint>
#include <functional>
#include <string>
#include <vector>
#include <model_path.h>
#include "audio_codec.h"
class AudioEngine {
public:
virtual ~AudioEngine() = default;
virtual bool Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) = 0;
virtual void Feed(std::vector<int16_t>&& data) = 0;
virtual void EnableWakeWordDetection(bool enable) = 0;
virtual void EnableVoiceProcessing(bool enable) = 0;
virtual void EnableDeviceAec(bool enable) = 0;
virtual bool HasWakeWord() const = 0;
virtual bool IsWakeWordDetectionEnabled() const = 0;
virtual bool IsVoiceProcessingEnabled() const = 0;
virtual bool IsAfeWakeWord() const = 0;
virtual size_t GetFeedSize() const = 0;
virtual void OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) = 0;
virtual void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) = 0;
virtual void OnVadStateChange(std::function<void(bool speaking)> callback) = 0;
virtual void EncodeWakeWordData() = 0;
virtual bool GetWakeWordOpus(std::vector<uint8_t>& opus) = 0;
virtual const std::string& GetLastDetectedWakeWord() const = 0;
};
#endif
-26
View File
@@ -1,26 +0,0 @@
#ifndef AUDIO_PROCESSOR_H
#define AUDIO_PROCESSOR_H
#include <string>
#include <vector>
#include <functional>
#include <model_path.h>
#include "audio_codec.h"
class AudioProcessor {
public:
virtual ~AudioProcessor() = default;
virtual void Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) = 0;
virtual void Feed(std::vector<int16_t>&& data) = 0;
virtual void Start() = 0;
virtual void Stop() = 0;
virtual bool IsRunning() = 0;
virtual void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) = 0;
virtual void OnVadStateChange(std::function<void(bool speaking)> callback) = 0;
virtual size_t GetFeedSize() = 0;
virtual void EnableDeviceAec(bool enable) = 0;
};
#endif
+233 -143
View File
@@ -22,17 +22,10 @@
.self_delimited = false, \
}
#if CONFIG_USE_AUDIO_PROCESSOR
#include "processors/afe_audio_processor.h"
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32P4 || CONFIG_IDF_TARGET_ESP32S31
#include "engines/afe_audio_engine.h"
#else
#include "processors/no_audio_processor.h"
#endif
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32P4
#include "wake_words/afe_wake_word.h"
#include "wake_words/custom_wake_word.h"
#else
#include "wake_words/esp_wake_word.h"
#include "engines/lite_audio_engine.h"
#endif
#define TAG "AudioService"
@@ -92,22 +85,26 @@ void AudioService::Initialize(AudioCodec* codec) {
}
}
#if CONFIG_USE_AUDIO_PROCESSOR
audio_processor_ = std::make_unique<AfeAudioProcessor>();
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32P4 || CONFIG_IDF_TARGET_ESP32S31
audio_engine_ = std::make_unique<AfeAudioEngine>();
#else
audio_processor_ = std::make_unique<NoAudioProcessor>();
audio_engine_ = std::make_unique<LiteAudioEngine>();
#endif
audio_processor_->OnOutput([this](std::vector<int16_t>&& data) {
audio_engine_->OnOutput([this](std::vector<int16_t>&& data) {
PushTaskToEncodeQueue(kAudioTaskTypeEncodeToSendQueue, std::move(data));
});
audio_processor_->OnVadStateChange([this](bool speaking) {
audio_engine_->OnVadStateChange([this](bool speaking) {
voice_detected_ = speaking;
if (callbacks_.on_vad_change) {
callbacks_.on_vad_change(speaking);
}
});
audio_engine_->OnWakeWordDetected([this](const std::string& wake_word) {
xEventGroupClearBits(event_group_, AS_EVENT_WAKE_WORD_RUNNING);
if (callbacks_.on_wake_word_detected) {
callbacks_.on_wake_word_detected(wake_word);
}
});
esp_timer_create_args_t audio_power_timer_args = {
.callback = [](void* arg) {
@@ -123,8 +120,9 @@ void AudioService::Initialize(AudioCodec* codec) {
}
void AudioService::Start() {
service_stopped_ = false;
xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING | AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING);
service_stopped_.store(false);
xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING | AS_EVENT_WAKE_WORD_RUNNING |
AS_EVENT_AUDIO_PROCESSOR_RUNNING | AS_EVENT_AUDIO_INPUT_STOP_REQUEST);
esp_timer_start_periodic(audio_power_timer_, 1000000);
@@ -168,17 +166,25 @@ void AudioService::Start() {
void AudioService::Stop() {
esp_timer_stop(audio_power_timer_);
service_stopped_ = true;
service_stopped_.store(true);
xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING |
AS_EVENT_WAKE_WORD_RUNNING |
AS_EVENT_AUDIO_PROCESSOR_RUNNING);
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
audio_encode_queue_.clear();
audio_decode_queue_.clear();
audio_playback_queue_.clear();
audio_testing_queue_.clear();
audio_queue_cv_.notify_all();
bool notify_drained = false;
{
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
++playback_generation_;
audio_encode_queue_.clear();
audio_decode_queue_.clear();
audio_playback_queue_.clear();
audio_testing_queue_.clear();
notify_drained = MarkPlaybackDrainedLocked();
audio_queue_cv_.notify_all();
}
if (notify_drained && callbacks_.on_playback_drained) {
callbacks_.on_playback_drained();
}
}
bool AudioService::ReadAudioData(std::vector<int16_t>& data, int sample_rate, int samples) {
@@ -228,16 +234,39 @@ bool AudioService::ReadAudioData(std::vector<int16_t>& data, int sample_rate, in
}
void AudioService::AudioInputTask() {
constexpr EventBits_t kAudioInputActiveBits = AS_EVENT_AUDIO_TESTING_RUNNING |
AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING;
while (true) {
EventBits_t bits = xEventGroupWaitBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING |
AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING,
EventBits_t bits = xEventGroupWaitBits(event_group_, kAudioInputActiveBits |
AS_EVENT_AUDIO_INPUT_STOP_REQUEST,
pdFALSE, pdFALSE, portMAX_DELAY);
if (service_stopped_) {
if (service_stopped_.load()) {
// ADC continuous mode keeps its hardware mutex from start until stop,
// so the input task that started it must also stop it before exiting.
if (codec_->input_enabled()) {
codec_->EnableInput(false);
}
break;
}
if (audio_input_need_warmup_) {
audio_input_need_warmup_ = false;
if (bits & AS_EVENT_AUDIO_INPUT_STOP_REQUEST) {
xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_INPUT_STOP_REQUEST);
// Recheck the active state in this task. Audio capture may have been
// enabled after the timer posted the stop request.
bits = xEventGroupGetBits(event_group_);
if ((bits & kAudioInputActiveBits) == 0) {
if (codec_->input_enabled()) {
codec_->EnableInput(false);
}
// Do not process the stale active bits returned by waitBits().
continue;
}
}
if (audio_input_need_warmup_.exchange(false)) {
vTaskDelay(pdMS_TO_TICKS(120));
continue;
}
@@ -265,17 +294,12 @@ void AudioService::AudioInputTask() {
}
}
/* Feed the wake word and/or audio processor */
/* Feed the selected audio engine */
if (bits & (AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING)) {
int samples = 160; // 10ms
std::vector<int16_t> data;
if (ReadAudioData(data, 16000, samples)) {
if (bits & AS_EVENT_WAKE_WORD_RUNNING) {
wake_word_->Feed(data);
}
if (bits & AS_EVENT_AUDIO_PROCESSOR_RUNNING) {
audio_processor_->Feed(std::move(data));
}
audio_engine_->Feed(std::move(data));
continue;
}
}
@@ -290,13 +314,16 @@ void AudioService::AudioInputTask() {
void AudioService::AudioOutputTask() {
while (true) {
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
audio_queue_cv_.wait(lock, [this]() { return !audio_playback_queue_.empty() || service_stopped_; });
if (service_stopped_) {
audio_queue_cv_.wait(lock, [this]() {
return !audio_playback_queue_.empty() || service_stopped_.load();
});
if (service_stopped_.load()) {
break;
}
auto task = std::move(audio_playback_queue_.front());
audio_playback_queue_.pop_front();
output_in_flight_ = true;
audio_queue_cv_.notify_all();
lock.unlock();
@@ -312,13 +339,22 @@ void AudioService::AudioOutputTask() {
last_output_time_ = std::chrono::steady_clock::now();
debug_statistics_.playback_count++;
bool notify_drained = false;
lock.lock();
#if CONFIG_USE_SERVER_AEC
/* Record the timestamp for server AEC */
if (task->timestamp > 0) {
lock.lock();
timestamp_queue_.push_back(task->timestamp);
}
#endif
output_in_flight_ = false;
notify_drained = MarkPlaybackDrainedLocked();
audio_queue_cv_.notify_all();
lock.unlock();
if (notify_drained && callbacks_.on_playback_drained) {
callbacks_.on_playback_drained();
}
}
ESP_LOGW(TAG, "Audio output task stopped");
@@ -328,11 +364,11 @@ void AudioService::OpusCodecTask() {
while (true) {
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
audio_queue_cv_.wait(lock, [this]() {
return service_stopped_ ||
(!audio_encode_queue_.empty() && audio_send_queue_.size() < MAX_SEND_PACKETS_IN_QUEUE) ||
return service_stopped_.load() ||
!audio_encode_queue_.empty() ||
(!audio_decode_queue_.empty() && audio_playback_queue_.size() < MAX_PLAYBACK_TASKS_IN_QUEUE);
});
if (service_stopped_) {
if (service_stopped_.load()) {
break;
}
@@ -340,6 +376,8 @@ void AudioService::OpusCodecTask() {
if (!audio_decode_queue_.empty() && audio_playback_queue_.size() < MAX_PLAYBACK_TASKS_IN_QUEUE) {
auto packet = std::move(audio_decode_queue_.front());
audio_decode_queue_.pop_front();
decode_in_flight_ = true;
const uint32_t generation = playback_generation_;
audio_queue_cv_.notify_all();
lock.unlock();
@@ -348,6 +386,7 @@ void AudioService::OpusCodecTask() {
task->timestamp = packet->timestamp;
SetDecodeSampleRate(packet->sample_rate, packet->frame_duration);
bool decoded = false;
if (opus_decoder_ != nullptr) {
task->pcm.resize(decoder_frame_size_);
esp_audio_dec_in_raw_t raw = {
@@ -377,22 +416,30 @@ void AudioService::OpusCodecTask() {
resampled.resize(actual_output);
task->pcm = std::move(resampled);
}
lock.lock();
audio_playback_queue_.push_back(std::move(task));
audio_queue_cv_.notify_all();
debug_statistics_.decode_count++;
decoded = true;
} else {
ESP_LOGE(TAG, "Failed to decode audio after resize, error code: %d", ret);
lock.lock();
}
} else {
ESP_LOGE(TAG, "Audio decoder is not configured");
lock.lock();
}
lock.lock();
if (decoded && generation == playback_generation_ && !service_stopped_.load()) {
audio_playback_queue_.push_back(std::move(task));
}
decode_in_flight_ = false;
debug_statistics_.decode_count++;
const bool notify_drained = MarkPlaybackDrainedLocked();
audio_queue_cv_.notify_all();
lock.unlock();
if (notify_drained && callbacks_.on_playback_drained) {
callbacks_.on_playback_drained();
}
lock.lock();
}
/* Encode the audio to send queue */
if (!audio_encode_queue_.empty() && audio_send_queue_.size() < MAX_SEND_PACKETS_IN_QUEUE) {
if (!audio_encode_queue_.empty()) {
auto task = std::move(audio_encode_queue_.front());
audio_encode_queue_.pop_front();
audio_queue_cv_.notify_all();
@@ -421,6 +468,11 @@ void AudioService::OpusCodecTask() {
if (task->type == kAudioTaskTypeEncodeToSendQueue) {
{
std::lock_guard<std::mutex> lock2(audio_queue_mutex_);
/* Never let a full send queue stall encoding: stale realtime
* audio is useless to the server, so drop the oldest packet. */
if (audio_send_queue_.size() >= MAX_SEND_PACKETS_IN_QUEUE) {
audio_send_queue_.pop_front();
}
audio_send_queue_.push_back(std::move(packet));
}
if (callbacks_.on_send_queue_available) {
@@ -485,33 +537,62 @@ void AudioService::PushTaskToEncodeQueue(AudioTaskType type, std::vector<int16_t
auto task = std::make_unique<AudioTask>();
task->type = type;
task->pcm = std::move(pcm);
/* Push the task to the encode queue */
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
/* If the task is to send queue, we need to set the timestamp */
if (type == kAudioTaskTypeEncodeToSendQueue && !timestamp_queue_.empty()) {
if (timestamp_queue_.size() <= MAX_TIMESTAMPS_IN_QUEUE) {
task->timestamp = timestamp_queue_.front();
} else {
ESP_LOGW(TAG, "Timestamp queue (%u) is full, dropping timestamp", timestamp_queue_.size());
uint32_t dropped_total = 0;
{
/* Push the task to the encode queue */
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
/* If the task is to send queue, we need to set the timestamp */
if (type == kAudioTaskTypeEncodeToSendQueue && !timestamp_queue_.empty()) {
if (timestamp_queue_.size() <= MAX_TIMESTAMPS_IN_QUEUE) {
task->timestamp = timestamp_queue_.front();
} else {
ESP_LOGW(TAG, "Timestamp queue (%u) is full, dropping timestamp", timestamp_queue_.size());
}
timestamp_queue_.pop_front();
}
timestamp_queue_.pop_front();
/* Microphone audio is realtime, so drop the oldest frame instead of blocking.
* Blocking here would stall the audio engine task (AFE fetch) and deadlock the
* whole input pipeline when the send queue stops being drained (e.g. network
* congestion or a failed UDP send). */
if (audio_encode_queue_.size() >= MAX_ENCODE_TASKS_IN_QUEUE) {
audio_encode_queue_.pop_front();
dropped_total = ++debug_statistics_.encode_drop_count;
}
audio_encode_queue_.push_back(std::move(task));
audio_queue_cv_.notify_all();
}
audio_queue_cv_.wait(lock, [this]() { return audio_encode_queue_.size() < MAX_ENCODE_TASKS_IN_QUEUE; });
audio_encode_queue_.push_back(std::move(task));
audio_queue_cv_.notify_all();
/* Log outside the lock (UART writes are slow and would starve the codec task),
* at most once per second. */
if (dropped_total > 0) {
int64_t now = esp_timer_get_time();
if (now - last_encode_drop_log_time_ >= 1000000) {
last_encode_drop_log_time_ = now;
ESP_LOGW(TAG, "Encode queue is full, dropping oldest frame (dropped %lu so far)",
(unsigned long)dropped_total);
}
}
}
bool AudioService::PushPacketToDecodeQueue(std::unique_ptr<AudioStreamPacket> packet, bool wait) {
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
if (audio_decode_queue_.size() >= MAX_DECODE_PACKETS_IN_QUEUE) {
if (wait) {
audio_queue_cv_.wait(lock, [this]() { return audio_decode_queue_.size() < MAX_DECODE_PACKETS_IN_QUEUE; });
audio_queue_cv_.wait(lock, [this]() {
return service_stopped_.load() ||
audio_decode_queue_.size() < MAX_DECODE_PACKETS_IN_QUEUE;
});
} else {
return false;
}
}
if (service_stopped_.load()) {
return false;
}
playback_drained_notified_ = false;
audio_decode_queue_.push_back(std::move(packet));
audio_queue_cv_.notify_all();
return true;
@@ -529,76 +610,68 @@ std::unique_ptr<AudioStreamPacket> AudioService::PopPacketFromSendQueue() {
}
void AudioService::EncodeWakeWord() {
if (wake_word_) {
wake_word_->EncodeWakeWordData();
if (audio_engine_) {
audio_engine_->EncodeWakeWordData();
}
}
const std::string& AudioService::GetLastWakeWord() const {
return wake_word_->GetLastDetectedWakeWord();
static const std::string empty;
return audio_engine_ ? audio_engine_->GetLastDetectedWakeWord() : empty;
}
std::unique_ptr<AudioStreamPacket> AudioService::PopWakeWordPacket() {
auto packet = std::make_unique<AudioStreamPacket>();
if (wake_word_->GetWakeWordOpus(packet->payload)) {
if (audio_engine_ && audio_engine_->GetWakeWordOpus(packet->payload)) {
return packet;
}
return nullptr;
}
void AudioService::EnableWakeWordDetection(bool enable) {
if (!wake_word_) {
return;
}
ESP_LOGD(TAG, "%s wake word detection", enable ? "Enabling" : "Disabling");
if (enable) {
if (!wake_word_initialized_) {
if (!wake_word_->Initialize(codec_, models_list_)) {
ESP_LOGE(TAG, "Failed to initialize wake word");
return;
}
wake_word_initialized_ = true;
if (!InitializeAudioEngine() || !audio_engine_->HasWakeWord()) {
xEventGroupClearBits(event_group_, AS_EVENT_WAKE_WORD_RUNNING);
return;
}
// Reset input resampler to clear cached data from previous mode (e.g. AudioProcessor)
// This prevents buffer overflow when switching between different feed sizes
{
std::lock_guard<std::mutex> lock(input_resampler_mutex_);
if (input_resampler_ != nullptr) {
esp_ae_rate_cvt_reset(input_resampler_);
}
}
wake_word_->Start();
audio_engine_->EnableWakeWordDetection(true);
xEventGroupSetBits(event_group_, AS_EVENT_WAKE_WORD_RUNNING);
} else {
wake_word_->Stop();
if (audio_engine_initialized_) {
audio_engine_->EnableWakeWordDetection(false);
}
xEventGroupClearBits(event_group_, AS_EVENT_WAKE_WORD_RUNNING);
}
}
void AudioService::EnableVoiceProcessing(bool enable) {
ESP_LOGD(TAG, "%s voice processing", enable ? "Enabling" : "Disabling");
if (enable) {
if (!audio_processor_initialized_) {
audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_);
audio_processor_initialized_ = true;
}
/* We should make sure no audio is playing */
if (enable) {
if (!InitializeAudioEngine()) {
return;
}
ResetDecoder();
audio_input_need_warmup_ = true;
// Reset input resampler to clear cached data from previous mode (e.g. WakeWord)
// This prevents buffer overflow when switching between different feed sizes
{
std::lock_guard<std::mutex> lock(input_resampler_mutex_);
if (input_resampler_ != nullptr) {
esp_ae_rate_cvt_reset(input_resampler_);
}
}
audio_processor_->Start();
audio_engine_->EnableVoiceProcessing(true);
xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_PROCESSOR_RUNNING);
} else {
audio_processor_->Stop();
if (audio_engine_initialized_) {
audio_engine_->EnableVoiceProcessing(false);
}
xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_PROCESSOR_RUNNING);
}
}
@@ -612,18 +685,22 @@ void AudioService::EnableAudioTesting(bool enable) {
/* Copy audio_testing_queue_ to audio_decode_queue_ */
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
audio_decode_queue_ = std::move(audio_testing_queue_);
if (!audio_decode_queue_.empty()) {
playback_drained_notified_ = false;
}
audio_queue_cv_.notify_all();
}
}
void AudioService::EnableDeviceAec(bool enable) {
ESP_LOGI(TAG, "%s device AEC", enable ? "Enabling" : "Disabling");
if (!audio_processor_initialized_) {
audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_);
audio_processor_initialized_ = true;
}
device_aec_enabled_ = enable;
audio_processor_->EnableDeviceAec(enable);
if (audio_engine_initialized_) {
audio_engine_->EnableDeviceAec(enable);
} else {
ESP_LOGI(TAG, "Deferring AEC change until the audio engine is initialized");
}
}
void AudioService::SetCallbacks(AudioServiceCallbacks& callbacks) {
@@ -655,28 +732,47 @@ void AudioService::PlaySound(const std::string_view& ogg) {
bool AudioService::IsIdle() {
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
return audio_encode_queue_.empty() && audio_decode_queue_.empty() && audio_playback_queue_.empty() && audio_testing_queue_.empty();
return audio_encode_queue_.empty() && IsPlaybackDrainedLocked() && audio_testing_queue_.empty();
}
void AudioService::WaitForPlaybackQueueEmpty() {
std::unique_lock<std::mutex> lock(audio_queue_mutex_);
audio_queue_cv_.wait(lock, [this]() {
return service_stopped_ || (audio_decode_queue_.empty() && audio_playback_queue_.empty());
});
bool AudioService::IsPlaybackIdle() {
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
return IsPlaybackDrainedLocked();
}
void AudioService::ResetDecoder() {
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
std::unique_lock<std::mutex> decoder_lock(decoder_mutex_);
if (opus_decoder_ != nullptr) {
esp_opus_dec_reset(opus_decoder_);
bool notify_drained = false;
{
std::lock_guard<std::mutex> lock(audio_queue_mutex_);
++playback_generation_;
std::unique_lock<std::mutex> decoder_lock(decoder_mutex_);
if (opus_decoder_ != nullptr) {
esp_opus_dec_reset(opus_decoder_);
}
decoder_lock.unlock();
timestamp_queue_.clear();
audio_decode_queue_.clear();
audio_playback_queue_.clear();
audio_testing_queue_.clear();
notify_drained = MarkPlaybackDrainedLocked();
audio_queue_cv_.notify_all();
}
decoder_lock.unlock();
timestamp_queue_.clear();
audio_decode_queue_.clear();
audio_playback_queue_.clear();
audio_testing_queue_.clear();
audio_queue_cv_.notify_all();
if (notify_drained && callbacks_.on_playback_drained) {
callbacks_.on_playback_drained();
}
}
bool AudioService::IsPlaybackDrainedLocked() const {
return audio_decode_queue_.empty() && audio_playback_queue_.empty() &&
!decode_in_flight_ && !output_in_flight_;
}
bool AudioService::MarkPlaybackDrainedLocked() {
if (!IsPlaybackDrainedLocked() || playback_drained_notified_) {
return false;
}
playback_drained_notified_ = true;
return true;
}
void AudioService::CheckAndUpdateAudioPowerState() {
@@ -684,7 +780,9 @@ void AudioService::CheckAndUpdateAudioPowerState() {
auto input_elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - last_input_time_).count();
auto output_elapsed = std::chrono::duration_cast<std::chrono::milliseconds>(now - last_output_time_).count();
if (input_elapsed > AUDIO_POWER_TIMEOUT_MS && codec_->input_enabled()) {
codec_->EnableInput(false);
// ADC continuous start/stop must run in the same task. Wake the audio
// input task instead of closing the codec from the esp_timer task.
xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_INPUT_STOP_REQUEST);
}
if (output_elapsed > AUDIO_POWER_TIMEOUT_MS && codec_->output_enabled()) {
// Keep TX clock when duplex RX is active; otherwise RX may stall on some boards.
@@ -698,37 +796,29 @@ void AudioService::CheckAndUpdateAudioPowerState() {
}
void AudioService::SetModelsList(srmodel_list_t* models_list) {
if (audio_engine_initialized_ && models_list_ != models_list) {
ESP_LOGW(TAG, "Ignoring speech model replacement after audio engine initialization");
return;
}
models_list_ = models_list;
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32P4
if (esp_srmodel_filter(models_list_, ESP_MN_PREFIX, NULL) != nullptr) {
wake_word_ = std::make_unique<CustomWakeWord>();
} else if (esp_srmodel_filter(models_list_, ESP_WN_PREFIX, NULL) != nullptr) {
wake_word_ = std::make_unique<AfeWakeWord>();
} else {
wake_word_ = nullptr;
}
#else
if (esp_srmodel_filter(models_list_, ESP_WN_PREFIX, NULL) != nullptr) {
wake_word_ = std::make_unique<EspWakeWord>();
} else {
wake_word_ = nullptr;
}
#endif
if (wake_word_) {
wake_word_->OnWakeWordDetected([this](const std::string& wake_word) {
if (callbacks_.on_wake_word_detected) {
callbacks_.on_wake_word_detected(wake_word);
}
});
}
}
bool AudioService::IsAfeWakeWord() {
#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32P4
return wake_word_ != nullptr && dynamic_cast<AfeWakeWord*>(wake_word_.get()) != nullptr;
#else
return false;
#endif
return audio_engine_initialized_ && audio_engine_->IsAfeWakeWord();
}
bool AudioService::InitializeAudioEngine() {
if (!audio_engine_) {
return false;
}
if (audio_engine_initialized_) {
return true;
}
if (!audio_engine_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_)) {
ESP_LOGE(TAG, "Failed to initialize audio engine");
return false;
}
audio_engine_initialized_ = true;
audio_engine_->EnableDeviceAec(device_aec_enabled_);
return true;
}
+29 -15
View File
@@ -2,6 +2,7 @@
#define AUDIO_SERVICE_H
#include <memory>
#include <atomic>
#include <deque>
#include <condition_variable>
#include <chrono>
@@ -19,18 +20,17 @@
#include "esp_audio_types.h"
#include "audio_codec.h"
#include "audio_processor.h"
#include "processors/audio_debugger.h"
#include "wake_word.h"
#include "audio_debugger.h"
#include "audio_engine.h"
#include "protocol.h"
#include "ogg_demuxer.h"
/*
* There are two types of audio data flow:
* 1. (MIC) -> [Processors] -> {Encode Queue} -> [Opus Encoder] -> {Send Queue} -> (Server)
* 1. (MIC) -> [Audio Engine] -> {Encode Queue} -> [Opus Encoder] -> {Send Queue} -> (Server)
* 2. (Server) -> {Decode Queue} -> [Opus Decoder] -> {Playback Queue} -> (Speaker)
*
* We use one task for MIC / Speaker / Processors, and one task for Opus Encoder / Opus Decoder.
* We use dedicated tasks for input, output, and Opus encoding/decoding.
*
* Decode Queue and Send Queue are the main queues, because Opus packets are quite smaller than PCM packets.
*
@@ -50,7 +50,7 @@
#define AS_EVENT_AUDIO_TESTING_RUNNING (1 << 0)
#define AS_EVENT_WAKE_WORD_RUNNING (1 << 1)
#define AS_EVENT_AUDIO_PROCESSOR_RUNNING (1 << 2)
#define AS_EVENT_PLAYBACK_NOT_EMPTY (1 << 3)
#define AS_EVENT_AUDIO_INPUT_STOP_REQUEST (1 << 4)
#define AS_OPUS_GET_FRAME_DRU_ENUM(duration_ms) \
((duration_ms) == 5 ? ESP_OPUS_ENC_FRAME_DURATION_5_MS : \
@@ -80,6 +80,8 @@ struct AudioServiceCallbacks {
std::function<void(const std::string&)> on_wake_word_detected;
std::function<void(bool)> on_vad_change;
std::function<void(void)> on_audio_testing_queue_full;
// Fired when the decode/playback queues and their in-flight work are drained.
std::function<void(void)> on_playback_drained;
};
@@ -92,7 +94,7 @@ enum AudioTaskType {
struct AudioTask {
AudioTaskType type;
std::vector<int16_t> pcm;
uint32_t timestamp;
uint32_t timestamp = 0;
};
struct DebugStatistics {
@@ -100,6 +102,7 @@ struct DebugStatistics {
uint32_t decode_count = 0;
uint32_t encode_count = 0;
uint32_t playback_count = 0;
uint32_t encode_drop_count = 0;
};
class AudioService {
@@ -115,7 +118,7 @@ public:
const std::string& GetLastWakeWord() const;
bool IsVoiceDetected() const { return voice_detected_; }
bool IsIdle();
void WaitForPlaybackQueueEmpty();
bool IsPlaybackIdle();
bool IsWakeWordRunning() const { return xEventGroupGetBits(event_group_) & AS_EVENT_WAKE_WORD_RUNNING; }
bool IsAudioProcessorRunning() const { return xEventGroupGetBits(event_group_) & AS_EVENT_AUDIO_PROCESSOR_RUNNING; }
bool IsAfeWakeWord();
@@ -137,8 +140,7 @@ public:
private:
AudioCodec* codec_ = nullptr;
AudioServiceCallbacks callbacks_;
std::unique_ptr<AudioProcessor> audio_processor_;
std::unique_ptr<WakeWord> wake_word_;
std::unique_ptr<AudioEngine> audio_engine_;
std::unique_ptr<AudioDebugger> audio_debugger_;
void* opus_encoder_ = nullptr;
void* opus_decoder_ = nullptr;
@@ -156,6 +158,7 @@ private:
int decoder_duration_ms_ = OPUS_FRAME_DURATION_MS;
int decoder_frame_size_ = 0;
DebugStatistics debug_statistics_;
int64_t last_encode_drop_log_time_ = 0;
srmodel_list_t* models_list_ = nullptr;
EventGroupHandle_t event_group_;
@@ -171,14 +174,22 @@ private:
std::deque<std::unique_ptr<AudioStreamPacket>> audio_testing_queue_;
std::deque<std::unique_ptr<AudioTask>> audio_encode_queue_;
std::deque<std::unique_ptr<AudioTask>> audio_playback_queue_;
bool decode_in_flight_ = false;
bool output_in_flight_ = false;
bool playback_drained_notified_ = true;
uint32_t playback_generation_ = 0;
// For server AEC
std::deque<uint32_t> timestamp_queue_;
bool wake_word_initialized_ = false;
bool audio_processor_initialized_ = false;
bool audio_engine_initialized_ = false;
bool voice_detected_ = false;
bool service_stopped_ = true;
bool audio_input_need_warmup_ = false;
#if CONFIG_USE_DEVICE_AEC
bool device_aec_enabled_ = true;
#else
bool device_aec_enabled_ = false;
#endif
std::atomic<bool> service_stopped_{true};
std::atomic<bool> audio_input_need_warmup_{false};
esp_timer_handle_t audio_power_timer_ = nullptr;
std::chrono::steady_clock::time_point last_input_time_;
@@ -188,8 +199,11 @@ private:
void AudioOutputTask();
void OpusCodecTask();
void PushTaskToEncodeQueue(AudioTaskType type, std::vector<int16_t>&& pcm);
bool InitializeAudioEngine();
void SetDecodeSampleRate(int sample_rate, int frame_duration);
void CheckAndUpdateAudioPowerState();
bool IsPlaybackDrainedLocked() const;
bool MarkPlaybackDrainedLocked();
};
#endif
#endif
+20 -8
View File
@@ -7,14 +7,18 @@
#define TAG "BoxAudioCodec"
BoxAudioCodec::BoxAudioCodec(void* i2c_master_handle, 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; // 输入通道数
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, float input_gain,
int reference_gain_channel, float reference_gain) {
duplex_ = true; // 是否双工
input_reference_ = input_reference; // 是否使用参考输入,实现回声消除
input_channels_ = input_reference_ ? 2 : 1; // 输入通道数
input_sample_rate_ = input_sample_rate;
output_sample_rate_ = output_sample_rate;
input_gain_ = 30;
input_gain_ = input_gain;
reference_gain_channel_ = reference_gain_channel;
reference_gain_ = reference_gain;
CreateDuplexChannels(mclk, bclk, ws, dout, din);
@@ -203,7 +207,15 @@ void BoxAudioCodec::EnableInput(bool enable) {
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_));
ESP_ERROR_CHECK(esp_codec_dev_set_in_channel_gain(
input_dev_, ESP_CODEC_DEV_MAKE_CHANNEL_MASK(0), input_gain_));
if (input_reference_ && reference_gain_channel_ >= 0) {
// ES7210 gain masks use physical MIC numbering, which differs
// from the TDM slot order (MIC1, MIC3, MIC2, MIC4).
ESP_ERROR_CHECK(esp_codec_dev_set_in_channel_gain(
input_dev_, ESP_CODEC_DEV_MAKE_CHANNEL_MASK(reference_gain_channel_),
reference_gain_));
}
} else {
ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_));
}
@@ -244,4 +256,4 @@ int BoxAudioCodec::Write(const int16_t* data, int samples) {
ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_write(output_dev_, (void*)data, samples * sizeof(int16_t)));
}
return samples;
}
}
+8 -3
View File
@@ -20,16 +20,21 @@ private:
esp_codec_dev_handle_t output_dev_ = nullptr;
esp_codec_dev_handle_t input_dev_ = nullptr;
std::mutex data_if_mutex_;
int reference_gain_channel_ = -1;
float reference_gain_ = 0.0f;
void CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din);
void CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout,
gpio_num_t din);
virtual int Read(int16_t* dest, int samples) override;
virtual int Write(const int16_t* data, int samples) override;
public:
BoxAudioCodec(void* i2c_master_handle, 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);
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,
float input_gain = 30.0f, int reference_gain_channel = -1,
float reference_gain = 0.0f);
virtual ~BoxAudioCodec();
virtual void SetOutputVolume(int volume) override;
+21 -3
View File
@@ -1,6 +1,8 @@
#include "es8311_audio_codec.h"
#include <esp_log.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#define TAG "Es8311AudioCodec"
@@ -36,6 +38,7 @@ Es8311AudioCodec::Es8311AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port,
};
ctrl_if_ = audio_codec_new_i2c_ctrl(&i2c_cfg);
assert(ctrl_if_ != NULL);
ResetCodec();
gpio_if_ = audio_codec_new_gpio();
assert(gpio_if_ != NULL);
@@ -58,6 +61,17 @@ Es8311AudioCodec::Es8311AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port,
}
}
void Es8311AudioCodec::ResetCodec() {
// Hold the ES8311 digital blocks in reset for several milliseconds, as
// recommended by the initialization guide. Normal codec initialization
// releases the reset and starts the state machine.
uint8_t reset_value = 0x1F;
ESP_ERROR_CHECK(static_cast<esp_err_t>(
ctrl_if_->write_reg(ctrl_if_, 0x00, 1, &reset_value, 1)));
vTaskDelay(pdMS_TO_TICKS(5));
ESP_LOGI(TAG, "ES8311 software reset complete");
}
Es8311AudioCodec::~Es8311AudioCodec() {
esp_codec_dev_delete(dev_);
@@ -88,7 +102,8 @@ void Es8311AudioCodec::UpdateDeviceState() {
ESP_ERROR_CHECK(esp_codec_dev_set_in_gain(dev_, input_gain_));
ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(dev_, output_volume_));
} else if (!input_enabled_ && !output_enabled_ && dev_ != nullptr) {
esp_codec_dev_close(dev_);
ESP_ERROR_CHECK(esp_codec_dev_close(dev_));
esp_codec_dev_delete(dev_);
dev_ = nullptr;
}
if (pa_pin_ != GPIO_NUM_NC) {
@@ -156,7 +171,10 @@ void Es8311AudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gp
}
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);
}
@@ -196,4 +214,4 @@ int Es8311AudioCodec::Write(const int16_t* data, int samples) {
ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_write(dev_, (void*)data, samples * sizeof(int16_t)));
}
return samples;
}
}
+2 -1
View File
@@ -23,6 +23,7 @@ private:
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);
void ResetCodec();
void UpdateDeviceState();
virtual int Read(int16_t* dest, int samples) override;
@@ -39,4 +40,4 @@ public:
virtual void EnableOutput(bool enable) override;
};
#endif // _ES8311_AUDIO_CODEC_H
#endif // _ES8311_AUDIO_CODEC_H
+1 -1
View File
@@ -3,7 +3,7 @@
#include "audio_codec.h"
#include <driver/i2c.h>
#include <driver/i2c_master.h>
#include <driver/gpio.h>
#include <esp_codec_dev.h>
#include <esp_codec_dev_defaults.h>
+18 -9
View File
@@ -176,14 +176,17 @@ void Es8388AudioCodec::EnableOutput(bool enable) {
return;
}
if (enable) {
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = 16,
.channel = 1,
.channel_mask = 0,
.sample_rate = (uint32_t)output_sample_rate_,
.mclk_multiple = 0,
};
ESP_ERROR_CHECK(esp_codec_dev_open(output_dev_, &fs));
if (!output_device_opened_) {
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = 16,
.channel = 1,
.channel_mask = 0,
.sample_rate = (uint32_t)output_sample_rate_,
.mclk_multiple = 0,
};
ESP_ERROR_CHECK(esp_codec_dev_open(output_dev_, &fs));
output_device_opened_ = true;
}
ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, output_volume_));
// Set analog output volume to 0dB, default is -45dB
@@ -193,11 +196,17 @@ void Es8388AudioCodec::EnableOutput(bool enable) {
ctrl_if_->write_reg(ctrl_if_, reg, 1, &reg_val, 1);
}
ESP_ERROR_CHECK(esp_codec_dev_set_out_mute(output_dev_, false));
if (pa_pin_ != GPIO_NUM_NC) {
gpio_set_level(pa_pin_, 1);
}
} else {
ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_));
// Keep TX open because the paired RX channel may use it as the clock
// source. Reopening the shared duplex interface after WiFi provisioning
// can otherwise leave playback silent on ESP-IDF 6.
if (output_device_opened_) {
ESP_ERROR_CHECK(esp_codec_dev_set_out_mute(output_dev_, true));
}
if (pa_pin_ != GPIO_NUM_NC) {
gpio_set_level(pa_pin_, 0);
}
+1
View File
@@ -18,6 +18,7 @@ private:
esp_codec_dev_handle_t output_dev_ = nullptr;
esp_codec_dev_handle_t input_dev_ = nullptr;
bool output_device_opened_ = false;
gpio_num_t pa_pin_ = GPIO_NUM_NC;
std::mutex data_if_mutex_;
+22 -11
View File
@@ -169,21 +169,32 @@ void Es8389AudioCodec::EnableOutput(bool enable) {
return;
}
if (enable) {
// Play 16bit 1 channel
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = 16,
.channel = 1,
.channel_mask = 0,
.sample_rate = (uint32_t)output_sample_rate_,
.mclk_multiple = 0,
};
ESP_ERROR_CHECK(esp_codec_dev_open(output_dev_, &fs));
if (!output_device_opened_) {
// Play 16bit 1 channel
esp_codec_dev_sample_info_t fs = {
.bits_per_sample = 16,
.channel = 1,
.channel_mask = 0,
.sample_rate = (uint32_t)output_sample_rate_,
.mclk_multiple = 0,
};
ESP_ERROR_CHECK(esp_codec_dev_open(output_dev_, &fs));
output_device_opened_ = true;
}
ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, output_volume_));
ESP_ERROR_CHECK(esp_codec_dev_set_out_mute(output_dev_, false));
if (pa_pin_ != GPIO_NUM_NC) {
gpio_set_level(pa_pin_, 1);
}
} else {
ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_));
// Keep the ES8389 TX data interface open. On ESP32-S3 the RX channel
// uses the paired TX channel as its clock source, and reopening TX via
// esp_codec_dev after it has been closed can leave the IDF 6 duplex
// channel silent even though esp_codec_dev_open reports success.
// Muting preserves the logical power state without reconfiguring I2S.
if (output_device_opened_) {
ESP_ERROR_CHECK(esp_codec_dev_set_out_mute(output_dev_, true));
}
if (pa_pin_ != GPIO_NUM_NC) {
gpio_set_level(pa_pin_, 0);
}
@@ -203,4 +214,4 @@ int Es8389AudioCodec::Write(const int16_t* data, int samples) {
ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_write(output_dev_, (void*)data, samples * sizeof(int16_t)));
}
return samples;
}
}
+2 -1
View File
@@ -3,7 +3,7 @@
#include "audio_codec.h"
#include <driver/i2c.h>
#include <driver/i2c_master.h>
#include <driver/gpio.h>
#include <esp_codec_dev.h>
#include <esp_codec_dev_defaults.h>
@@ -18,6 +18,7 @@ private:
esp_codec_dev_handle_t output_dev_ = nullptr;
esp_codec_dev_handle_t input_dev_ = nullptr;
bool output_device_opened_ = false;
gpio_num_t pa_pin_ = GPIO_NUM_NC;
std::mutex data_if_mutex_;
+10 -9
View File
@@ -2,6 +2,7 @@
#include <esp_log.h>
#include <cmath>
#include <cstdint>
#include <cstring>
#define TAG "NoAudioCodec"
@@ -21,7 +22,7 @@ NoAudioCodecDuplex::NoAudioCodecDuplex(int input_sample_rate, int output_sample_
output_sample_rate_ = output_sample_rate;
i2s_chan_config_t chan_cfg = {
.id = I2S_NUM_0,
.id = XIAOZHI_I2S_PORT(0),
.role = I2S_ROLE_MASTER,
.dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM,
.dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM,
@@ -82,7 +83,7 @@ NoAudioCodecSimplex::NoAudioCodecSimplex(int input_sample_rate, int output_sampl
// Create a new channel for speaker
i2s_chan_config_t chan_cfg = {
.id = (i2s_port_t)0,
.id = XIAOZHI_I2S_PORT(0),
.role = I2S_ROLE_MASTER,
.dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM,
.dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM,
@@ -133,7 +134,7 @@ NoAudioCodecSimplex::NoAudioCodecSimplex(int input_sample_rate, int output_sampl
ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg));
// Create a new channel for MIC
chan_cfg.id = (i2s_port_t)1;
chan_cfg.id = XIAOZHI_I2S_PORT(1);
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, nullptr, &rx_handle_));
std_cfg.clk_cfg.sample_rate_hz = (uint32_t)input_sample_rate_;
std_cfg.gpio_cfg.bclk = mic_sck;
@@ -151,7 +152,7 @@ NoAudioCodecSimplex::NoAudioCodecSimplex(int input_sample_rate, int output_sampl
// Create a new channel for speaker
i2s_chan_config_t chan_cfg = {
.id = (i2s_port_t)0,
.id = XIAOZHI_I2S_PORT(0),
.role = I2S_ROLE_MASTER,
.dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM,
.dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM,
@@ -202,7 +203,7 @@ NoAudioCodecSimplex::NoAudioCodecSimplex(int input_sample_rate, int output_sampl
ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg));
// Create a new channel for MIC
chan_cfg.id = (i2s_port_t)1;
chan_cfg.id = XIAOZHI_I2S_PORT(1);
ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, nullptr, &rx_handle_));
std_cfg.clk_cfg.sample_rate_hz = (uint32_t)input_sample_rate_;
std_cfg.slot_cfg.slot_mask = mic_slot_mask;
@@ -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 TickType_t kReadTimeoutTicks = pdMS_TO_TICKS(200);
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, kReadTimeoutTicks) != ESP_OK) {
if (i2s_channel_read(rx_handle_, bit32_buffer.data(), samples * sizeof(int32_t), &bytes_read, kReadTimeoutMs) != ESP_OK) {
return 0;
}
@@ -292,7 +293,7 @@ NoAudioCodecSimplexPdm::NoAudioCodecSimplexPdm(int input_sample_rate, int output
output_sample_rate_ = output_sample_rate;
// Create a new channel for speaker
i2s_chan_config_t tx_chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG((i2s_port_t)1, I2S_ROLE_MASTER);
i2s_chan_config_t tx_chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(XIAOZHI_I2S_PORT(1), I2S_ROLE_MASTER);
tx_chan_cfg.dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM;
tx_chan_cfg.dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM;
tx_chan_cfg.auto_clear_after_cb = true;
@@ -342,7 +343,7 @@ NoAudioCodecSimplexPdm::NoAudioCodecSimplexPdm(int input_sample_rate, int output
ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &tx_std_cfg));
#if SOC_I2S_SUPPORTS_PDM_RX
// Create a new channel for MIC in PDM mode
i2s_chan_config_t rx_chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG((i2s_port_t)0, I2S_ROLE_MASTER);
i2s_chan_config_t rx_chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG(XIAOZHI_I2S_PORT(0), I2S_ROLE_MASTER);
ESP_ERROR_CHECK(i2s_new_channel(&rx_chan_cfg, NULL, &rx_handle_));
i2s_pdm_rx_config_t pdm_rx_cfg = {
.clk_cfg = I2S_PDM_RX_CLK_DEFAULT_CONFIG((uint32_t)input_sample_rate_),
+571
View File
@@ -0,0 +1,571 @@
#include "afe_audio_engine.h"
#include <cassert>
#include <cstring>
#include <sstream>
#include <esp_heap_caps.h>
#include <esp_log.h>
#include <esp_timer.h>
#include <esp_vadn_models.h>
#include "audio_service.h"
#include "wake_words/custom_wake_word.h"
#define TAG "AfeAudioEngine"
#if CONFIG_USE_AUDIO_PROCESSOR
static constexpr bool kUseAfeForVoiceProcessing = true;
#else
static constexpr bool kUseAfeForVoiceProcessing = false;
#endif
AfeAudioEngine::AfeAudioEngine() {
event_group_ = xEventGroupCreate();
}
AfeAudioEngine::~AfeAudioEngine() {
custom_wake_word_.reset();
if (afe_data_ != nullptr) {
afe_iface_->destroy(afe_data_);
}
if (wake_word_encode_task_stack_ != nullptr) {
heap_caps_free(wake_word_encode_task_stack_);
}
if (wake_word_encode_task_buffer_ != nullptr) {
heap_caps_free(wake_word_encode_task_buffer_);
}
if (owns_models_ && models_ != nullptr) {
esp_srmodel_deinit(models_);
}
if (event_group_ != nullptr) {
vEventGroupDelete(event_group_);
}
}
bool AfeAudioEngine::Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) {
if (afe_data_ != nullptr || (codec_ != nullptr && !kUseAfeForVoiceProcessing && wake_detector_ == WakeDetector::kNone)) {
return true;
}
if (codec == nullptr) {
ESP_LOGE(TAG, "Codec is null");
return false;
}
codec_ = codec;
frame_samples_ = frame_duration_ms * 16000 / 1000;
output_buffer_.reserve(frame_samples_);
if (models_list == nullptr) {
models_ = esp_srmodel_init("model");
owns_models_ = models_ != nullptr;
} else {
models_ = models_list;
}
char* wakenet_model_name = nullptr;
char* multinet_model_name = nullptr;
if (models_ != nullptr && models_->num > 0) {
wakenet_model_name = esp_srmodel_filter(models_, ESP_WN_PREFIX, nullptr);
multinet_model_name = esp_srmodel_filter(models_, ESP_MN_PREFIX, nullptr);
for (int i = 0; i < models_->num; ++i) {
ESP_LOGI(TAG, "Model %d: %s", i, models_->model_name[i]);
}
}
if (multinet_model_name != nullptr) {
wake_detector_ = WakeDetector::kMultiNet;
custom_wake_word_ = std::make_unique<CustomWakeWord>();
custom_wake_word_->OnWakeWordDetected([this](const std::string& wake_word) {
last_detected_wake_word_ = wake_word;
xEventGroupClearBits(event_group_, kWakeWordEnabled);
UpdateActiveState();
if (wake_word_detected_callback_) {
wake_word_detected_callback_(wake_word);
}
});
if (!custom_wake_word_->Initialize(codec_, models_)) {
ESP_LOGE(TAG, "Failed to initialize MultiNet wake-word detector");
custom_wake_word_.reset();
wake_detector_ = WakeDetector::kNone;
return false;
}
} else if (wakenet_model_name != nullptr) {
wake_detector_ = WakeDetector::kWakeNet;
auto words = esp_srmodel_get_wake_words(models_, wakenet_model_name);
if (words != nullptr) {
std::stringstream stream(words);
std::string word;
while (std::getline(stream, word, ';')) {
wake_words_.push_back(word);
}
}
#if CONFIG_SEND_WAKE_WORD_DATA
if (!wake_word_audio_cache_.Initialize(16000 * 2)) {
ESP_LOGW(TAG, "Wake-word audio upload disabled: PSRAM cache allocation failed");
}
#endif
}
const bool needs_afe = kUseAfeForVoiceProcessing || wake_detector_ != WakeDetector::kNone;
if (!needs_afe) {
ESP_LOGI(TAG, "Initialized as raw engine because AFE features are disabled");
return true;
}
int ref_num = codec_->input_reference() ? 1 : 0;
std::string input_format;
for (int i = 0; i < codec_->input_channels() - ref_num; ++i) {
input_format.push_back('M');
}
for (int i = 0; i < ref_num; ++i) {
input_format.push_back('R');
}
char* vad_model_name = models_ == nullptr
? nullptr
: esp_srmodel_filter(models_, ESP_VADN_PREFIX, nullptr);
afe_config_t* afe_config = afe_config_init(
input_format.c_str(), models_, AFE_TYPE_VC, AFE_MODE_HIGH_PERF);
if (afe_config == nullptr) {
ESP_LOGE(TAG, "Failed to create AFE configuration");
return false;
}
afe_config->aec_init = codec_->input_reference();
afe_config->aec_mode = AEC_MODE_VOIP_HIGH_PERF;
afe_config->aec_nlp_level = AEC_NLP_LEVEL_VERYAGGR;
afe_config->ns_init = false;
afe_config->vad_init = kUseAfeForVoiceProcessing;
afe_config->vad_mode = VAD_MODE_0;
afe_config->vad_min_noise_ms = 100;
if (vad_model_name != nullptr) {
afe_config->vad_model_name = vad_model_name;
}
afe_config->wakenet_init = wake_detector_ == WakeDetector::kWakeNet;
afe_config->wakenet_model_name = wake_detector_ == WakeDetector::kWakeNet
? wakenet_model_name
: nullptr;
afe_config->agc_init = false;
afe_config->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM;
afe_iface_ = esp_afe_handle_from_config(afe_config);
if (afe_iface_ != nullptr) {
afe_data_ = afe_iface_->create_from_config(afe_config);
}
afe_config_free(afe_config);
if (afe_iface_ == nullptr || afe_data_ == nullptr) {
ESP_LOGE(TAG, "Failed to create FD AFE instance");
afe_iface_ = nullptr;
afe_data_ = nullptr;
return false;
}
if (wake_detector_ == WakeDetector::kWakeNet) {
afe_iface_->disable_wakenet(afe_data_);
}
if (codec_->input_reference()) {
afe_iface_->disable_aec(afe_data_);
}
afe_iface_->print_pipeline(afe_data_);
BaseType_t task_created = xTaskCreate([](void* arg) {
auto* engine = static_cast<AfeAudioEngine*>(arg);
engine->ProcessingTask();
vTaskDelete(nullptr);
}, "audio_afe", 4096, this, 3, &processing_task_);
if (task_created != pdPASS) {
ESP_LOGE(TAG, "Failed to create AFE processing task");
afe_iface_->destroy(afe_data_);
afe_data_ = nullptr;
afe_iface_ = nullptr;
return false;
}
const char* detector = wake_detector_ == WakeDetector::kWakeNet
? "WakeNet"
: (wake_detector_ == WakeDetector::kMultiNet ? "MultiNet" : "none");
ESP_LOGI(TAG, "Initialized FD AFE, detector: %s, NS: off, feed: %d, fetch: %d",
detector, afe_iface_->get_feed_chunksize(afe_data_), afe_iface_->get_fetch_chunksize(afe_data_));
return true;
}
void AfeAudioEngine::Feed(std::vector<int16_t>&& data) {
EventBits_t bits = xEventGroupGetBits(event_group_);
if ((bits & kVoiceProcessingEnabled) && !kUseAfeForVoiceProcessing) {
OutputRawAudio(data);
}
if (afe_data_ == nullptr || (bits & kAfeActive) == 0) {
return;
}
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
if ((xEventGroupGetBits(event_group_) & kAfeActive) == 0) {
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 AfeAudioEngine::EnableWakeWordDetection(bool enable) {
if (!HasWakeWord()) {
return;
}
// WakeNet enable/disable on the AFE instance is applied by ProcessingTask
// (see ApplyAfeControls), driven by the kWakeWordEnabled bit.
if (enable) {
if (wake_detector_ == WakeDetector::kMultiNet) {
custom_wake_word_->Start();
}
xEventGroupSetBits(event_group_, kWakeWordEnabled);
} else {
xEventGroupClearBits(event_group_, kWakeWordEnabled);
if (wake_detector_ == WakeDetector::kMultiNet) {
custom_wake_word_->Stop();
}
}
UpdateActiveState();
}
void AfeAudioEngine::EnableVoiceProcessing(bool enable) {
if (enable) {
xEventGroupSetBits(event_group_, kVoiceProcessingEnabled);
} else {
xEventGroupClearBits(event_group_, kVoiceProcessingEnabled);
is_speaking_ = false;
}
UpdateActiveState();
}
void AfeAudioEngine::EnableDeviceAec(bool enable) {
device_aec_enabled_.store(enable);
if (enable && (codec_ == nullptr || !codec_->input_reference())) {
ESP_LOGW(TAG, "Device AEC requires a playback reference channel");
}
UpdateAecState();
}
bool AfeAudioEngine::HasWakeWord() const {
return wake_detector_ != WakeDetector::kNone;
}
bool AfeAudioEngine::IsWakeWordDetectionEnabled() const {
return event_group_ != nullptr && (xEventGroupGetBits(event_group_) & kWakeWordEnabled) != 0;
}
bool AfeAudioEngine::IsVoiceProcessingEnabled() const {
return event_group_ != nullptr && (xEventGroupGetBits(event_group_) & kVoiceProcessingEnabled) != 0;
}
size_t AfeAudioEngine::GetFeedSize() const {
return afe_data_ == nullptr ? frame_samples_ : afe_iface_->get_feed_chunksize(afe_data_);
}
void AfeAudioEngine::OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) {
wake_word_detected_callback_ = std::move(callback);
}
void AfeAudioEngine::OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) {
output_callback_ = std::move(callback);
}
void AfeAudioEngine::OnVadStateChange(std::function<void(bool speaking)> callback) {
vad_state_change_callback_ = std::move(callback);
}
void AfeAudioEngine::UpdateActiveState() {
EventBits_t bits = xEventGroupGetBits(event_group_);
const bool afe_active = afe_data_ != nullptr &&
((bits & kWakeWordEnabled) ||
(kUseAfeForVoiceProcessing && (bits & kVoiceProcessingEnabled)));
if (afe_active) {
xEventGroupSetBits(event_group_, kAfeActive);
} else {
xEventGroupClearBits(event_group_, kAfeActive);
control_generation_.fetch_add(1);
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
input_buffer_.clear();
if (afe_data_ != nullptr) {
// Don't call reset_buffer() here: this runs in the main task while
// ProcessingTask may be inside fetch_with_delay() on the same AFE
// instance, and a concurrent reset corrupts the ring buffer state.
// Defer the reset to ProcessingTask, which owns the fetch side.
reset_pending_ = true;
}
}
if ((bits & kVoiceProcessingEnabled) == 0) {
// output_buffer_ is owned by the task that produces output frames,
// so let that task clear it instead of racing with it here.
output_reset_pending_ = true;
}
afe_control_dirty_ = true;
}
void AfeAudioEngine::UpdateAecState() {
if (afe_data_ == nullptr || codec_ == nullptr || !codec_->input_reference()) {
return;
}
afe_control_dirty_ = true;
}
void AfeAudioEngine::ApplyAfeControls() {
EventBits_t bits = xEventGroupGetBits(event_group_);
if (wake_detector_ == WakeDetector::kWakeNet) {
if (bits & kWakeWordEnabled) {
afe_iface_->enable_wakenet(afe_data_);
} else {
afe_iface_->disable_wakenet(afe_data_);
}
}
if (codec_->input_reference()) {
const bool enable_aec = (bits & kWakeWordEnabled) ||
(device_aec_enabled_.load() && (bits & kVoiceProcessingEnabled));
if (enable_aec) {
afe_iface_->enable_aec(afe_data_);
} else {
afe_iface_->disable_aec(afe_data_);
}
}
}
void AfeAudioEngine::ApplyPendingReset() {
if (!reset_pending_.exchange(false)) {
return;
}
// Discard audio recorded before (re)activation. Holding input_buffer_mutex_
// serializes the reset against Feed(); fetch/reset both run in this task.
std::lock_guard<std::mutex> lock(input_buffer_mutex_);
input_buffer_.clear();
afe_iface_->reset_buffer(afe_data_);
}
void AfeAudioEngine::ProcessingTask() {
while (true) {
xEventGroupWaitBits(event_group_, kAfeActive, pdFALSE, pdTRUE, portMAX_DELAY);
ApplyPendingReset();
if ((xEventGroupGetBits(event_group_) & kAfeActive) == 0) {
continue;
}
if (afe_control_dirty_.exchange(false)) {
// WakeNet/AEC toggles are not safe against a concurrent fetch,
// so they are applied here, in the task that owns the fetch side.
ApplyAfeControls();
}
const uint32_t generation = control_generation_.load();
auto* result = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY);
if (generation != control_generation_.load() ||
(xEventGroupGetBits(event_group_) & kAfeActive) == 0) {
// A disable/re-enable may make an old blocked fetch return after the
// AFE is active again. Reset immediately and never process that frame.
ApplyPendingReset();
continue;
}
if (result == nullptr || result->ret_value == ESP_FAIL) {
if (result != nullptr) {
ESP_LOGW(TAG, "AFE fetch failed: %d", result->ret_value);
}
continue;
}
EventBits_t bits = xEventGroupGetBits(event_group_);
if (bits & kWakeWordEnabled) {
HandleWakeWordResult(result);
}
if (kUseAfeForVoiceProcessing && (bits & kVoiceProcessingEnabled)) {
HandleVoiceResult(result);
}
}
}
void AfeAudioEngine::HandleWakeWordResult(const afe_fetch_result_t* result) {
if (wake_detector_ == WakeDetector::kMultiNet) {
custom_wake_word_->FeedMono(
result->data, result->data_size / sizeof(int16_t));
return;
}
#if CONFIG_SEND_WAKE_WORD_DATA
wake_word_audio_cache_.Store(result->data, result->data_size / sizeof(int16_t));
#endif
if (result->wakeup_state != WAKENET_DETECTED) {
return;
}
int model_index = result->wakenet_model_index - 1;
if (model_index < 0 || model_index >= static_cast<int>(wake_words_.size())) {
ESP_LOGE(TAG, "Invalid WakeNet model index: %d", result->wakenet_model_index);
return;
}
last_detected_wake_word_ = wake_words_[model_index];
xEventGroupClearBits(event_group_, kWakeWordEnabled);
// UpdateActiveState marks the AFE controls dirty; the next loop iteration
// of ProcessingTask disables WakeNet via ApplyAfeControls.
UpdateActiveState();
if (wake_word_detected_callback_) {
wake_word_detected_callback_(last_detected_wake_word_);
}
}
void AfeAudioEngine::HandleVoiceResult(const afe_fetch_result_t* result) {
if (output_reset_pending_.exchange(false)) {
output_buffer_.clear();
}
if (vad_state_change_callback_) {
if (result->vad_state == VAD_SPEECH && !is_speaking_) {
is_speaking_ = true;
vad_state_change_callback_(true);
} else if (result->vad_state == VAD_SILENCE && is_speaking_) {
is_speaking_ = false;
vad_state_change_callback_(false);
}
}
if (!output_callback_) {
return;
}
size_t samples = result->data_size / sizeof(int16_t);
output_buffer_.insert(output_buffer_.end(), result->data, result->data + samples);
while (output_buffer_.size() >= static_cast<size_t>(frame_samples_)) {
if (output_buffer_.size() == static_cast<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_);
}
}
}
void AfeAudioEngine::OutputRawAudio(const std::vector<int16_t>& data) {
if (!output_callback_ || codec_ == nullptr) {
return;
}
if (output_reset_pending_.exchange(false)) {
output_buffer_.clear();
}
const size_t channels = codec_->input_channels();
if (channels <= 1) {
output_buffer_.insert(output_buffer_.end(), data.begin(), data.end());
} else {
for (size_t i = 0; i < data.size(); i += channels) {
output_buffer_.push_back(data[i]);
}
}
while (output_buffer_.size() >= static_cast<size_t>(frame_samples_)) {
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_);
}
}
void AfeAudioEngine::EncodeWakeWordData() {
if (wake_detector_ == WakeDetector::kMultiNet) {
custom_wake_word_->EncodeWakeWordData();
return;
}
if (wake_detector_ != WakeDetector::kWakeNet) {
return;
}
const size_t stack_size = 4096 * 6;
wake_word_opus_.clear();
if (wake_word_encode_task_stack_ == nullptr) {
wake_word_encode_task_stack_ = static_cast<StackType_t*>(
heap_caps_malloc(stack_size, MALLOC_CAP_SPIRAM));
assert(wake_word_encode_task_stack_ != nullptr);
}
if (wake_word_encode_task_buffer_ == nullptr) {
wake_word_encode_task_buffer_ = static_cast<StaticTask_t*>(
heap_caps_malloc(sizeof(StaticTask_t), MALLOC_CAP_INTERNAL));
assert(wake_word_encode_task_buffer_ != nullptr);
}
wake_word_encode_task_ = xTaskCreateStatic([](void* arg) {
auto* engine = static_cast<AfeAudioEngine*>(arg);
auto start_time = esp_timer_get_time();
esp_opus_enc_config_t opus_enc_cfg = AS_OPUS_ENC_CONFIG();
void* encoder_handle = nullptr;
auto ret = esp_opus_enc_open(&opus_enc_cfg, sizeof(esp_opus_enc_config_t), &encoder_handle);
if (encoder_handle == nullptr) {
ESP_LOGE(TAG, "Failed to create wake-word encoder: %d", ret);
engine->wake_word_audio_cache_.Clear();
{
std::lock_guard<std::mutex> lock(engine->wake_word_mutex_);
engine->wake_word_opus_.emplace_back();
engine->wake_word_cv_.notify_all();
}
vTaskDelete(nullptr);
return;
}
int frame_size = 0;
int outbuf_size = 0;
esp_opus_enc_get_frame_size(encoder_handle, &frame_size, &outbuf_size);
frame_size /= sizeof(int16_t);
int packets = 0;
std::vector<int16_t> input(frame_size);
esp_audio_enc_in_frame_t in = {};
esp_audio_enc_out_frame_t out = {};
const size_t cached_samples = engine->wake_word_audio_cache_.Size();
for (size_t offset = 0;
offset + static_cast<size_t>(frame_size) <= cached_samples;
offset += frame_size) {
if (engine->wake_word_audio_cache_.Read(
offset, input.data(), frame_size) != static_cast<size_t>(frame_size)) {
break;
}
std::vector<uint8_t> opus_buf(outbuf_size);
in.buffer = reinterpret_cast<uint8_t*>(input.data());
in.len = frame_size * sizeof(int16_t);
out.buffer = opus_buf.data();
out.len = outbuf_size;
out.encoded_bytes = 0;
ret = esp_opus_enc_process(encoder_handle, &in, &out);
if (ret == ESP_AUDIO_ERR_OK) {
std::lock_guard<std::mutex> lock(engine->wake_word_mutex_);
engine->wake_word_opus_.emplace_back(
opus_buf.data(), opus_buf.data() + out.encoded_bytes);
engine->wake_word_cv_.notify_all();
++packets;
} else {
ESP_LOGE(TAG, "Failed to encode wake-word audio: %d", ret);
}
}
engine->wake_word_audio_cache_.Clear();
esp_opus_enc_close(encoder_handle);
ESP_LOGI(TAG, "Encoded wake word into %d packets in %ld ms", packets,
static_cast<long>((esp_timer_get_time() - start_time) / 1000));
{
std::lock_guard<std::mutex> lock(engine->wake_word_mutex_);
engine->wake_word_opus_.emplace_back();
engine->wake_word_cv_.notify_all();
}
vTaskDelete(nullptr);
}, "encode_wake_word", stack_size, this, 2,
wake_word_encode_task_stack_, wake_word_encode_task_buffer_);
}
bool AfeAudioEngine::GetWakeWordOpus(std::vector<uint8_t>& opus) {
if (wake_detector_ == WakeDetector::kMultiNet) {
return custom_wake_word_->GetWakeWordOpus(opus);
}
if (wake_detector_ != WakeDetector::kWakeNet) {
return false;
}
std::unique_lock<std::mutex> lock(wake_word_mutex_);
wake_word_cv_.wait(lock, [this]() { return !wake_word_opus_.empty(); });
opus.swap(wake_word_opus_.front());
wake_word_opus_.pop_front();
return !opus.empty();
}
+110
View File
@@ -0,0 +1,110 @@
#ifndef AFE_AUDIO_ENGINE_H
#define AFE_AUDIO_ENGINE_H
#include <atomic>
#include <condition_variable>
#include <deque>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include <esp_afe_sr_models.h>
#include <freertos/FreeRTOS.h>
#include <freertos/event_groups.h>
#include <freertos/task.h>
#include "audio_engine.h"
#include "wake_words/wake_word_audio_cache.h"
class CustomWakeWord;
class AfeAudioEngine : public AudioEngine {
public:
AfeAudioEngine();
~AfeAudioEngine() override;
bool Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) override;
void Feed(std::vector<int16_t>&& data) override;
void EnableWakeWordDetection(bool enable) override;
void EnableVoiceProcessing(bool enable) override;
void EnableDeviceAec(bool enable) override;
bool HasWakeWord() const override;
bool IsWakeWordDetectionEnabled() const override;
bool IsVoiceProcessingEnabled() const override;
bool IsAfeWakeWord() const override { return HasWakeWord(); }
size_t GetFeedSize() const override;
void OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) override;
void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) override;
void OnVadStateChange(std::function<void(bool speaking)> callback) override;
void EncodeWakeWordData() override;
bool GetWakeWordOpus(std::vector<uint8_t>& opus) override;
const std::string& GetLastDetectedWakeWord() const override { return last_detected_wake_word_; }
private:
enum class WakeDetector {
kNone,
kWakeNet,
kMultiNet,
};
static constexpr EventBits_t kWakeWordEnabled = 1 << 0;
static constexpr EventBits_t kVoiceProcessingEnabled = 1 << 1;
static constexpr EventBits_t kAfeActive = 1 << 2;
AudioCodec* codec_ = nullptr;
srmodel_list_t* models_ = nullptr;
bool owns_models_ = false;
const esp_afe_sr_iface_t* afe_iface_ = nullptr;
esp_afe_sr_data_t* afe_data_ = nullptr;
EventGroupHandle_t event_group_ = nullptr;
TaskHandle_t processing_task_ = nullptr;
int frame_samples_ = 0;
bool is_speaking_ = false;
std::atomic<bool> device_aec_enabled_{false};
// Deferred AFE buffer reset, performed by ProcessingTask (see UpdateActiveState)
std::atomic<bool> reset_pending_{false};
// Deferred WakeNet/AEC toggles, applied by ProcessingTask (see ApplyAfeControls)
std::atomic<bool> afe_control_dirty_{false};
// Deferred output_buffer_ clear, performed by the output-producing task
std::atomic<bool> output_reset_pending_{false};
// Incremented whenever an active AFE session is invalidated. ProcessingTask
// uses it to reject a fetch result produced before a disable/re-enable cycle.
std::atomic<uint32_t> control_generation_{0};
WakeDetector wake_detector_ = WakeDetector::kNone;
std::unique_ptr<CustomWakeWord> custom_wake_word_;
std::vector<std::string> wake_words_;
std::string last_detected_wake_word_;
std::vector<int16_t> input_buffer_;
std::vector<int16_t> output_buffer_;
std::mutex input_buffer_mutex_;
std::function<void(const std::string&)> wake_word_detected_callback_;
std::function<void(std::vector<int16_t>&&)> output_callback_;
std::function<void(bool)> vad_state_change_callback_;
TaskHandle_t wake_word_encode_task_ = nullptr;
StaticTask_t* wake_word_encode_task_buffer_ = nullptr;
StackType_t* wake_word_encode_task_stack_ = nullptr;
WakeWordAudioCache wake_word_audio_cache_;
std::deque<std::vector<uint8_t>> wake_word_opus_;
std::mutex wake_word_mutex_;
std::condition_variable wake_word_cv_;
void ProcessingTask();
void UpdateActiveState();
void UpdateAecState();
void ApplyAfeControls();
void ApplyPendingReset();
void OutputRawAudio(const std::vector<int16_t>& data);
void HandleWakeWordResult(const afe_fetch_result_t* result);
void HandleVoiceResult(const afe_fetch_result_t* result);
};
#endif
+150
View File
@@ -0,0 +1,150 @@
#include "lite_audio_engine.h"
#include <esp_log.h>
#include <esp_wn_models.h>
#include "wake_words/esp_wake_word.h"
#define TAG "LiteAudioEngine"
LiteAudioEngine::LiteAudioEngine() = default;
LiteAudioEngine::~LiteAudioEngine() = default;
bool LiteAudioEngine::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_);
bool has_wakenet = models_list != nullptr &&
esp_srmodel_filter(models_list, ESP_WN_PREFIX, nullptr) != nullptr;
#if CONFIG_USE_ESP_WAKE_WORD
has_wakenet = has_wakenet || models_list == nullptr;
#endif
if (has_wakenet) {
wake_word_ = std::make_unique<EspWakeWord>();
wake_word_->OnWakeWordDetected([this](const std::string& wake_word) {
wake_word_enabled_ = false;
if (wake_word_detected_callback_) {
wake_word_detected_callback_(wake_word);
}
});
if (!wake_word_->Initialize(codec_, models_list)) {
ESP_LOGE(TAG, "Failed to initialize standalone WakeNet");
wake_word_.reset();
return false;
}
}
ESP_LOGI(TAG, "Initialized, WakeNet: %s", wake_word_ ? "yes" : "no");
return true;
}
void LiteAudioEngine::Feed(std::vector<int16_t>&& data) {
if (wake_word_enabled_ && wake_word_) {
wake_word_->Feed(data);
}
if (voice_processing_enabled_) {
OutputRawAudio(data);
}
}
void LiteAudioEngine::EnableWakeWordDetection(bool enable) {
if (!wake_word_) {
wake_word_enabled_ = false;
return;
}
wake_word_enabled_ = enable;
if (enable) {
wake_word_->Start();
} else {
wake_word_->Stop();
}
}
void LiteAudioEngine::EnableVoiceProcessing(bool enable) {
voice_processing_enabled_ = enable;
if (!enable) {
std::lock_guard<std::mutex> lock(output_mutex_);
output_buffer_.clear();
}
}
void LiteAudioEngine::EnableDeviceAec(bool enable) {
if (enable) {
ESP_LOGW(TAG, "Device AEC is not supported by the lite engine");
}
}
bool LiteAudioEngine::HasWakeWord() const {
return wake_word_ != nullptr;
}
bool LiteAudioEngine::IsWakeWordDetectionEnabled() const {
return wake_word_enabled_;
}
bool LiteAudioEngine::IsVoiceProcessingEnabled() const {
return voice_processing_enabled_;
}
size_t LiteAudioEngine::GetFeedSize() const {
if (wake_word_) {
return wake_word_->GetFeedSize();
}
return frame_samples_;
}
void LiteAudioEngine::OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) {
wake_word_detected_callback_ = std::move(callback);
}
void LiteAudioEngine::OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) {
output_callback_ = std::move(callback);
}
void LiteAudioEngine::OnVadStateChange(std::function<void(bool speaking)> callback) {
vad_state_change_callback_ = std::move(callback);
}
void LiteAudioEngine::EncodeWakeWordData() {
if (wake_word_) {
wake_word_->EncodeWakeWordData();
}
}
bool LiteAudioEngine::GetWakeWordOpus(std::vector<uint8_t>& opus) {
return wake_word_ && wake_word_->GetWakeWordOpus(opus);
}
const std::string& LiteAudioEngine::GetLastDetectedWakeWord() const {
return wake_word_ ? wake_word_->GetLastDetectedWakeWord() : empty_wake_word_;
}
void LiteAudioEngine::OutputRawAudio(const std::vector<int16_t>& data) {
if (!output_callback_ || codec_ == nullptr) {
return;
}
std::lock_guard<std::mutex> lock(output_mutex_);
const size_t channels = codec_->input_channels();
if (channels <= 1) {
output_buffer_.insert(output_buffer_.end(), data.begin(), data.end());
} else {
for (size_t i = 0; i < data.size(); i += channels) {
output_buffer_.push_back(data[i]);
}
}
while (output_buffer_.size() >= static_cast<size_t>(frame_samples_)) {
if (output_buffer_.size() == static_cast<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_);
}
}
}
+58
View File
@@ -0,0 +1,58 @@
#ifndef LITE_AUDIO_ENGINE_H
#define LITE_AUDIO_ENGINE_H
#include <atomic>
#include <functional>
#include <memory>
#include <mutex>
#include <string>
#include <vector>
#include "audio_engine.h"
class EspWakeWord;
class LiteAudioEngine : public AudioEngine {
public:
LiteAudioEngine();
~LiteAudioEngine() override;
bool Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) override;
void Feed(std::vector<int16_t>&& data) override;
void EnableWakeWordDetection(bool enable) override;
void EnableVoiceProcessing(bool enable) override;
void EnableDeviceAec(bool enable) override;
bool HasWakeWord() const override;
bool IsWakeWordDetectionEnabled() const override;
bool IsVoiceProcessingEnabled() const override;
bool IsAfeWakeWord() const override { return false; }
size_t GetFeedSize() const override;
void OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) override;
void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) override;
void OnVadStateChange(std::function<void(bool speaking)> callback) override;
void EncodeWakeWordData() override;
bool GetWakeWordOpus(std::vector<uint8_t>& opus) override;
const std::string& GetLastDetectedWakeWord() const override;
private:
AudioCodec* codec_ = nullptr;
std::unique_ptr<EspWakeWord> wake_word_;
std::atomic<bool> wake_word_enabled_ = false;
std::atomic<bool> voice_processing_enabled_ = false;
int frame_samples_ = 0;
std::vector<int16_t> output_buffer_;
std::mutex output_mutex_;
std::function<void(const std::string&)> wake_word_detected_callback_;
std::function<void(std::vector<int16_t>&&)> output_callback_;
std::function<void(bool)> vad_state_change_callback_;
std::string empty_wake_word_;
void OutputRawAudio(const std::vector<int16_t>& data);
};
#endif
@@ -1,201 +0,0 @@
#include "afe_audio_processor.h"
#include <esp_log.h>
#define PROCESSOR_RUNNING 0x01
#define TAG "AfeAudioProcessor"
AfeAudioProcessor::AfeAudioProcessor()
: afe_data_(nullptr) {
event_group_ = xEventGroupCreate();
}
void AfeAudioProcessor::Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) {
codec_ = codec;
frame_samples_ = frame_duration_ms * 16000 / 1000;
// Pre-allocate output buffer capacity
output_buffer_.reserve(frame_samples_);
int ref_num = codec_->input_reference() ? 1 : 0;
std::string input_format;
for (int i = 0; i < codec_->input_channels() - ref_num; i++) {
input_format.push_back('M');
}
for (int i = 0; i < ref_num; i++) {
input_format.push_back('R');
}
srmodel_list_t *models;
if (models_list == nullptr) {
models = esp_srmodel_init("model");
} else {
models = models_list;
}
char* ns_model_name = esp_srmodel_filter(models, ESP_NSNET_PREFIX, NULL);
char* vad_model_name = esp_srmodel_filter(models, ESP_VADN_PREFIX, NULL);
afe_config_t* afe_config = afe_config_init(input_format.c_str(), NULL, AFE_TYPE_VC, AFE_MODE_HIGH_PERF);
afe_config->aec_mode = AEC_MODE_VOIP_HIGH_PERF;
afe_config->vad_mode = VAD_MODE_0;
afe_config->vad_min_noise_ms = 100;
if (vad_model_name != nullptr) {
afe_config->vad_model_name = vad_model_name;
}
if (ns_model_name != nullptr) {
afe_config->ns_init = true;
afe_config->ns_model_name = ns_model_name;
afe_config->afe_ns_mode = AFE_NS_MODE_NET;
} else {
afe_config->ns_init = false;
}
afe_config->agc_init = false;
afe_config->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM;
#ifdef CONFIG_USE_DEVICE_AEC
afe_config->aec_init = true;
afe_config->vad_init = false;
#else
afe_config->aec_init = false;
afe_config->vad_init = true;
#endif
afe_iface_ = esp_afe_handle_from_config(afe_config);
afe_data_ = afe_iface_->create_from_config(afe_config);
xTaskCreate([](void* arg) {
auto this_ = (AfeAudioProcessor*)arg;
this_->AudioProcessorTask();
vTaskDelete(NULL);
}, "audio_communication", 4096, this, 3, NULL);
}
AfeAudioProcessor::~AfeAudioProcessor() {
if (afe_data_ != nullptr) {
afe_iface_->destroy(afe_data_);
}
vEventGroupDelete(event_group_);
}
size_t AfeAudioProcessor::GetFeedSize() {
if (afe_data_ == nullptr) {
return 0;
}
return afe_iface_->get_feed_chunksize(afe_data_);
}
void AfeAudioProcessor::Feed(std::vector<int16_t>&& data) {
if (afe_data_ == nullptr) {
return;
}
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() {
xEventGroupSetBits(event_group_, PROCESSOR_RUNNING);
}
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() {
return xEventGroupGetBits(event_group_) & PROCESSOR_RUNNING;
}
void AfeAudioProcessor::OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) {
output_callback_ = callback;
}
void AfeAudioProcessor::OnVadStateChange(std::function<void(bool speaking)> callback) {
vad_state_change_callback_ = callback;
}
void AfeAudioProcessor::AudioProcessorTask() {
auto fetch_size = afe_iface_->get_fetch_chunksize(afe_data_);
auto feed_size = afe_iface_->get_feed_chunksize(afe_data_);
ESP_LOGI(TAG, "Audio communication task started, feed size: %d fetch size: %d",
feed_size, fetch_size);
while (true) {
xEventGroupWaitBits(event_group_, PROCESSOR_RUNNING, pdFALSE, pdTRUE, portMAX_DELAY);
auto res = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY);
if ((xEventGroupGetBits(event_group_) & PROCESSOR_RUNNING) == 0) {
continue;
}
if (res == nullptr || res->ret_value == ESP_FAIL) {
if (res != nullptr) {
ESP_LOGI(TAG, "Error code: %d", res->ret_value);
}
continue;
}
// VAD state change
if (vad_state_change_callback_) {
if (res->vad_state == VAD_SPEECH && !is_speaking_) {
is_speaking_ = true;
vad_state_change_callback_(true);
} else if (res->vad_state == VAD_SILENCE && is_speaking_) {
is_speaking_ = false;
vad_state_change_callback_(false);
}
}
if (output_callback_) {
size_t samples = res->data_size / sizeof(int16_t);
// Add data to buffer
output_buffer_.insert(output_buffer_.end(), res->data, res->data + samples);
// Output complete frames when buffer has enough data
while (output_buffer_.size() >= frame_samples_) {
if (output_buffer_.size() == frame_samples_) {
// If buffer size equals frame size, move the entire buffer
output_callback_(std::move(output_buffer_));
output_buffer_.clear();
output_buffer_.reserve(frame_samples_);
} else {
// If buffer size exceeds frame size, copy one frame and remove it
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_);
}
}
}
}
}
void AfeAudioProcessor::EnableDeviceAec(bool enable) {
if (enable) {
#if CONFIG_USE_DEVICE_AEC
afe_iface_->disable_vad(afe_data_);
afe_iface_->enable_aec(afe_data_);
#else
ESP_LOGE(TAG, "Device AEC is not supported");
#endif
} else {
afe_iface_->disable_aec(afe_data_);
afe_iface_->enable_vad(afe_data_);
}
}
@@ -1,48 +0,0 @@
#ifndef AFE_AUDIO_PROCESSOR_H
#define AFE_AUDIO_PROCESSOR_H
#include <esp_afe_sr_models.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/event_groups.h>
#include <string>
#include <vector>
#include <functional>
#include <mutex>
#include "audio_processor.h"
#include "audio_codec.h"
class AfeAudioProcessor : public AudioProcessor {
public:
AfeAudioProcessor();
~AfeAudioProcessor();
void Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) override;
void Feed(std::vector<int16_t>&& data) override;
void Start() override;
void Stop() override;
bool IsRunning() override;
void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) override;
void OnVadStateChange(std::function<void(bool speaking)> callback) override;
size_t GetFeedSize() override;
void EnableDeviceAec(bool enable) override;
private:
EventGroupHandle_t event_group_ = nullptr;
const esp_afe_sr_iface_t* afe_iface_ = nullptr;
esp_afe_sr_data_t* afe_data_ = nullptr;
std::function<void(std::vector<int16_t>&& data)> output_callback_;
std::function<void(bool speaking)> vad_state_change_callback_;
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();
};
#endif
@@ -1,71 +0,0 @@
#include "no_audio_processor.h"
#include <esp_log.h>
#define TAG "NoAudioProcessor"
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) {
if (!is_running_ || !output_callback_) {
return;
}
// Convert stereo to mono if needed
if (codec_->input_channels() == 2) {
for (size_t i = 0, j = 0; i < data.size() / 2; ++i, j += 2) {
output_buffer_.push_back(data[j]);
}
} else {
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_);
}
}
}
void NoAudioProcessor::Start() {
is_running_ = true;
}
void NoAudioProcessor::Stop() {
is_running_ = false;
output_buffer_.clear();
}
bool NoAudioProcessor::IsRunning() {
return is_running_;
}
void NoAudioProcessor::OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) {
output_callback_ = callback;
}
void NoAudioProcessor::OnVadStateChange(std::function<void(bool speaking)> callback) {
vad_state_change_callback_ = callback;
}
size_t NoAudioProcessor::GetFeedSize() {
if (!codec_) {
return 0;
}
return frame_samples_;
}
void NoAudioProcessor::EnableDeviceAec(bool enable) {
if (enable) {
ESP_LOGE(TAG, "Device AEC is not supported");
}
}
@@ -1,35 +0,0 @@
#ifndef DUMMY_AUDIO_PROCESSOR_H
#define DUMMY_AUDIO_PROCESSOR_H
#include <vector>
#include <functional>
#include <atomic>
#include "audio_processor.h"
#include "audio_codec.h"
class NoAudioProcessor : public AudioProcessor {
public:
NoAudioProcessor() = default;
~NoAudioProcessor() = default;
void Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) override;
void Feed(std::vector<int16_t>&& data) override;
void Start() override;
void Stop() override;
bool IsRunning() override;
void OnOutput(std::function<void(std::vector<int16_t>&& data)> callback) override;
void OnVadStateChange(std::function<void(bool speaking)> callback) override;
size_t GetFeedSize() override;
void EnableDeviceAec(bool enable) override;
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_;
std::atomic<bool> is_running_ = false;
};
#endif
-263
View File
@@ -1,263 +0,0 @@
#include "afe_wake_word.h"
#include "audio_service.h"
#include <esp_log.h>
#include <sstream>
#define DETECTION_RUNNING_EVENT 1
#define TAG "AfeWakeWord"
AfeWakeWord::AfeWakeWord()
: afe_data_(nullptr),
wake_word_pcm_(),
wake_word_opus_() {
event_group_ = xEventGroupCreate();
}
AfeWakeWord::~AfeWakeWord() {
if (afe_data_ != nullptr) {
afe_iface_->destroy(afe_data_);
}
if (wake_word_encode_task_stack_ != nullptr) {
heap_caps_free(wake_word_encode_task_stack_);
}
if (wake_word_encode_task_buffer_ != nullptr) {
heap_caps_free(wake_word_encode_task_buffer_);
}
if (models_ != nullptr) {
esp_srmodel_deinit(models_);
}
vEventGroupDelete(event_group_);
}
bool AfeWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list) {
codec_ = codec;
int ref_num = codec_->input_reference() ? 1 : 0;
if (models_list == nullptr) {
models_ = esp_srmodel_init("model");
} else {
models_ = models_list;
}
if (models_ == nullptr || models_->num == -1) {
ESP_LOGE(TAG, "Failed to initialize wakenet model");
return false;
}
for (int i = 0; i < models_->num; i++) {
ESP_LOGI(TAG, "Model %d: %s", i, models_->model_name[i]);
if (strstr(models_->model_name[i], ESP_WN_PREFIX) != NULL) {
wakenet_model_ = models_->model_name[i];
auto words = esp_srmodel_get_wake_words(models_, wakenet_model_);
// split by ";" to get all wake words
std::stringstream ss(words);
std::string word;
while (std::getline(ss, word, ';')) {
wake_words_.push_back(word);
}
}
}
std::string input_format;
for (int i = 0; i < codec_->input_channels() - ref_num; i++) {
input_format.push_back('M');
}
for (int i = 0; i < ref_num; i++) {
input_format.push_back('R');
}
afe_config_t* afe_config = afe_config_init(input_format.c_str(), models_, AFE_TYPE_SR, AFE_MODE_HIGH_PERF);
afe_config->aec_init = codec_->input_reference();
afe_config->aec_mode = AEC_MODE_SR_HIGH_PERF;
afe_config->afe_perferred_core = 1;
afe_config->afe_perferred_priority = 1;
afe_config->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM;
afe_iface_ = esp_afe_handle_from_config(afe_config);
afe_data_ = afe_iface_->create_from_config(afe_config);
xTaskCreate([](void* arg) {
auto this_ = (AfeWakeWord*)arg;
this_->AudioDetectionTask();
vTaskDelete(NULL);
}, "audio_detection", 4096, this, 3, nullptr);
return true;
}
void AfeWakeWord::OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) {
wake_word_detected_callback_ = callback;
}
void AfeWakeWord::Start() {
xEventGroupSetBits(event_group_, DETECTION_RUNNING_EVENT);
}
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;
}
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() {
if (afe_data_ == nullptr) {
return 0;
}
return afe_iface_->get_feed_chunksize(afe_data_);
}
void AfeWakeWord::AudioDetectionTask() {
auto fetch_size = afe_iface_->get_fetch_chunksize(afe_data_);
auto feed_size = afe_iface_->get_feed_chunksize(afe_data_);
ESP_LOGI(TAG, "Audio detection task started, feed size: %d fetch size: %d",
feed_size, fetch_size);
while (true) {
xEventGroupWaitBits(event_group_, DETECTION_RUNNING_EVENT, pdFALSE, pdTRUE, portMAX_DELAY);
auto res = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY);
if (res == nullptr || res->ret_value == ESP_FAIL) {
continue;;
}
// Store the wake word data for voice recognition, like who is speaking
StoreWakeWordData(res->data, res->data_size / sizeof(int16_t));
if (res->wakeup_state == WAKENET_DETECTED) {
Stop();
last_detected_wake_word_ = wake_words_[res->wakenet_model_index - 1];
if (wake_word_detected_callback_) {
wake_word_detected_callback_(last_detected_wake_word_);
}
}
}
}
void AfeWakeWord::StoreWakeWordData(const int16_t* data, size_t samples) {
// store audio data to wake_word_pcm_
wake_word_pcm_.emplace_back(std::vector<int16_t>(data, data + samples));
// keep about 2 seconds of data, detect duration is 30ms (sample_rate == 16000, chunksize == 512)
while (wake_word_pcm_.size() > 2000 / 30) {
wake_word_pcm_.pop_front();
}
}
void AfeWakeWord::EncodeWakeWordData() {
const size_t stack_size = 4096 * 6;
wake_word_opus_.clear();
if (wake_word_encode_task_stack_ == nullptr) {
wake_word_encode_task_stack_ = (StackType_t*)heap_caps_malloc(stack_size, MALLOC_CAP_SPIRAM);
assert(wake_word_encode_task_stack_ != nullptr);
}
if (wake_word_encode_task_buffer_ == nullptr) {
wake_word_encode_task_buffer_ = (StaticTask_t*)heap_caps_malloc(sizeof(StaticTask_t), MALLOC_CAP_INTERNAL);
assert(wake_word_encode_task_buffer_ != nullptr);
}
wake_word_encode_task_ = xTaskCreateStatic([](void* arg) {
auto this_ = (AfeWakeWord*)arg;
{
auto start_time = esp_timer_get_time();
// Create encoder
esp_opus_enc_config_t opus_enc_cfg = AS_OPUS_ENC_CONFIG();
void* encoder_handle = nullptr;
auto ret = esp_opus_enc_open(&opus_enc_cfg, sizeof(esp_opus_enc_config_t), &encoder_handle);
if (encoder_handle == nullptr) {
ESP_LOGE(TAG, "Failed to create audio encoder, error code: %d", ret);
std::lock_guard<std::mutex> lock(this_->wake_word_mutex_);
this_->wake_word_opus_.push_back(std::vector<uint8_t>());
this_->wake_word_cv_.notify_all();
return;
}
// Get frame size
int frame_size = 0;
int outbuf_size = 0;
esp_opus_enc_get_frame_size(encoder_handle, &frame_size, &outbuf_size);
frame_size = frame_size / sizeof(int16_t);
// Encode all PCM data
int packets = 0;
std::vector<int16_t> in_buffer;
esp_audio_enc_in_frame_t in = {};
esp_audio_enc_out_frame_t out = {};
for (auto& pcm: this_->wake_word_pcm_) {
if (in_buffer.empty()) {
in_buffer = std::move(pcm);
} else {
in_buffer.reserve(in_buffer.size() + pcm.size());
in_buffer.insert(in_buffer.end(), pcm.begin(), pcm.end());
}
while (in_buffer.size() >= frame_size) {
std::vector<uint8_t> opus_buf(outbuf_size);
in.buffer = (uint8_t *)(in_buffer.data());
in.len = (uint32_t)(frame_size * sizeof(int16_t));
out.buffer = opus_buf.data();
out.len = outbuf_size;
out.encoded_bytes = 0;
ret = esp_opus_enc_process(encoder_handle, &in, &out);
if (ret == ESP_AUDIO_ERR_OK) {
std::lock_guard<std::mutex> lock(this_->wake_word_mutex_);
this_->wake_word_opus_.emplace_back(opus_buf.data(), opus_buf.data() + out.encoded_bytes);
this_->wake_word_cv_.notify_all();
packets++;
} else {
ESP_LOGE(TAG, "Failed to encode audio, error code: %d", ret);
}
in_buffer.erase(in_buffer.begin(), in_buffer.begin() + frame_size);
}
}
this_->wake_word_pcm_.clear();
// Close encoder
esp_opus_enc_close(encoder_handle);
auto end_time = esp_timer_get_time();
ESP_LOGI(TAG, "Encode wake word opus %d packets in %ld ms", packets, (long)((end_time - start_time) / 1000));
std::lock_guard<std::mutex> lock(this_->wake_word_mutex_);
this_->wake_word_opus_.push_back(std::vector<uint8_t>());
this_->wake_word_cv_.notify_all();
}
vTaskDelete(NULL);
}, "encode_wake_word", stack_size, this, 2, wake_word_encode_task_stack_, wake_word_encode_task_buffer_);
}
bool AfeWakeWord::GetWakeWordOpus(std::vector<uint8_t>& opus) {
std::unique_lock<std::mutex> lock(wake_word_mutex_);
wake_word_cv_.wait(lock, [this]() {
return !wake_word_opus_.empty();
});
opus.swap(wake_word_opus_.front());
wake_word_opus_.pop_front();
return !opus.empty();
}
-62
View File
@@ -1,62 +0,0 @@
#ifndef AFE_WAKE_WORD_H
#define AFE_WAKE_WORD_H
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <freertos/event_groups.h>
#include <esp_afe_sr_models.h>
#include <esp_nsn_models.h>
#include <model_path.h>
#include <deque>
#include <string>
#include <vector>
#include <functional>
#include <mutex>
#include <condition_variable>
#include "audio_codec.h"
#include "wake_word.h"
class AfeWakeWord : public WakeWord {
public:
AfeWakeWord();
~AfeWakeWord();
bool Initialize(AudioCodec* codec, srmodel_list_t* models_list);
void Feed(const std::vector<int16_t>& data);
void OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback);
void Start();
void Stop();
size_t GetFeedSize();
void EncodeWakeWordData();
bool GetWakeWordOpus(std::vector<uint8_t>& opus);
const std::string& GetLastDetectedWakeWord() const { return last_detected_wake_word_; }
private:
srmodel_list_t *models_ = nullptr;
const esp_afe_sr_iface_t* afe_iface_ = nullptr;
esp_afe_sr_data_t* afe_data_ = nullptr;
char* wakenet_model_ = NULL;
std::vector<std::string> wake_words_;
EventGroupHandle_t event_group_;
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;
StackType_t* wake_word_encode_task_stack_ = nullptr;
std::deque<std::vector<int16_t>> wake_word_pcm_;
std::deque<std::vector<uint8_t>> wake_word_opus_;
std::mutex wake_word_mutex_;
std::condition_variable wake_word_cv_;
void StoreWakeWordData(const int16_t* data, size_t size);
void AudioDetectionTask();
};
#endif
+51 -44
View File
@@ -12,7 +12,7 @@
#define TAG "CustomWakeWord"
CustomWakeWord::CustomWakeWord()
: wake_word_pcm_(), wake_word_opus_() {
: wake_word_opus_() {
}
CustomWakeWord::~CustomWakeWord() {
@@ -29,7 +29,7 @@ CustomWakeWord::~CustomWakeWord() {
heap_caps_free(wake_word_encode_task_buffer_);
}
if (models_ != nullptr) {
if (owns_models_ && models_ != nullptr) {
esp_srmodel_deinit(models_);
}
}
@@ -89,6 +89,7 @@ bool CustomWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list)
if (models_list == nullptr) {
language_ = "cn";
models_ = esp_srmodel_init("model");
owns_models_ = models_ != nullptr;
#ifdef CONFIG_CUSTOM_WAKE_WORD
threshold_ = CONFIG_CUSTOM_WAKE_WORD_THRESHOLD / 100.0f;
commands_.push_back({CONFIG_CUSTOM_WAKE_WORD, CONFIG_CUSTOM_WAKE_WORD_DISPLAY, "wake"});
@@ -118,6 +119,7 @@ bool CustomWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list)
multinet_ = esp_mn_handle_from_name(mn_name_);
multinet_model_data_ = multinet_->create(mn_name_, duration_);
multinet_->set_det_threshold(multinet_model_data_, threshold_);
input_buffer_.reserve(multinet_->get_samp_chunksize(multinet_model_data_));
esp_mn_commands_clear();
for (int i = 0; i < commands_.size(); i++) {
esp_mn_commands_add(i + 1, commands_[i].command.c_str());
@@ -125,6 +127,11 @@ bool CustomWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list)
esp_mn_commands_update();
multinet_->print_active_speech_commands(multinet_model_data_);
#if CONFIG_SEND_WAKE_WORD_DATA
if (!wake_word_audio_cache_.Initialize(16000 * 2)) {
ESP_LOGW(TAG, "Wake-word audio upload disabled: PSRAM cache allocation failed");
}
#endif
return true;
}
@@ -144,7 +151,15 @@ void CustomWakeWord::Stop() {
}
void CustomWakeWord::Feed(const std::vector<int16_t>& data) {
if (multinet_model_data_ == nullptr) {
FeedSamples(data.data(), data.size(), false);
}
void CustomWakeWord::FeedMono(const int16_t* data, size_t samples) {
FeedSamples(data, samples, true);
}
void CustomWakeWord::FeedSamples(const int16_t* data, size_t samples, bool mono) {
if (multinet_model_data_ == nullptr || data == nullptr || samples == 0) {
return;
}
@@ -155,20 +170,21 @@ void CustomWakeWord::Feed(const std::vector<int16_t>& data) {
}
// If input channels is 2, we need to fetch the left channel data
if (codec_->input_channels() == 2) {
for (size_t i = 0; i < data.size(); i += 2) {
if (!mono && codec_->input_channels() > 1) {
for (size_t i = 0; i < samples; i += codec_->input_channels()) {
input_buffer_.push_back(data[i]);
}
} else {
input_buffer_.insert(input_buffer_.end(), data.begin(), data.end());
input_buffer_.insert(input_buffer_.end(), data, data + samples);
}
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 CONFIG_SEND_WAKE_WORD_DATA
wake_word_audio_cache_.Store(input_buffer_.data(), chunksize);
#endif
esp_mn_state_t mn_state = multinet_->detect(multinet_model_data_, input_buffer_.data());
if (mn_state == ESP_MN_STATE_DETECTED) {
esp_mn_results_t *mn_result = multinet_->get_results(multinet_model_data_);
@@ -206,15 +222,6 @@ size_t CustomWakeWord::GetFeedSize() {
return multinet_->get_samp_chunksize(multinet_model_data_);
}
void CustomWakeWord::StoreWakeWordData(const std::vector<int16_t>& data) {
// store audio data to wake_word_pcm_
wake_word_pcm_.push_back(data);
// keep about 2 seconds of data, detect duration is 30ms (sample_rate == 16000, chunksize == 512)
while (wake_word_pcm_.size() > 2000 / 30) {
wake_word_pcm_.pop_front();
}
}
void CustomWakeWord::EncodeWakeWordData() {
const size_t stack_size = 4096 * 7;
wake_word_opus_.clear();
@@ -237,9 +244,11 @@ void CustomWakeWord::EncodeWakeWordData() {
auto ret = esp_opus_enc_open(&opus_enc_cfg, sizeof(esp_opus_enc_config_t), &encoder_handle);
if (encoder_handle == nullptr) {
ESP_LOGE(TAG, "Failed to create audio encoder, error code: %d", ret);
this_->wake_word_audio_cache_.Clear();
std::lock_guard<std::mutex> lock(this_->wake_word_mutex_);
this_->wake_word_opus_.push_back(std::vector<uint8_t>());
this_->wake_word_cv_.notify_all();
vTaskDelete(nullptr);
return;
}
// Get frame size
@@ -249,36 +258,34 @@ void CustomWakeWord::EncodeWakeWordData() {
frame_size = frame_size / sizeof(int16_t);
// Encode all PCM data
int packets = 0;
std::vector<int16_t> in_buffer;
std::vector<int16_t> in_buffer(frame_size);
esp_audio_enc_in_frame_t in = {};
esp_audio_enc_out_frame_t out = {};
for (auto& pcm: this_->wake_word_pcm_) {
if (in_buffer.empty()) {
in_buffer = std::move(pcm);
} else {
in_buffer.reserve(in_buffer.size() + pcm.size());
in_buffer.insert(in_buffer.end(), pcm.begin(), pcm.end());
const size_t cached_samples = this_->wake_word_audio_cache_.Size();
for (size_t offset = 0;
offset + static_cast<size_t>(frame_size) <= cached_samples;
offset += frame_size) {
if (this_->wake_word_audio_cache_.Read(
offset, in_buffer.data(), frame_size) != static_cast<size_t>(frame_size)) {
break;
}
while (in_buffer.size() >= frame_size) {
std::vector<uint8_t> opus_buf(outbuf_size);
in.buffer = (uint8_t *)(in_buffer.data());
in.len = (uint32_t)(frame_size * sizeof(int16_t));
out.buffer = opus_buf.data();
out.len = outbuf_size;
out.encoded_bytes = 0;
ret = esp_opus_enc_process(encoder_handle, &in, &out);
if (ret == ESP_AUDIO_ERR_OK) {
std::lock_guard<std::mutex> lock(this_->wake_word_mutex_);
this_->wake_word_opus_.emplace_back(opus_buf.data(), opus_buf.data() + out.encoded_bytes);
this_->wake_word_cv_.notify_all();
packets++;
} else {
ESP_LOGE(TAG, "Failed to encode audio, error code: %d", ret);
}
in_buffer.erase(in_buffer.begin(), in_buffer.begin() + frame_size);
std::vector<uint8_t> opus_buf(outbuf_size);
in.buffer = reinterpret_cast<uint8_t*>(in_buffer.data());
in.len = frame_size * sizeof(int16_t);
out.buffer = opus_buf.data();
out.len = outbuf_size;
out.encoded_bytes = 0;
ret = esp_opus_enc_process(encoder_handle, &in, &out);
if (ret == ESP_AUDIO_ERR_OK) {
std::lock_guard<std::mutex> lock(this_->wake_word_mutex_);
this_->wake_word_opus_.emplace_back(opus_buf.data(), opus_buf.data() + out.encoded_bytes);
this_->wake_word_cv_.notify_all();
packets++;
} else {
ESP_LOGE(TAG, "Failed to encode audio, error code: %d", ret);
}
}
this_->wake_word_pcm_.clear();
this_->wake_word_audio_cache_.Clear();
// Close encoder
esp_opus_enc_close(encoder_handle);
auto end_time = esp_timer_get_time();
+7 -2
View File
@@ -2,6 +2,8 @@
#define CUSTOM_WAKE_WORD_H
#include <esp_attr.h>
#include <freertos/FreeRTOS.h>
#include <freertos/task.h>
#include <esp_mn_iface.h>
#include <esp_mn_models.h>
#include <model_path.h>
@@ -16,6 +18,7 @@
#include "audio_codec.h"
#include "wake_word.h"
#include "wake_word_audio_cache.h"
class CustomWakeWord : public WakeWord {
public:
@@ -24,6 +27,7 @@ public:
bool Initialize(AudioCodec* codec, srmodel_list_t* models_list);
void Feed(const std::vector<int16_t>& data);
void FeedMono(const int16_t* data, size_t samples);
void OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback);
void Start();
void Stop();
@@ -43,6 +47,7 @@ private:
esp_mn_iface_t* multinet_ = nullptr;
model_iface_data_t* multinet_model_data_ = nullptr;
srmodel_list_t *models_ = nullptr;
bool owns_models_ = false;
char* mn_name_ = nullptr;
std::string language_ = "cn";
int duration_ = 3000;
@@ -59,12 +64,12 @@ private:
TaskHandle_t wake_word_encode_task_ = nullptr;
StaticTask_t* wake_word_encode_task_buffer_ = nullptr;
StackType_t* wake_word_encode_task_stack_ = nullptr;
std::deque<std::vector<int16_t>> wake_word_pcm_;
WakeWordAudioCache wake_word_audio_cache_;
std::deque<std::vector<uint8_t>> wake_word_opus_;
std::mutex wake_word_mutex_;
std::condition_variable wake_word_cv_;
void StoreWakeWordData(const std::vector<int16_t>& data);
void FeedSamples(const int16_t* data, size_t samples, bool mono);
void ParseWakenetModelConfig();
};
+19 -6
View File
@@ -10,6 +10,8 @@ EspWakeWord::EspWakeWord() {
EspWakeWord::~EspWakeWord() {
if (wakenet_data_ != nullptr) {
wakenet_iface_->destroy(wakenet_data_);
}
if (owns_models_ && wakenet_model_ != nullptr) {
esp_srmodel_deinit(wakenet_model_);
}
}
@@ -19,6 +21,7 @@ bool EspWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list) {
if (models_list == nullptr) {
wakenet_model_ = esp_srmodel_init("model");
owns_models_ = wakenet_model_ != nullptr;
} else {
wakenet_model_ = models_list;
}
@@ -27,15 +30,25 @@ bool EspWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list) {
ESP_LOGE(TAG, "Failed to initialize wakenet model");
return false;
}
if(wakenet_model_->num > 1) {
ESP_LOGW(TAG, "More than one model found, using the first one");
} else if (wakenet_model_->num == 0) {
if (wakenet_model_->num == 0) {
ESP_LOGE(TAG, "No model found");
return false;
}
char *model_name = wakenet_model_->model_name[0];
char *model_name = esp_srmodel_filter(wakenet_model_, ESP_WN_PREFIX, nullptr);
if (model_name == nullptr) {
ESP_LOGE(TAG, "No WakeNet model found");
return false;
}
wakenet_iface_ = (esp_wn_iface_t*)esp_wn_handle_from_name(model_name);
if (wakenet_iface_ == nullptr) {
ESP_LOGE(TAG, "No WakeNet interface found for %s", model_name);
return false;
}
wakenet_data_ = wakenet_iface_->create(model_name, DET_MODE_95);
if (wakenet_data_ == nullptr) {
ESP_LOGE(TAG, "Failed to create WakeNet model %s", model_name);
return false;
}
int frequency = wakenet_iface_->get_samp_rate(wakenet_data_);
int audio_chunksize = wakenet_iface_->get_samp_chunksize(wakenet_data_);
@@ -70,8 +83,8 @@ void EspWakeWord::Feed(const std::vector<int16_t>& data) {
return;
}
if (codec_->input_channels() == 2) {
for (size_t i = 0; i < data.size(); i += 2) {
if (codec_->input_channels() > 1) {
for (size_t i = 0; i < data.size(); i += codec_->input_channels()) {
input_buffer_.push_back(data[i]);
}
} else {
+1
View File
@@ -33,6 +33,7 @@ private:
esp_wn_iface_t *wakenet_iface_ = nullptr;
model_iface_data_t *wakenet_data_ = nullptr;
srmodel_list_t *wakenet_model_ = nullptr;
bool owns_models_ = false;
AudioCodec* codec_ = nullptr;
std::atomic<bool> running_ = false;
@@ -0,0 +1,98 @@
#include "wake_word_audio_cache.h"
#include <algorithm>
#include <cstring>
#include <esp_heap_caps.h>
#include <esp_log.h>
#define TAG "WakeWordCache"
WakeWordAudioCache::~WakeWordAudioCache() {
if (buffer_ != nullptr) {
heap_caps_free(buffer_);
}
}
bool WakeWordAudioCache::Initialize(size_t sample_count) {
std::lock_guard<std::mutex> lock(mutex_);
if (buffer_ != nullptr) {
return capacity_ == sample_count;
}
if (sample_count == 0) {
return false;
}
buffer_ = static_cast<int16_t*>(heap_caps_malloc(
sample_count * sizeof(int16_t), MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT));
if (buffer_ == nullptr) {
ESP_LOGE(TAG, "Failed to allocate %u bytes in PSRAM",
static_cast<unsigned>(sample_count * sizeof(int16_t)));
return false;
}
capacity_ = sample_count;
ESP_LOGI(TAG, "Allocated %u bytes in PSRAM",
static_cast<unsigned>(capacity_ * sizeof(int16_t)));
return true;
}
void WakeWordAudioCache::Store(const int16_t* data, size_t samples) {
if (data == nullptr || samples == 0) {
return;
}
std::lock_guard<std::mutex> lock(mutex_);
if (buffer_ == nullptr) {
return;
}
if (samples >= capacity_) {
data += samples - capacity_;
samples = capacity_;
std::memcpy(buffer_, data, samples * sizeof(int16_t));
size_ = capacity_;
write_position_ = 0;
return;
}
const size_t first = std::min(samples, capacity_ - write_position_);
std::memcpy(buffer_ + write_position_, data, first * sizeof(int16_t));
if (samples > first) {
std::memcpy(buffer_, data + first, (samples - first) * sizeof(int16_t));
}
write_position_ = (write_position_ + samples) % capacity_;
size_ = std::min(capacity_, size_ + samples);
}
size_t WakeWordAudioCache::Read(size_t offset, int16_t* output, size_t samples) const {
if (output == nullptr || samples == 0) {
return 0;
}
std::lock_guard<std::mutex> lock(mutex_);
if (buffer_ == nullptr || offset >= size_) {
return 0;
}
samples = std::min(samples, size_ - offset);
const size_t oldest = (write_position_ + capacity_ - size_) % capacity_;
const size_t read_position = (oldest + offset) % capacity_;
const size_t first = std::min(samples, capacity_ - read_position);
std::memcpy(output, buffer_ + read_position, first * sizeof(int16_t));
if (samples > first) {
std::memcpy(output + first, buffer_, (samples - first) * sizeof(int16_t));
}
return samples;
}
size_t WakeWordAudioCache::Size() const {
std::lock_guard<std::mutex> lock(mutex_);
return size_;
}
void WakeWordAudioCache::Clear() {
std::lock_guard<std::mutex> lock(mutex_);
size_ = 0;
write_position_ = 0;
}
@@ -0,0 +1,30 @@
#ifndef WAKE_WORD_AUDIO_CACHE_H
#define WAKE_WORD_AUDIO_CACHE_H
#include <cstddef>
#include <cstdint>
#include <mutex>
class WakeWordAudioCache {
public:
WakeWordAudioCache() = default;
~WakeWordAudioCache();
WakeWordAudioCache(const WakeWordAudioCache&) = delete;
WakeWordAudioCache& operator=(const WakeWordAudioCache&) = delete;
bool Initialize(size_t sample_count);
void Store(const int16_t* data, size_t samples);
size_t Read(size_t offset, int16_t* output, size_t samples) const;
size_t Size() const;
void Clear();
private:
int16_t* buffer_ = nullptr;
size_t capacity_ = 0;
size_t size_ = 0;
size_t write_position_ = 0;
mutable std::mutex mutex_;
};
#endif
-12
View File
@@ -1,12 +0,0 @@
{
"target": "esp32s3",
"builds": [
{
"name": "aipi-lite",
"sdkconfig_append": [
"CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y",
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/16m.csv\""
]
}
]
}
-17
View File
@@ -1,17 +0,0 @@
{
"target": "esp32s3",
"builds": [
{
"name": "aipi-lite_en",
"sdkconfig_append": [
"CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y",
"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/16m.csv\"",
"CONFIG_LANGUAGE_EN_US=y",
"CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS=n",
"CONFIG_SR_WN_WN9_JARVIS_TTS=y",
"CONFIG_SR_WN_WN9_SOPHIA_TTS=y"
]
}
]
}
@@ -192,8 +192,7 @@ private:
},
.bus_width = 8,
.max_transfer_bytes = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t),
.psram_trans_align = 64,
.sram_trans_align = 4,
.dma_burst_size = 64,
};
ESP_ERROR_CHECK(esp_lcd_new_i80_bus(&bus_config, &i80_bus));
@@ -217,11 +216,10 @@ private:
};
ESP_ERROR_CHECK(esp_lcd_new_panel_io_i80(i80_bus, &io_config, &panel_io));
esp_lcd_panel_dev_config_t panel_config = {
.reset_gpio_num = LCD_NUM_RST,
.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB,
.bits_per_pixel = 16,
};
esp_lcd_panel_dev_config_t panel_config = {};
panel_config.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB;
panel_config.bits_per_pixel = 16;
panel_config.reset_gpio_num = LCD_NUM_RST;
ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel));
esp_lcd_panel_reset(panel);
@@ -1,4 +1,6 @@
{
"manufacturer": "alientek",
"type": "atk-dnesp32s3-box",
"target": "esp32s3",
"builds": [
{
@@ -8,4 +10,4 @@
]
}
]
}
}
@@ -1,4 +1,6 @@
{
"manufacturer": "alientek",
"type": "atk-dnesp32s3-box0",
"target": "esp32s3",
"builds": [
{
@@ -6,4 +8,4 @@
"sdkconfig_append": []
}
]
}
}
@@ -347,8 +347,7 @@ private:
},
.bus_width = 8,
.max_transfer_bytes = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t),
.psram_trans_align = 64,
.sram_trans_align = 4,
.dma_burst_size = 64,
};
ESP_ERROR_CHECK(esp_lcd_new_i80_bus(&bus_config, &i80_bus));
@@ -374,11 +373,10 @@ private:
};
ESP_ERROR_CHECK(esp_lcd_new_panel_io_i80(i80_bus, &io_config, &panel_io));
esp_lcd_panel_dev_config_t panel_config = {
.reset_gpio_num = LCD_PIN_RST,
.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB,
.bits_per_pixel = 16,
};
esp_lcd_panel_dev_config_t panel_config = {};
panel_config.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB;
panel_config.bits_per_pixel = 16;
panel_config.reset_gpio_num = LCD_PIN_RST;
ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel));
esp_lcd_panel_reset(panel);
@@ -437,7 +435,8 @@ public:
AUDIO_I2S_GPIO_DOUT,
AUDIO_I2S_GPIO_DIN,
GPIO_NUM_NC,
AUDIO_CODEC_ES8389_ADDR
AUDIO_CODEC_ES8389_ADDR,
AUDIO_CODEC_USE_MCLK
);
return &audio_codec;
}
@@ -9,8 +9,8 @@ enum PowerSupply {
kDeviceBatterySupply,
};
#define AUDIO_INPUT_SAMPLE_RATE 16000
#define AUDIO_OUTPUT_SAMPLE_RATE 16000
#define AUDIO_INPUT_SAMPLE_RATE 24000
#define AUDIO_OUTPUT_SAMPLE_RATE 24000
#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_38
#define AUDIO_I2S_GPIO_WS GPIO_NUM_42
@@ -21,6 +21,7 @@ enum PowerSupply {
#define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_48
#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_47
#define AUDIO_CODEC_ES8389_ADDR ES8389_CODEC_DEFAULT_ADDR
#define AUDIO_CODEC_USE_MCLK true
#define SPISD_PIN_MOSI GPIO_NUM_16
#define SPISD_PIN_MISO GPIO_NUM_18
@@ -77,4 +78,3 @@ enum PowerSupply {
#define Module_4G_TX_PIN GPIO_NUM_43
#endif // _BOARD_CONFIG_H_
@@ -1,4 +1,6 @@
{
"manufacturer": "alientek",
"type": "atk-dnesp32s3-box2-4g",
"target": "esp32s3",
"builds": [
{
@@ -6,4 +8,4 @@
"sdkconfig_append": []
}
]
}
}
@@ -104,7 +104,7 @@ private:
});
power_save_timer_->OnShutdownRequest([this]() {
if (power_status_ == kDeviceBatterySupply) {
GetBacklight()->SetBrightness(0);
GetBacklight()->SetBrightness(0);
esp_timer_stop(power_manager_->timer_handle_);
esp_io_expander_set_dir( io_exp_handle, XIO_CHG_CTRL, IO_EXPANDER_OUTPUT);
esp_io_expander_set_level(io_exp_handle, XIO_CHG_CTRL, 0);
@@ -327,8 +327,7 @@ private:
},
.bus_width = 8,
.max_transfer_bytes = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t),
.psram_trans_align = 64,
.sram_trans_align = 4,
.dma_burst_size = 64,
};
ESP_ERROR_CHECK(esp_lcd_new_i80_bus(&bus_config, &i80_bus));
@@ -354,11 +353,10 @@ private:
};
ESP_ERROR_CHECK(esp_lcd_new_panel_io_i80(i80_bus, &io_config, &panel_io));
esp_lcd_panel_dev_config_t panel_config = {
.reset_gpio_num = LCD_PIN_RST,
.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB,
.bits_per_pixel = 16,
};
esp_lcd_panel_dev_config_t panel_config = {};
panel_config.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB;
panel_config.bits_per_pixel = 16;
panel_config.reset_gpio_num = LCD_PIN_RST;
ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel));
esp_lcd_panel_reset(panel);
@@ -417,7 +415,7 @@ public:
AUDIO_I2S_GPIO_DIN,
GPIO_NUM_NC,
AUDIO_CODEC_ES8389_ADDR,
false);
AUDIO_CODEC_USE_MCLK);
return &audio_codec;
}
@@ -8,8 +8,8 @@ enum PowerSupply {
kDeviceBatterySupply,
};
#define AUDIO_INPUT_SAMPLE_RATE 16000
#define AUDIO_OUTPUT_SAMPLE_RATE 16000
#define AUDIO_INPUT_SAMPLE_RATE 24000
#define AUDIO_OUTPUT_SAMPLE_RATE 24000
#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_38
#define AUDIO_I2S_GPIO_WS GPIO_NUM_42
@@ -20,6 +20,7 @@ enum PowerSupply {
#define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_48
#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_47
#define AUDIO_CODEC_ES8389_ADDR ES8389_CODEC_DEFAULT_ADDR
#define AUDIO_CODEC_USE_MCLK true
#define R_BUTTON_GPIO GPIO_NUM_0
@@ -68,4 +69,3 @@ enum PowerSupply {
#define DISPLAY_BACKLIGHT_OUTPUT_INVERT false
#endif // _BOARD_CONFIG_H_
@@ -1,4 +1,6 @@
{
"manufacturer": "alientek",
"type": "atk-dnesp32s3-box2-wifi",
"target": "esp32s3",
"builds": [
{
@@ -6,4 +8,4 @@
"sdkconfig_append": []
}
]
}
}
@@ -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_noto_sans_basic_20_4);
LV_FONT_DECLARE(font_material_symbols_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;
@@ -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_
@@ -0,0 +1,16 @@
{
"manufacturer": "alientek",
"type": "atk-dnesp32s3-box3",
"target": "esp32s3",
"builds": [
{
"name": "atk-dnesp32s3-box3",
"sdkconfig_append": [
"CONFIG_USE_DEVICE_AEC=y",
"CONFIG_CAMERA_GCO308=y",
"CONFIG_CAMERA_GCO308_AUTO_DETECT_DVP_INTERFACE_SENSOR=y",
"CONFIG_CAMERA_GC0308_DVP_YUV422_320X240_20FPS=y"
]
}
]
}
@@ -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;
}
};
@@ -1,4 +1,6 @@
{
"manufacturer": "alientek",
"type": "atk-dnesp32s3",
"target": "esp32s3",
"builds": [
{
@@ -10,4 +12,4 @@
]
}
]
}
}
@@ -0,0 +1,6 @@
{
"manufacturer": "alientek",
"type": "atk-dnesp32s3m-4g",
"target": "esp32s3",
"builds": []
}

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