From 70481429367ca00144b24f118cbfdfb28b02967d Mon Sep 17 00:00:00 2001
From: wangzhibo <wangzhibo@shsening.com>
Date: 星期三, 26 八月 2026 15:54:31 +0800
Subject: [PATCH] add sening functions

---
 xiaozhi-esp32-2.2.6/main/boards/common/board.h                                          |    3 
 xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.cc                                    |   22 
 xiaozhi-esp32-2.2.6/main/audio/wake_words/esp_wake_word.h                               |    2 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h                                |   23 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c                                | 2243 +++++++++++++++
 xiaozhi-esp32-2.2.6/main/mcp_server.h                                                   |    8 
 xiaozhi-esp32-2.2.6/main/audio/wake_word.h                                              |    1 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt_interface.md                     |  437 ++
 xiaozhi-esp32-2.2.6/tools/validate_no_boot_jpg_restore.py                               |   24 
 xiaozhi-esp32-2.2.6/docs/superpowers/plans/2026-06-25-offline-command-word.md           |   89 
 xiaozhi-esp32-2.2.6/main/audio/wake_words/esp_wake_word.cc                              |    4 
 xiaozhi-esp32-2.2.6/tools/validate_power_off_restore_playlist_resume.py                 |   47 
 xiaozhi-esp32-2.2.6/tools/validate_power_off_stops_playlist.py                          |   38 
 xiaozhi-esp32-2.2.6/tools/validate_stt_event_report.py                                  |   61 
 xiaozhi-esp32-2.2.6/main/audio/audio_service.h                                          |    1 
 xiaozhi-esp32-2.2.6/main/audio/audio_service.cc                                         |   11 
 xiaozhi-esp32-2.2.6/main/mcp_server.cc                                                  |    3 
 xiaozhi-esp32-2.2.6/main/application.cc                                                 |  573 +++
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt_download_bridge.cc               |    0 
 xiaozhi-esp32-2.2.6/main/protocols/mqtt_protocol.h                                      |    3 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc                         |  380 ++
 xiaozhi-esp32-2.2.6/tools/validate_alarm_tool_schema.py                                 |   29 
 xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/03_feature_request.yml                       |   34 
 xiaozhi-esp32-2.2.6/tools/validate_sdcard_voice_coordination.py                         |   76 
 xiaozhi-esp32-2.2.6/docs/superpowers/plans/2026-06-17-update-manifest-batch-download.md |  625 ++++
 xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h                            |    2 
 xiaozhi-esp32-2.2.6/main/CMakeLists.txt                                                 |    3 
 xiaozhi-esp32-2.2.6/tools/validate_internal_tool_speech_filter.py                       |   44 
 xiaozhi-esp32-2.2.6/.github/workflows/build.yml                                         |  111 
 xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc                              |   56 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h                              |  402 ++
 xiaozhi-esp32-2.2.6/main/protocols/mqtt_protocol.cc                                     |   98 
 xiaozhi-esp32-2.2.6/main/application.h                                                  |   38 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c                              | 1666 +++++++++++
 xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/01_build_install_bug.yml                     |  103 
 xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h                               |    7 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/README.md                             |    1 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/wifi_history_api.cc                   |   44 
 xiaozhi-esp32-2.2.6/main/protocols/protocol.h                                           |    1 
 xiaozhi-esp32-2.2.6/sdkconfig.defaults                                                  |    5 
 xiaozhi-esp32-2.2.6/main/Kconfig.projbuild                                              |    3 
 xiaozhi-esp32-2.2.6/.superpowers/sdd/task-1-report.md                                   |   71 
 xiaozhi-esp32-2.2.6/main/idf_component.yml                                              |    6 
 xiaozhi-esp32-2.2.6/partitions/v2/16m.csv                                               |    3 
 xiaozhi-esp32-2.2.6/tools/validate_offline_command_wiring.py                            |   37 
 xiaozhi-esp32-2.2.6/tools/validate_playlist_jpg_display.py                              |   78 
 xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/config.yml                                   |    8 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/config.h                              |   52 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc                  |  985 ++++++
 xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.h                                     |    3 
 xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc                           |   29 
 xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/config.json                           |    9 
 xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/02_runtime_bug.yml                           |  115 
 xiaozhi-esp32-2.2.6/tools/validate_config_mode_offline_resume.py                        |   56 
 xiaozhi-esp32-2.2.6/tools/validate_mqtt_alarm_compat.py                                 |   36 
 xiaozhi-esp32-2.2.6/scripts/build_default_assets.py                                     |    6 
 xiaozhi-esp32-2.2.6/sdkconfig.defaults.esp32s3                                          |    1 
 57 files changed, 8,746 insertions(+), 70 deletions(-)

diff --git a/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/01_build_install_bug.yml b/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/01_build_install_bug.yml
new file mode 100644
index 0000000..d3489e2
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/01_build_install_bug.yml
@@ -0,0 +1,103 @@
+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
\ No newline at end of file
diff --git a/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/02_runtime_bug.yml b/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/02_runtime_bug.yml
new file mode 100644
index 0000000..60a62f8
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/02_runtime_bug.yml
@@ -0,0 +1,115 @@
+name: Runtime bug report
+description: Report runtime 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 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.
+          required: true
+  - type: input
+    id: xiaozhi_ai_firmware_version
+    attributes:
+      label: XiaoZhi AI firmware version.
+      description: On which firmware version does this issue occur on?
+      placeholder: ex. v1.2.1_bread-compact-wifi
+    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: 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
\ No newline at end of file
diff --git a/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/03_feature_request.yml b/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/03_feature_request.yml
new file mode 100644
index 0000000..79cb921
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/03_feature_request.yml
@@ -0,0 +1,34 @@
+name: Feature request
+description: Suggest an idea for this project.
+labels: ['enhancement']
+body:
+  - type: markdown
+    attributes:
+      value: |
+        * We welcome any ideas or feature requests! It鈥檚 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
+    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 ...
+  - 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 ...
+  - 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 ...
\ No newline at end of file
diff --git a/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/config.yml b/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..d663ce7
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,8 @@
+blank_issues_enabled: true
+contact_links:
+  - name: 灏忔櫤 AI 瀹樻柟缃戠珯
+    url: https://xiaozhi.me/
+    about: 婵�娲昏澶囥�侀厤缃� AI銆佸0绾硅瘑鍒�佸0闊冲厠闅嗙瓑搴旀湁灏芥湁锛孌IY 灞炰簬浣犺嚜宸辩殑灏忔櫤
+  - name: 灏忔櫤 AI 鑱婂ぉ鏈哄櫒浜虹櫨绉戝叏涔�
+    url: https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb
+    about: 寮�鍙戞枃妗c�佺‖浠跺埗浣溿�佺儳褰曟暀绋嬨�丗AQ灏藉湪灏忔櫤鐧剧
\ No newline at end of file
diff --git a/xiaozhi-esp32-2.2.6/.github/workflows/build.yml b/xiaozhi-esp32-2.2.6/.github/workflows/build.yml
new file mode 100644
index 0000000..e559641
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/.github/workflows/build.yml
@@ -0,0 +1,111 @@
+name: Build Boards
+
+on:
+  push:
+    branches:
+      - main
+      - ci/* # for ci test
+  pull_request:
+    branches:
+      - main
+
+permissions:
+  contents: read
+
+jobs:
+  prepare:
+    name: Determine variants to build
+    runs-on: ubuntu-latest
+    outputs:
+      variants: ${{ steps.select.outputs.variants }}
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v6
+        with:
+          fetch-depth: 0
+
+      - name: Install jq
+        run: sudo apt-get update && sudo apt-get install -y jq
+
+      - id: list
+        name: Get all variant list
+        run: |
+          echo "all_variants=$(python scripts/release.py --list-boards --json)" >> $GITHUB_OUTPUT
+
+      - id: select
+        name: Select variants based on changes
+        env:
+          ALL_VARIANTS: ${{ steps.list.outputs.all_variants }}
+        run: |
+          EVENT_NAME="${{ github.event_name }}"
+
+          # push 鍒� main 鍒嗘敮锛岀紪璇戝叏閮ㄥ彉浣�
+          if [[ "$EVENT_NAME" == "push" ]]; then
+            echo "variants=$ALL_VARIANTS" >> $GITHUB_OUTPUT
+            exit 0
+          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"
+
+          CHANGED=$(git diff --name-only $BASE_SHA $HEAD_SHA || true)
+          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
+
+  build:
+    name: Build ${{ matrix.full_name }}
+    needs: prepare
+    if: ${{ needs.prepare.outputs.variants != '[]' }}
+    strategy:
+      fail-fast: false  # 鍗曚釜鍙樹綋澶辫触涓嶅奖鍝嶅叾瀹冨彉浣�
+      matrix:
+        include: ${{ fromJson(needs.prepare.outputs.variants) }}
+    runs-on: ubuntu-latest
+    container:
+      image: espressif/idf:v5.5.2
+    steps:
+      - name: Checkout
+        uses: actions/checkout@v4
+
+      - name: Build current variant
+        shell: bash
+        run: |
+          source $IDF_PATH/export.sh
+          python scripts/release.py ${{ matrix.board }} --name ${{ matrix.name }}
+
+      - name: Upload artifacts
+        uses: actions/upload-artifact@v4
+        with:
+          name: xiaozhi_${{ matrix.full_name }}_${{ github.sha }}
+          path: build/merged-binary.bin
+          if-no-files-found: error
diff --git a/xiaozhi-esp32-2.2.6/.superpowers/sdd/task-1-report.md b/xiaozhi-esp32-2.2.6/.superpowers/sdd/task-1-report.md
new file mode 100644
index 0000000..119e0d1
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/.superpowers/sdd/task-1-report.md
@@ -0,0 +1,71 @@
+Status: DONE_WITH_CONCERNS
+
+Files changed:
+- `D:\Project\xiaozhi-esp32\main\boards\sening-xiaozhi-s3\mqtt.h`
+- `D:\Project\xiaozhi-esp32\main\boards\sening-xiaozhi-s3\mqtt.c`
+
+Verification:
+1. Command: `cmd.exe //c "set MSYSTEM=& idf.py build"`
+   - Exit status: 0
+   - Key output:
+     - `Project build complete. To flash, run:`
+     - `xiaozhi.bin binary size 0x302ba0 bytes. Smallest app partition is 0x3f0000 bytes. 0xed460 bytes (24%) free.`
+     - Warnings emitted during `mqtt.c` compilation:
+       - `warning: 'BoardMqtt_StopPlaylistTask' declared 'static' but never defined [-Wunused-function]`
+       - `warning: 'BoardMqtt_PlaylistHasUnfinished' declared 'static' but never defined [-Wunused-function]`
+       - `warning: 'BoardMqtt_StartPlaylistTask' declared 'static' but never defined [-Wunused-function]`
+       - `warning: 's_playlist_task_handle' defined but not used [-Wunused-variable]`
+
+Self-review against brief:
+1. `mqtt.h`: Added `void BoardMqtt_ResumePlaylistIfUnfinished(void);` inside the `extern "C"` block immediately after `bool BoardMqtt_CancelDownload(void);`.
+2. `mqtt.c`: Inserted the three playlist scaffolding macros immediately after `#define BOARD_MQTT_QOS 0`:
+   - `BOARD_MQTT_PLAYLIST_PATH`
+   - `BOARD_MQTT_PLAYLIST_TASK_STACK_SIZE`
+   - `BOARD_MQTT_PLAYLIST_WAIT_MS`
+3. `board_mqtt_task_type_t`: Extended the enum to include `BOARD_MQTT_TASK_PLAYLIST` after `BOARD_MQTT_TASK_OTA`.
+4. State scaffolding: Inserted after `static uint32_t s_state_sequence = 0;`:
+   - `static TaskHandle_t s_playlist_task_handle = NULL;`
+   - `static volatile bool s_playlist_stop_requested = false;`
+5. Forward declarations: Inserted after `static void BoardMqtt_WaitConnectedForFinalReport(void);`:
+   - `static void BoardMqtt_StopPlaylistTask(bool stop_mp3);`
+   - `static bool BoardMqtt_PlaylistHasUnfinished(void);`
+   - `static esp_err_t BoardMqtt_StartPlaylistTask(const char *req_id, bool resume_only);`
+   Confirmed no `BoardMqtt_HandlePlayMedia()` prototype was added there.
+6. Verification command was run exactly as required, and the build completed successfully.
+
+Concerns:
+- The required Task 1 scaffolding builds successfully, but it introduces compiler warnings because the newly added static playlist helper declarations are not yet defined and `s_playlist_task_handle` is not yet used.
+- The new public declaration `BoardMqtt_ResumePlaylistIfUnfinished()` is intentionally only declared at this stage; its implementation appears to be deferred to later playlist tasks.
+
+Follow-up fix:
+1. Scope:
+   - Removed the warning-producing unused playlist helper forward declarations from `D:\Project\xiaozhi-esp32\main\boards\sening-xiaozhi-s3\mqtt.c`:
+     - `static void BoardMqtt_StopPlaylistTask(bool stop_mp3);`
+     - `static bool BoardMqtt_PlaylistHasUnfinished(void);`
+     - `static esp_err_t BoardMqtt_StartPlaylistTask(const char *req_id, bool resume_only);`
+   - Removed the warning-producing unused playlist state variables from the same file:
+     - `static TaskHandle_t s_playlist_task_handle = NULL;`
+     - `static volatile bool s_playlist_stop_requested = false;`
+   - Kept the requested non-warning Task 1 playlist scaffolding in place:
+     - `BOARD_MQTT_PLAYLIST_PATH`
+     - `BOARD_MQTT_PLAYLIST_TASK_STACK_SIZE`
+     - `BOARD_MQTT_PLAYLIST_WAIT_MS`
+     - `BOARD_MQTT_TASK_PLAYLIST`
+2. Verification:
+   - Command: `cmd.exe //c "set MSYSTEM=& idf.py build"`
+   - Exit status: 0
+   - Exact command output summary:
+     - `Building C object esp-idf/main/CMakeFiles/__idf_main.dir/boards/sening-xiaozhi-s3/mqtt.c.obj`
+     - `Linking C static library esp-idf\main\libmain.a`
+     - `Linking CXX executable xiaozhi.elf`
+     - `Successfully created esp32s3 image.`
+     - `Generated D:/Project/xiaozhi-esp32/build/xiaozhi.bin`
+     - `xiaozhi.bin binary size 0x302ba0 bytes. Smallest app partition is 0x3f0000 bytes. 0xed460 bytes (24%) free.`
+     - `Project build complete. To flash, run:`
+   - Result: build completed successfully and no playlist-related unused declaration/unused variable warnings were emitted from `mqtt.c`.
+3. Self-review:
+   - Limited the code change to `mqtt.c` and left the intended playlist constants and enum value intact.
+   - Did not touch the public header declaration `void BoardMqtt_ResumePlaylistIfUnfinished(void);`.
+   - Verification used the exact required command and confirmed warning cleanliness did not regress for this Task 1 scaffolding fix.
+4. Concerns:
+   - The retained public declaration still depends on later tasks for implementation and runtime integration.
diff --git a/xiaozhi-esp32-2.2.6/docs/superpowers/plans/2026-06-17-update-manifest-batch-download.md b/xiaozhi-esp32-2.2.6/docs/superpowers/plans/2026-06-17-update-manifest-batch-download.md
new file mode 100644
index 0000000..cb46d69
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/docs/superpowers/plans/2026-06-17-update-manifest-batch-download.md
@@ -0,0 +1,625 @@
+# UPDATE_MANIFEST Batch Download Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make `UPDATE_MANIFEST` save the course manifest and asynchronously download every listed asset, overwriting existing files safely, with web UI support for uploading multiple MP3 files and generating the manifest command.
+
+**Architecture:** Reuse the existing single-file `DOWNLOAD` pipeline rather than introducing a second downloader. `mqtt.c` will add a manifest job that validates assets, stores a compact copy of the manifest, then runs a FreeRTOS worker to save the manifest and download each asset sequentially via `Board_Download_File_To_Path()`, reporting aggregate progress using the existing MQTT progress topic. The web test server will add a multi-MP3 upload endpoint and UI that builds an `UPDATE_MANIFEST` payload with local HTTP URLs, MD5s, and target paths.
+
+**Tech Stack:** ESP-IDF C/C++, FreeRTOS tasks, cJSON, SD card filesystem helpers in `system.c`, Python `http.server`/`cgi`, browser JavaScript.
+
+---
+
+## File Structure
+
+- Modify `main/boards/sening-xiaozhi-s3/mqtt.c`
+  - Add manifest job structs.
+  - Extend `BoardMqtt_HandleUpdateManifest()` from save-only to async save + batch download.
+  - Reuse `s_task_busy` so `DOWNLOAD`, `OTA_UPDATE`, and `UPDATE_MANIFEST` are mutually exclusive.
+  - Reuse `Board_Download_File_To_Path()`, `SDCard_Prepare_Business_File()`, `SDCard_Commit_Part_File()`, and `SDCard_Remove_File_If_Exists()`.
+
+- Modify `main/boards/sening-xiaozhi-s3/MQTT_Web/local_mqtt_test_server.py`
+  - Add multi-file MP3 upload API.
+  - Add UI controls for course ID and multiple MP3 import.
+  - Generate an `UPDATE_MANIFEST` payload from uploaded files.
+  - Ensure progress panel tracks `UPDATE_MANIFEST` tasks.
+
+- No new firmware source files.
+- No changes to `system.c` expected because existing helpers already overwrite via commit/rename path.
+
+---
+
+### Task 1: Firmware manifest job data structures and helpers
+
+**Files:**
+- Modify: `main/boards/sening-xiaozhi-s3/mqtt.c`
+
+- [ ] **Step 1: Add asset/job structs near existing download and OTA job structs**
+
+Insert after `board_mqtt_ota_job_t`:
+
+```c
+typedef struct {
+    char url[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+    char target_path[SDCARD_PATH_MAX_LEN];
+    char md5[33];
+} board_mqtt_manifest_asset_t;
+
+typedef struct {
+    char req_id[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+    char course_id[ALARM_ID_MAX_LEN];
+    char *manifest_json;
+    size_t asset_count;
+    board_mqtt_manifest_asset_t assets[];
+} board_mqtt_manifest_job_t;
+```
+
+- [ ] **Step 2: Add aggregate manifest progress context after `board_mqtt_download_progress_ctx_t`**
+
+```c
+typedef struct {
+    const char *req_id;
+    size_t asset_index;
+    size_t asset_count;
+    int64_t last_publish_us;
+    int last_progress;
+} board_mqtt_manifest_progress_ctx_t;
+```
+
+- [ ] **Step 3: Add manifest progress callback after `BoardMqtt_DownloadProgressCallback()`**
+
+```c
+static void BoardMqtt_ManifestProgressCallback(int progress, const char *info, void *ctx)
+{
+    board_mqtt_manifest_progress_ctx_t *progress_ctx = (board_mqtt_manifest_progress_ctx_t *)ctx;
+    if (!progress_ctx || progress_ctx->asset_count == 0) {
+        return;
+    }
+
+    int aggregate = (int)(((progress_ctx->asset_index * 100) + progress) / progress_ctx->asset_count);
+    if (aggregate < 0) {
+        aggregate = 0;
+    } else if (aggregate > 100) {
+        aggregate = 100;
+    }
+
+    int64_t now = esp_timer_get_time();
+    bool force = aggregate <= 0 || aggregate >= 100;
+    if (!force && aggregate == progress_ctx->last_progress) {
+        return;
+    }
+    if (!force && progress_ctx->last_publish_us > 0 && now - progress_ctx->last_publish_us < 2000000) {
+        return;
+    }
+
+    progress_ctx->last_publish_us = now;
+    progress_ctx->last_progress = aggregate;
+    char progress_info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN];
+    snprintf(progress_info, sizeof(progress_info), "asset %u/%u %s",
+             (unsigned)(progress_ctx->asset_index + 1),
+             (unsigned)progress_ctx->asset_count,
+             info ? info : "downloading");
+    BoardMqtt_PublishProgress(progress_ctx->req_id, aggregate, progress_info);
+}
+```
+
+- [ ] **Step 4: Build firmware to catch type errors**
+
+Run:
+
+```bash
+cmd.exe //c "set MSYSTEM=& idf.py build"
+```
+
+Expected: build succeeds or fails only because later tasks are not implemented yet if references are added prematurely. At this task, it should succeed.
+
+---
+
+### Task 2: Firmware asynchronous UPDATE_MANIFEST download task
+
+**Files:**
+- Modify: `main/boards/sening-xiaozhi-s3/mqtt.c`
+
+- [ ] **Step 1: Add manifest worker task before `BoardMqtt_DownloadTask()`**
+
+```c
+static void BoardMqtt_ManifestTask(void *arg)
+{
+    board_mqtt_manifest_job_t *job = (board_mqtt_manifest_job_t *)arg;
+    if (!job) {
+        vTaskDelete(NULL);
+        return;
+    }
+
+    Board_Acquire_Sleep_Inhibit();
+    BoardMqtt_PublishProgress(job->req_id, 0, "saving manifest");
+
+    esp_err_t err = SDCard_Save_Manifest(job->course_id, job->manifest_json);
+    char error_info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN] = {0};
+    if (err != ESP_OK) {
+        snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err));
+    }
+
+    for (size_t i = 0; err == ESP_OK && i < job->asset_count; ++i) {
+        char final_path[SDCARD_PATH_MAX_LEN] = {0};
+        char part_path[SDCARD_PATH_MAX_LEN] = {0};
+        board_mqtt_manifest_progress_ctx_t progress_ctx = {
+            .req_id = job->req_id,
+            .asset_index = i,
+            .asset_count = job->asset_count,
+            .last_publish_us = 0,
+            .last_progress = -1,
+        };
+
+        err = SDCard_Prepare_Business_File(job->assets[i].target_path, final_path, sizeof(final_path), part_path, sizeof(part_path));
+        if (err == ESP_OK) {
+            err = Board_Download_File_To_Path(job->assets[i].url, part_path, job->assets[i].md5,
+                                             BoardMqtt_ManifestProgressCallback, &progress_ctx,
+                                             error_info, sizeof(error_info));
+        } else {
+            snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err));
+        }
+
+        if (err == ESP_OK) {
+            err = SDCard_Commit_Part_File(part_path, final_path);
+            if (err != ESP_OK) {
+                snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err));
+            }
+        }
+
+        if (err != ESP_OK) {
+            SDCard_Remove_File_If_Exists(part_path);
+            break;
+        }
+
+        int completed_progress = (int)(((i + 1) * 100) / job->asset_count);
+        char info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN];
+        snprintf(info, sizeof(info), "asset %u/%u completed", (unsigned)(i + 1), (unsigned)job->asset_count);
+        BoardMqtt_PublishProgress(job->req_id, completed_progress, info);
+    }
+
+    if (err == ESP_OK) {
+        BoardMqtt_PublishProgress(job->req_id, 100, "manifest assets downloaded");
+        BoardMqtt_PublishAck(job->req_id, true, 0, "manifest saved and assets downloaded");
+    } else {
+        BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED,
+                             error_info[0] ? error_info : esp_err_to_name(err));
+    }
+
+    s_task_busy = false;
+    Board_Release_Sleep_Inhibit();
+    free(job->manifest_json);
+    free(job);
+    vTaskDelete(NULL);
+}
+```
+
+- [ ] **Step 2: Build to catch missing symbol or declaration issues**
+
+Run:
+
+```bash
+cmd.exe //c "set MSYSTEM=& idf.py build"
+```
+
+Expected: build succeeds after Task 1 is complete.
+
+---
+
+### Task 3: Firmware UPDATE_MANIFEST handler validation and async dispatch
+
+**Files:**
+- Modify: `main/boards/sening-xiaozhi-s3/mqtt.c:703-730`
+
+- [ ] **Step 1: Replace `BoardMqtt_HandleUpdateManifest()` with async implementation**
+
+Replace the whole function with:
+
+```c
+static board_mqtt_action_result_t BoardMqtt_HandleUpdateManifest(const char *req_id, cJSON *payload)
+{
+    cJSON *course_id = cJSON_GetObjectItem(payload, "course_id");
+    cJSON *assets = cJSON_GetObjectItem(payload, "assets");
+    if (!cJSON_IsString(course_id) || !cJSON_IsArray(assets)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest payload");
+    }
+
+    int asset_count = cJSON_GetArraySize(assets);
+    if (asset_count <= 0) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "empty manifest assets");
+    }
+    if (s_task_busy) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "task busy");
+    }
+
+    char *manifest_json = cJSON_PrintUnformatted((cJSON *)payload);
+    if (!manifest_json) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "failed to format manifest");
+    }
+
+    size_t job_size = sizeof(board_mqtt_manifest_job_t) + sizeof(board_mqtt_manifest_asset_t) * (size_t)asset_count;
+    board_mqtt_manifest_job_t *job = (board_mqtt_manifest_job_t *)calloc(1, job_size);
+    if (!job) {
+        cJSON_free(manifest_json);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "no memory for manifest job");
+    }
+
+    job->manifest_json = manifest_json;
+    job->asset_count = (size_t)asset_count;
+    if (!BoardMqtt_CopyString(job->req_id, sizeof(job->req_id), req_id) ||
+        !BoardMqtt_CopyString(job->course_id, sizeof(job->course_id), course_id->valuestring)) {
+        free(job->manifest_json);
+        free(job);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "manifest field too long");
+    }
+
+    int index = 0;
+    cJSON *asset = NULL;
+    cJSON_ArrayForEach(asset, assets) {
+        cJSON *url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "url") : NULL;
+        cJSON *target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "target_path") : NULL;
+        cJSON *md5 = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "md5") : NULL;
+        if (!cJSON_IsString(url) || !cJSON_IsString(target_path)) {
+            free(job->manifest_json);
+            free(job);
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset");
+        }
+        if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) {
+            free(job->manifest_json);
+            free(job);
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset url");
+        }
+        char sd_path[SDCARD_PATH_MAX_LEN];
+        if (SDCard_Map_Business_Path(target_path->valuestring, sd_path, sizeof(sd_path)) != ESP_OK) {
+            free(job->manifest_json);
+            free(job);
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+        }
+        if (!BoardMqtt_CopyString(job->assets[index].url, sizeof(job->assets[index].url), url->valuestring) ||
+            !BoardMqtt_CopyString(job->assets[index].target_path, sizeof(job->assets[index].target_path), target_path->valuestring)) {
+            free(job->manifest_json);
+            free(job);
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
+        }
+        if (cJSON_IsString(md5) && md5->valuestring[0] != '\0') {
+            BoardMqtt_CopyString(job->assets[index].md5, sizeof(job->assets[index].md5), md5->valuestring);
+        }
+        index++;
+    }
+
+    s_task_busy = true;
+    BaseType_t ret = xTaskCreate(BoardMqtt_ManifestTask, "mqtt_manifest", 8192, job, 5, NULL);
+    if (ret != pdPASS) {
+        s_task_busy = false;
+        free(job->manifest_json);
+        free(job);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "failed to create manifest task");
+    }
+    return BoardMqtt_ResultOk("manifest accepted");
+}
+```
+
+- [ ] **Step 2: Update dispatch call signature**
+
+Change:
+
+```c
+if (strcmp(action, "UPDATE_MANIFEST") == 0) return BoardMqtt_HandleUpdateManifest(payload);
+```
+
+To:
+
+```c
+if (strcmp(action, "UPDATE_MANIFEST") == 0) return BoardMqtt_HandleUpdateManifest(req_id, payload);
+```
+
+- [ ] **Step 3: Build firmware**
+
+Run:
+
+```bash
+cmd.exe //c "set MSYSTEM=& idf.py build"
+```
+
+Expected: build succeeds and generates `D:/Project/xiaozhi-esp32/build/xiaozhi.bin`.
+
+---
+
+### Task 4: Web server multi-MP3 upload API
+
+**Files:**
+- Modify: `main/boards/sening-xiaozhi-s3/MQTT_Web/local_mqtt_test_server.py`
+
+- [ ] **Step 1: Add POST route**
+
+In `do_POST()`, after `/api/upload-mp3` route, add:
+
+```python
+        elif path == "/api/upload-mp3-batch":
+            self._handle_upload_mp3_batch()
+```
+
+- [ ] **Step 2: Add batch upload handler after `_handle_upload_mp3()`**
+
+```python
+    def _handle_upload_mp3_batch(self):
+        ctype, pdict = cgi.parse_header(self.headers.get("Content-Type", ""))
+        if ctype != "multipart/form-data":
+            self._send_json(400, {"error": "multipart/form-data required"})
+            return
+        form = cgi.FieldStorage(fp=self.rfile, headers=self.headers, environ={
+            "REQUEST_METHOD": "POST",
+            "CONTENT_TYPE": self.headers.get("Content-Type", ""),
+            "CONTENT_LENGTH": self.headers.get("Content-Length", "0"),
+        })
+        fields = form["file"] if "file" in form else []
+        if not isinstance(fields, list):
+            fields = [fields]
+        uploads = []
+        for field in fields:
+            if not field.filename:
+                continue
+            original = safe_filename(field.filename)
+            if not original.lower().endswith(".mp3"):
+                self._send_json(400, {"error": f"only mp3 is supported: {original}"})
+                return
+            stored_name = f"{int(time.time())}-{uuid.uuid4().hex[:8]}-{original}"
+            full_path = os.path.join(self.state.upload_dir, stored_name)
+            with open(full_path, "wb") as fp:
+                while True:
+                    chunk = field.file.read(1024 * 1024)
+                    if not chunk:
+                        break
+                    fp.write(chunk)
+            meta = {
+                "filename": stored_name,
+                "original_name": original,
+                "size": os.path.getsize(full_path),
+                "md5": md5_file(full_path),
+                "url": f"{self.state.lan_base_url}/uploads/{stored_name}",
+            }
+            uploads.append(meta)
+        if not uploads:
+            self._send_json(400, {"error": "file is required"})
+            return
+        with self.state.upload_lock:
+            self.state.uploads.extend(uploads)
+        self._send_json(200, {"files": uploads})
+```
+
+- [ ] **Step 3: Run Python syntax check**
+
+Run:
+
+```bash
+python -m py_compile "main/boards/sening-xiaozhi-s3/MQTT_Web/local_mqtt_test_server.py"
+```
+
+Expected: no output.
+
+---
+
+### Task 5: Web UI for batch manifest download
+
+**Files:**
+- Modify: `main/boards/sening-xiaozhi-s3/MQTT_Web/local_mqtt_test_server.py`
+
+- [ ] **Step 1: Add HTML block below single MP3 upload block**
+
+Add:
+
+```html
+      <hr style="border-color:#2b3958; margin:18px 0;">
+      <h2>鎵归噺瀵煎叆璇剧▼ MP3</h2>
+      <label for="manifestCourseId">璇剧▼ ID</label>
+      <input id="manifestCourseId" placeholder="course_001" value="course_001">
+      <label for="manifestMp3Files">閫夋嫨澶氫釜 MP3 鏂囦欢</label>
+      <input id="manifestMp3Files" type="file" accept=".mp3,audio/mpeg" multiple>
+      <label for="manifestTargetDir">璁惧淇濆瓨鐩綍</label>
+      <input id="manifestTargetDir" placeholder="/data/course_001" value="/data/course_001">
+      <button id="uploadManifestMp3Btn" class="secondary" type="button">涓婁紶骞剁敓鎴� UPDATE_MANIFEST</button>
+      <div class="upload-result card" id="manifestUploadResult">
+        <div class="small">璇剧▼璧勬簮</div>
+        <pre id="manifestUploadInfo"></pre>
+        <p class="small">璁惧鏀跺埌 UPDATE_MANIFEST 鍚庝細淇濆瓨绱㈠紩骞堕�愪釜涓嬭浇璧勬簮锛屽悓鍚嶆枃浠朵細琚鐩栥��</p>
+        <button id="fillManifestBtn" type="button">濉叆 UPDATE_MANIFEST 鎸囦护</button>
+      </div>
+```
+
+- [ ] **Step 2: Add JS element references near upload references**
+
+```javascript
+    const manifestCourseIdEl = document.getElementById('manifestCourseId');
+    const manifestMp3FilesEl = document.getElementById('manifestMp3Files');
+    const manifestTargetDirEl = document.getElementById('manifestTargetDir');
+    const manifestUploadResultEl = document.getElementById('manifestUploadResult');
+    const manifestUploadInfoEl = document.getElementById('manifestUploadInfo');
+```
+
+- [ ] **Step 3: Add state variable near `latestUpload`**
+
+```javascript
+    let latestManifestUpload = null;
+```
+
+- [ ] **Step 4: Add helper functions near `uploadMp3()`**
+
+```javascript
+    function joinBusinessPath(dir, name) {
+      const cleanDir = (dir || '/data').trim().replace(/\/+$/, '') || '/data';
+      return `${cleanDir}/${name}`;
+    }
+
+    async function uploadManifestMp3Batch() {
+      const files = manifestMp3FilesEl.files;
+      if (!files || files.length === 0) {
+        alert('璇烽�夋嫨鑷冲皯涓�涓� MP3 鏂囦欢');
+        return;
+      }
+      const courseId = manifestCourseIdEl.value.trim();
+      if (!courseId) {
+        alert('璇峰~鍐欒绋� ID');
+        return;
+      }
+      const form = new FormData();
+      Array.from(files).forEach(file => form.append('file', file));
+      const res = await fetch('/api/upload-mp3-batch', {method: 'POST', body: form});
+      const data = await res.json();
+      if (!res.ok) {
+        alert(data.error || '涓婁紶澶辫触');
+        return;
+      }
+      latestManifestUpload = {
+        course_id: courseId,
+        files: data.files.map(file => ({
+          ...file,
+          target_path: joinBusinessPath(manifestTargetDirEl.value, file.original_name)
+        }))
+      };
+      manifestUploadResultEl.style.display = 'block';
+      manifestUploadInfoEl.textContent = JSON.stringify(latestManifestUpload, null, 2);
+      fillManifestPayload();
+    }
+
+    function fillManifestPayload() {
+      if (!latestManifestUpload) {
+        alert('璇峰厛涓婁紶璇剧▼ MP3 鏂囦欢');
+        return;
+      }
+      actionEl.value = 'UPDATE_MANIFEST';
+      updateTemplate();
+      payloadEl.value = JSON.stringify({
+        course_id: latestManifestUpload.course_id,
+        assets: latestManifestUpload.files.map(file => ({
+          url: file.url,
+          md5: file.md5,
+          target_path: file.target_path
+        }))
+      }, null, 2);
+    }
+```
+
+- [ ] **Step 5: Add event listeners near existing upload listeners**
+
+```javascript
+    document.getElementById('uploadManifestMp3Btn').addEventListener('click', uploadManifestMp3Batch);
+    document.getElementById('fillManifestBtn').addEventListener('click', fillManifestPayload);
+```
+
+- [ ] **Step 6: Ensure progress panel tracks UPDATE_MANIFEST**
+
+Find:
+
+```javascript
+if (!msg || !['DOWNLOAD', 'OTA_UPDATE'].includes(msg.action) || !msg.header || !msg.header.req_id) return;
+```
+
+Change to:
+
+```javascript
+if (!msg || !['DOWNLOAD', 'OTA_UPDATE', 'UPDATE_MANIFEST'].includes(msg.action) || !msg.header || !msg.header.req_id) return;
+```
+
+- [ ] **Step 7: Run Python syntax check**
+
+Run:
+
+```bash
+python -m py_compile "main/boards/sening-xiaozhi-s3/MQTT_Web/local_mqtt_test_server.py"
+```
+
+Expected: no output.
+
+---
+
+### Task 6: End-to-end validation commands
+
+**Files:**
+- No code changes.
+
+- [ ] **Step 1: Build firmware**
+
+Run:
+
+```bash
+cmd.exe //c "set MSYSTEM=& idf.py build"
+```
+
+Expected:
+
+```text
+Project build complete.
+Generated D:/Project/xiaozhi-esp32/build/xiaozhi.bin
+```
+
+- [ ] **Step 2: Start local MQTT web server manually**
+
+Run the existing launcher from Windows Explorer or command prompt:
+
+```bat
+main\boards\sening-xiaozhi-s3\start_mqtt_test_server.bat
+```
+
+Expected: browser opens local test page.
+
+- [ ] **Step 3: Web UI manual test**
+
+Use the new 鈥滄壒閲忓鍏ヨ绋� MP3鈥� section:
+
+1. Set course ID to `course_001`.
+2. Select two MP3 files.
+3. Keep target dir `/data/course_001`.
+4. Click 鈥滀笂浼犲苟鐢熸垚 UPDATE_MANIFEST鈥�.
+5. Verify generated payload shape:
+
+```json
+{
+  "course_id": "course_001",
+  "assets": [
+    {
+      "url": "http://<pc-ip>:8080/uploads/<file1>.mp3",
+      "md5": "<md5>",
+      "target_path": "/data/course_001/<file1>.mp3"
+    },
+    {
+      "url": "http://<pc-ip>:8080/uploads/<file2>.mp3",
+      "md5": "<md5>",
+      "target_path": "/data/course_001/<file2>.mp3"
+    }
+  ]
+}
+```
+
+- [ ] **Step 4: Firmware manual test**
+
+Send the generated `UPDATE_MANIFEST` command to the device.
+
+Expected terminal logs:
+
+```text
+board_mqtt: MQTT command JSON received from ...
+board_mqtt: Published MQTT message ... rpt/ack ... manifest accepted
+board_mqtt: Published MQTT message ... rpt/progress ... saving manifest
+system: save manifest: /sdcard/manifest/course_001.json
+board_mqtt: Published MQTT message ... rpt/progress ... asset 1/2 ...
+board_mqtt: Published MQTT message ... rpt/progress ... asset 2/2 ...
+board_mqtt: Published MQTT message ... rpt/ack ... manifest saved and assets downloaded
+```
+
+- [ ] **Step 5: Overwrite manual test**
+
+Send the same `UPDATE_MANIFEST` command again.
+
+Expected:
+
+- Downloads complete successfully.
+- Existing `/sdcard/data/course_001/*.mp3` files are replaced by the new `.part` files during commit.
+- Final ACK is success.
+
+---
+
+## Self-Review
+
+- Spec coverage: The plan covers manifest saving, asset validation, sequential downloads, overwrite behavior through existing commit helper, progress reporting, busy mutex, multi-MP3 upload UI, payload generation, and build/manual validation.
+- Placeholder scan: No TBD/TODO placeholders remain.
+- Type consistency: `BoardMqtt_HandleUpdateManifest(req_id, payload)` matches the dispatch update. Manifest asset/job structs use existing constants already available through `system.h`. Progress callback signature matches `board_download_progress_cb_t`.
diff --git a/xiaozhi-esp32-2.2.6/docs/superpowers/plans/2026-06-25-offline-command-word.md b/xiaozhi-esp32-2.2.6/docs/superpowers/plans/2026-06-25-offline-command-word.md
new file mode 100644
index 0000000..7bc8378
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/docs/superpowers/plans/2026-06-25-offline-command-word.md
@@ -0,0 +1,89 @@
+# Offline Command Word Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Make the board keep AFE offline wake word recognition while also enabling the offline command word `鍒囨崲妯″紡` in non-power-off states.
+
+**Architecture:** Keep `AfeWakeWord` as the primary ESP32-S3 wake-word implementation whenever a WakeNet model exists. Attach MultiNet command detection inside `AfeWakeWord`, and enable a Chinese MultiNet model in configuration so the command recognizer can initialize. The board command callback remains log-only per user confirmation.
+
+**Tech Stack:** ESP-IDF, ESP-SR WakeNet/MultiNet, C++ audio service, C board support code, Python static validation.
+
+## Global Constraints
+
+- Do not change the offline command behavior beyond log-only callback.
+- Preserve existing AFE wake-word behavior.
+- User will perform final firmware compilation.
+- Avoid broad refactors.
+
+---
+
+### Task 1: Static Validation for Offline Command Wiring
+
+**Files:**
+- Create: `tools/validate_offline_command_wiring.py`
+
+**Interfaces:**
+- Consumes: `main/audio/audio_service.cc`, `sdkconfig`, `sdkconfig.defaults.esp32s3`
+- Produces: a command-line validator that exits non-zero if AFE wake word is not preferred or Chinese MultiNet is not enabled.
+
+- [ ] **Step 1: Write the failing validation script**
+
+```python
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    audio_service = read_text("main/audio/audio_service.cc")
+    afe_pos = audio_service.find("wake_word_ = std::make_unique<AfeWakeWord>();")
+    custom_pos = audio_service.find("wake_word_ = std::make_unique<CustomWakeWord>();")
+    require(afe_pos != -1, "AfeWakeWord selection is missing")
+    require(custom_pos != -1, "CustomWakeWord fallback is missing")
+    require(afe_pos < custom_pos, "AfeWakeWord must be preferred before CustomWakeWord when WakeNet exists")
+
+    sdkconfig = read_text("sdkconfig")
+    defaults = read_text("sdkconfig.defaults.esp32s3")
+    multinet_enabled = "CONFIG_SR_MN_CN_MULTINET" in sdkconfig and "CONFIG_SR_MN_CN_NONE=y" not in sdkconfig
+    defaults_enabled = "CONFIG_SR_MN_CN_MULTINET" in defaults
+    require(multinet_enabled, "sdkconfig must enable a Chinese MultiNet model")
+    require(defaults_enabled, "sdkconfig.defaults.esp32s3 must enable a Chinese MultiNet model")
+
+
+if __name__ == "__main__":
+    main()
+```
+
+- [ ] **Step 2: Run validation to verify it fails**
+
+Run: `python tools/validate_offline_command_wiring.py`
+Expected: FAIL with either `AfeWakeWord must be preferred...` or `sdkconfig must enable...`.
+
+- [ ] **Step 3: Implement minimal wiring/config changes**
+
+Modify `main/audio/audio_service.cc` so `SetModelsList()` chooses `AfeWakeWord` first when WakeNet exists, then falls back to `CustomWakeWord` when only MultiNet exists.
+
+Modify `sdkconfig` and `sdkconfig.defaults.esp32s3` to enable a Chinese MultiNet model:
+
+```text
+CONFIG_SR_MN_CN_MULTINET7_QUANT=y
+# CONFIG_SR_MN_CN_NONE is not set
+```
+
+- [ ] **Step 4: Run validation to verify it passes**
+
+Run: `python tools/validate_offline_command_wiring.py`
+Expected: no output and exit code 0.
+
+- [ ] **Step 5: Optional compile check**
+
+User will run ESP-IDF build locally and report any errors.
diff --git a/xiaozhi-esp32-2.2.6/main/CMakeLists.txt b/xiaozhi-esp32-2.2.6/main/CMakeLists.txt
index 3d90620..c27455e 100644
--- a/xiaozhi-esp32-2.2.6/main/CMakeLists.txt
+++ b/xiaozhi-esp32-2.2.6/main/CMakeLists.txt
@@ -785,6 +785,8 @@
     set(BUILTIN_TEXT_FONT font_puhui_basic_20_4)
     set(BUILTIN_ICON_FONT font_awesome_20_4)
     set(DEFAULT_EMOJI_COLLECTION twemoji_64)
+elseif(CONFIG_BOARD_TYPE_SENING_XIAOZHI_ESP32S3)
+    set(BOARD_TYPE "sening-xiaozhi-s3")
 endif()
 
 if(MANUFACTURER)
@@ -982,6 +984,7 @@
                         efuse
                         bt
                         fatfs
+                        mqtt
                         ${MAIN_PRIV_REQUIRES_EXTRA}
                     )
 
diff --git a/xiaozhi-esp32-2.2.6/main/Kconfig.projbuild b/xiaozhi-esp32-2.2.6/main/Kconfig.projbuild
index e69337b..b2c86a7 100644
--- a/xiaozhi-esp32-2.2.6/main/Kconfig.projbuild
+++ b/xiaozhi-esp32-2.2.6/main/Kconfig.projbuild
@@ -548,6 +548,9 @@
     config BOARD_TYPE_AI_VOX3
         bool "NULLLAB-AI-VOX3"
         depends on IDF_TARGET_ESP32S3
+    config BOARD_TYPE_SENING_XIAOZHI_ESP32S3
+        bool "SENING_XIAOZHI_ESP32S3"
+        depends on IDF_TARGET_ESP32S3
 endchoice
 
 choice
diff --git a/xiaozhi-esp32-2.2.6/main/application.cc b/xiaozhi-esp32-2.2.6/main/application.cc
index 0a809fa..e6e0d7d 100644
--- a/xiaozhi-esp32-2.2.6/main/application.cc
+++ b/xiaozhi-esp32-2.2.6/main/application.cc
@@ -5,12 +5,15 @@
 #include "audio_codec.h"
 #include "mqtt_protocol.h"
 #include "websocket_protocol.h"
+#include "boards/sening-xiaozhi-s3/mqtt.h"
 #include "assets/lang_config.h"
 #include "mcp_server.h"
 #include "assets.h"
 #include "settings.h"
+#include "boards/sening-xiaozhi-s3/system.h"
 
 #include <cstring>
+#include <memory>
 #include <esp_log.h>
 #include <cJSON.h>
 #include <driver/gpio.h>
@@ -19,6 +22,59 @@
 
 #define TAG "Application"
 
+extern "C" void SDCard_Pause_Play(void) __attribute__((weak));
+extern "C" void SDCard_Resume_Play(void) __attribute__((weak));
+extern "C" void SDCard_Stop_Play(void) __attribute__((weak));
+extern "C" bool SDCard_Is_Playing(void) __attribute__((weak));
+extern "C" bool SDCard_Is_Paused(void) __attribute__((weak));
+
+static bool IsSdCardMp3Playing() {
+    return SDCard_Is_Playing && SDCard_Is_Playing();
+}
+
+static bool IsSdCardMp3Paused() {
+    return SDCard_Is_Paused && SDCard_Is_Paused();
+}
+
+static void PauseSdCardMp3() {
+    if (SDCard_Pause_Play) {
+        SDCard_Pause_Play();
+    }
+}
+
+static void ResumeSdCardMp3() {
+    if (SDCard_Resume_Play) {
+        SDCard_Resume_Play();
+    }
+}
+
+static void StopSdCardMp3() {
+    if (SDCard_Stop_Play) {
+        SDCard_Stop_Play();
+    }
+}
+
+static bool IsInternalToolSpeech(const char* text) {
+    if (!text) {
+        return false;
+    }
+    while (*text == ' ' || *text == '\t') {
+        ++text;
+    }
+    if (strncmp(text, "% self.", 7) == 0 || strncmp(text, "%self.", 6) == 0) {
+        return true;
+    }
+    return (strstr(text, "\"arguments\"") && strstr(text, "\"trigger_time\"")) || strstr(text, "\"label\"");
+}
+
+static bool IsStandbyRequestText(const char* text) {
+    if (!text) {
+        return false;
+    }
+    return strstr(text, "寰呭懡") || strstr(text, "杩涘叆寰呮満") || strstr(text, "杩涘叆寰呮満鐘舵��") ||
+        strstr(text, "杩涘叆浼戠湢") || strstr(text, "浼戠湢鐘舵��") || strstr(text, "鍋滄鐩戝惉") ||
+        strstr(text, "鍒惉浜�") || strstr(text, "涓嶈鍚簡");
+}
 
 Application::Application() {
     event_group_ = xEventGroupCreate();
@@ -60,11 +116,15 @@
 
 void Application::Initialize() {
     auto& board = Board::GetInstance();
+    ota_ = std::make_unique<Ota>();
+    ota_->MarkCurrentVersionValid();
+
     SetDeviceState(kDeviceStateStarting);
 
     // Setup the display
     auto display = board.GetDisplay();
     display->SetupUI();
+    ESP_LOGI(TAG, "Application firmware version: %s", APP_FIRMWARE_VERSION);
     // Print board name/version info
     display->SetChatMessage("system", SystemInfo::GetUserAgent().c_str());
 
@@ -78,7 +138,14 @@
         xEventGroupSetBits(event_group_, MAIN_EVENT_SEND_AUDIO);
     };
     callbacks.on_wake_word_detected = [this](const std::string& wake_word) {
-        xEventGroupSetBits(event_group_, MAIN_EVENT_WAKE_WORD_DETECTED);
+        if (!power_off_standby_active_.load()) {
+            xEventGroupSetBits(event_group_, MAIN_EVENT_WAKE_WORD_DETECTED);
+        }
+    };
+    callbacks.on_offline_command_detected = [this](const std::string& command) {
+        if (!power_off_standby_active_.load()) {
+            Board_Handle_Offline_Command(command.c_str());
+        }
     };
     callbacks.on_vad_change = [this](bool speaking) {
         xEventGroupSetBits(event_group_, MAIN_EVENT_VAD_CHANGE);
@@ -130,6 +197,8 @@
             case NetworkEvent::Disconnected:
                 xEventGroupSetBits(event_group_, MAIN_EVENT_NETWORK_DISCONNECTED);
                 break;
+            case NetworkEvent::ConnectionFailed:
+                break;
             case NetworkEvent::WifiConfigModeEnter:
                 // WiFi config mode enter is handled by WifiBoard internally
                 break;
@@ -154,6 +223,8 @@
                 break;
         }
     });
+
+    LoadLocalAssetsForWakeWord();
 
     // Start network asynchronously
     board.StartNetwork();
@@ -284,6 +355,11 @@
 }
 
 void Application::HandleNetworkDisconnectedEvent() {
+    auto& board = Board::GetInstance();
+    if (board.IsNetworkReady()) {
+        return;
+    }
+
     // Close current conversation when network disconnected
     auto state = GetDeviceState();
     if (state == kDeviceStateConnecting || state == kDeviceStateListening || state == kDeviceStateSpeaking) {
@@ -292,7 +368,7 @@
     }
 
     // Update the status bar immediately to show the network state
-    auto display = Board::GetInstance().GetDisplay();
+    auto display = board.GetDisplay();
     display->UpdateStatusBar(true);
 }
 
@@ -320,9 +396,19 @@
     });
 }
 
+void Application::LoadLocalAssetsForWakeWord() {
+    auto& assets = Assets::GetInstance();
+    if (!assets.partition_valid() || local_assets_applied_) {
+        return;
+    }
+
+    local_assets_applied_ = assets.Apply(false);
+}
+
 void Application::ActivationTask() {
-    // Create OTA object for activation process
-    ota_ = std::make_unique<Ota>();
+    if (!ota_) {
+        ota_ = std::make_unique<Ota>();
+    }
 
     // Check for new assets version
     CheckAssetsVersion();
@@ -372,7 +458,7 @@
 
         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);
+            snprintf(buffer, sizeof(buffer), "%d%% %uKB/s", progress, static_cast<unsigned>(speed / 1024));
             Schedule([display, message = std::string(buffer)]() {
                 display->SetChatMessage("system", message.c_str());
             });
@@ -387,10 +473,13 @@
             SetDeviceState(kDeviceStateActivating);
             return;
         }
+        local_assets_applied_ = false;
     }
 
     // Apply assets
-    assets.Apply();
+    if (!local_assets_applied_) {
+        local_assets_applied_ = assets.Apply();
+    }
     display->SetChatMessage("system", "");
     display->SetEmotion("microchip_ai");
 }
@@ -496,9 +585,20 @@
     });
     
     protocol_->OnIncomingAudio([this](std::unique_ptr<AudioStreamPacket> packet) {
-        if (GetDeviceState() == kDeviceStateSpeaking) {
-            audio_service_.PushPacketToDecodeQueue(std::move(packet));
-        }
+        auto shared_packet = std::shared_ptr<AudioStreamPacket>(packet.release());
+        Schedule([this, shared_packet]() {
+            auto state = GetDeviceState();
+            if (state != kDeviceStateSpeaking && !tts_audio_expected_) {
+                return;
+            }
+
+            tts_response_started_ = true;
+
+            ScheduleTtsAudio(shared_packet);
+            if (state == kDeviceStateListening) {
+                SetDeviceState(kDeviceStateSpeaking);
+            }
+        });
     });
     
     protocol_->OnAudioChannelOpened([this, codec, &board]() {
@@ -513,8 +613,34 @@
         board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER);
         Schedule([this]() {
             auto display = Board::GetInstance().GetDisplay();
+            opening_audio_channel_ = false;
+            tts_audio_expected_ = false;
+            tts_response_started_ = false;
+            tts_audio_received_ = false;
+            tts_stop_pending_ = false;
+            tts_stop_waiting_ = false;
+            tts_idle_waiting_ = false;
+            tts_speaking_state_ready_ = false;
+            tts_activity_generation_++;
+            audio_service_.ResetDecoder();
             display->SetChatMessage("system", "");
             SetDeviceState(kDeviceStateIdle);
+            if (power_off_requested_) {
+                power_off_requested_ = false;
+                resume_sdcard_mp3_after_tts_ = false;
+                resume_sdcard_mp3_after_voice_ = false;
+                Board_Enter_Power_Off();
+                return;
+            }
+            if (standby_requested_) {
+                standby_requested_ = false;
+            }
+            if (resume_sdcard_mp3_after_tts_ && !sdcard_mp3_stop_requested_) {
+                resume_sdcard_mp3_after_tts_ = false;
+                ResumeSdCardMp3();
+            } else {
+                resume_sdcard_mp3_after_tts_ = false;
+            }
         });
     });
     
@@ -526,23 +652,44 @@
             if (strcmp(state->valuestring, "start") == 0) {
                 Schedule([this]() {
                     aborted_ = false;
-                    SetDeviceState(kDeviceStateSpeaking);
+                    tts_audio_expected_ = true;
+                    tts_response_started_ = false;
+                    tts_audio_received_ = false;
+                    tts_activity_generation_++;
                 });
             } else if (strcmp(state->valuestring, "stop") == 0) {
                 Schedule([this]() {
-                    if (GetDeviceState() == kDeviceStateSpeaking) {
-                        if (listening_mode_ == kListeningModeManualStop) {
-                            SetDeviceState(kDeviceStateIdle);
-                        } else {
-                            SetDeviceState(kDeviceStateListening);
-                        }
+                    if (!tts_response_started_ && !standby_requested_ && !power_off_requested_) {
+                        tts_stop_pending_ = true;
+                        return;
                     }
+                    tts_stop_pending_ = true;
+                    FinishTtsResponseAfterPlayback();
                 });
             } else if (strcmp(state->valuestring, "sentence_start") == 0) {
                 auto text = cJSON_GetObjectItem(root, "text");
                 if (cJSON_IsString(text)) {
                     ESP_LOGI(TAG, "<< %s", text->valuestring);
-                    Schedule([display, message = std::string(text->valuestring)]() {
+                    if (IsInternalToolSpeech(text->valuestring)) {
+                        ESP_LOGW(TAG, "Ignore internal tool speech: %s", text->valuestring);
+                        Schedule([this]() {
+                            tts_audio_expected_ = false;
+                            tts_response_started_ = false;
+                        });
+                        return;
+                    }
+                    Schedule([this, display, message = std::string(text->valuestring)]() {
+                        tts_audio_expected_ = true;
+                        tts_response_started_ = true;
+                        tts_activity_generation_++;
+                        if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
+                            PauseSdCardMp3();
+                            resume_sdcard_mp3_after_tts_ = true;
+                        }
+                        if (GetDeviceState() == kDeviceStateListening) {
+                            SetDeviceState(kDeviceStateSpeaking);
+                        }
+                        ScheduleTtsIdleFallback();
                         display->SetChatMessage("assistant", message.c_str());
                     });
                 }
@@ -550,7 +697,15 @@
         } else if (strcmp(type->valuestring, "stt") == 0) {
             auto text = cJSON_GetObjectItem(root, "text");
             if (cJSON_IsString(text)) {
+                if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
+                    PauseSdCardMp3();
+                    resume_sdcard_mp3_after_tts_ = true;
+                }
                 ESP_LOGI(TAG, ">> %s", text->valuestring);
+                BoardMqtt_PublishSttEvent(text->valuestring);
+                if (IsStandbyRequestText(text->valuestring)) {
+                    EnterStandby();
+                }
                 Schedule([display, message = std::string(text->valuestring)]() {
                     display->SetChatMessage("user", message.c_str());
                 });
@@ -565,6 +720,23 @@
         } else if (strcmp(type->valuestring, "mcp") == 0) {
             auto payload = cJSON_GetObjectItem(root, "payload");
             if (cJSON_IsObject(payload)) {
+                auto method = cJSON_GetObjectItem(payload, "method");
+                auto params = cJSON_GetObjectItem(payload, "params");
+                auto name = cJSON_IsObject(params) ? cJSON_GetObjectItem(params, "name") : nullptr;
+                if (cJSON_IsString(method) && strcmp(method->valuestring, "tools/call") == 0 && cJSON_IsString(name)) {
+                    if (strcmp(name->valuestring, "self.mp3.stop") == 0) {
+                        sdcard_mp3_stop_requested_ = true;
+                        BoardMqtt_StopPlaylistPlayback();
+                        resume_sdcard_mp3_after_tts_ = false;
+                        resume_sdcard_mp3_after_voice_ = false;
+                    } else if (strcmp(name->valuestring, "self.mp3.play") == 0) {
+                        sdcard_mp3_stop_requested_ = false;
+                        if (GetDeviceState() == kDeviceStateSpeaking) {
+                            PauseSdCardMp3();
+                            resume_sdcard_mp3_after_tts_ = true;
+                        }
+                    }
+                }
                 McpServer::GetInstance().ParseMessage(payload);
             }
         } else if (strcmp(type->valuestring, "system") == 0) {
@@ -672,6 +844,11 @@
 }
 
 void Application::HandleToggleChatEvent() {
+    if (power_off_standby_active_.load()) {
+        ESP_LOGI(TAG, "Ignore toggle chat while power-off standby is active");
+        return;
+    }
+
     auto state = GetDeviceState();
     
     if (state == kDeviceStateActivating) {
@@ -711,21 +888,59 @@
 }
 
 void Application::ContinueOpenAudioChannel(ListeningMode mode) {
+    if (power_off_standby_active_.load()) {
+        ESP_LOGI(TAG, "Ignore open audio channel while power-off standby is active");
+        return;
+    }
+
     // Check state again in case it was changed during scheduling
     if (GetDeviceState() != kDeviceStateConnecting) {
         return;
     }
+    if (opening_audio_channel_) {
+        return;
+    }
 
+    opening_audio_channel_ = true;
     if (!protocol_->IsAudioChannelOpened()) {
+        if (!Board::GetInstance().EnsureNetworkReadyForWake(30000)) {
+            opening_audio_channel_ = false;
+            SetDeviceState(kDeviceStateIdle);
+            return;
+        }
         if (!protocol_->OpenAudioChannel()) {
+            opening_audio_channel_ = false;
+            SetDeviceState(kDeviceStateIdle);
             return;
         }
     }
+    opening_audio_channel_ = false;
 
     SetListeningMode(mode);
 }
 
+void Application::PauseSdCardMp3ForVoice() {
+    if (IsSdCardMp3Playing() && !IsSdCardMp3Paused()) {
+        PauseSdCardMp3();
+        resume_sdcard_mp3_after_voice_ = true;
+    }
+}
+
+void Application::ResumeSdCardMp3AfterVoice() {
+    if (resume_sdcard_mp3_after_voice_ && !sdcard_mp3_stop_requested_) {
+        resume_sdcard_mp3_after_voice_ = false;
+        ResumeSdCardMp3();
+    } else {
+        resume_sdcard_mp3_after_voice_ = false;
+    }
+}
+
 void Application::HandleStartListeningEvent() {
+    if (power_off_standby_active_.load()) {
+        ESP_LOGI(TAG, "Ignore start listening while power-off standby is active");
+        return;
+    }
+
     auto state = GetDeviceState();
     
     if (state == kDeviceStateActivating) {
@@ -744,6 +959,7 @@
     
     if (state == kDeviceStateIdle) {
         if (!protocol_->IsAudioChannelOpened()) {
+            PauseSdCardMp3ForVoice();
             SetDeviceState(kDeviceStateConnecting);
             // Schedule to let the state change be processed first (UI update)
             Schedule([this]() {
@@ -751,6 +967,7 @@
             });
             return;
         }
+        PauseSdCardMp3ForVoice();
         SetListeningMode(kListeningModeManualStop);
     } else if (state == kDeviceStateSpeaking) {
         AbortSpeaking(kAbortReasonNone);
@@ -774,6 +991,11 @@
 }
 
 void Application::HandleWakeWordDetectedEvent() {
+    if (power_off_standby_active_.load()) {
+        ESP_LOGI(TAG, "Ignore wake word while power-off standby is active");
+        return;
+    }
+
     if (!protocol_) {
         return;
     }
@@ -783,6 +1005,7 @@
     ESP_LOGI(TAG, "Wake word detected: %s (state: %d)", wake_word.c_str(), (int)state);
 
     if (state == kDeviceStateIdle) {
+        PauseSdCardMp3ForVoice();
         audio_service_.EncodeWakeWord();
         auto wake_word = audio_service_.GetLastWakeWord();
 
@@ -795,8 +1018,9 @@
             });
             return;
         }
-        // Channel already opened, continue directly
-        ContinueWakeWordInvoke(wake_word);
+        Schedule([this, wake_word]() {
+            ContinueWakeWordInvoke(wake_word);
+        });
     } else if (state == kDeviceStateSpeaking || state == kDeviceStateListening) {
         AbortSpeaking(kAbortReasonWakeWordDetected);
         // Clear send queue to avoid sending residues to server
@@ -820,23 +1044,44 @@
 }
 
 void Application::ContinueWakeWordInvoke(const std::string& wake_word) {
-    // Check state again in case it was changed during scheduling
-    if (GetDeviceState() != kDeviceStateConnecting) {
+    if (power_off_standby_active_.load()) {
+        ESP_LOGI(TAG, "Ignore wake word invoke while power-off standby is active");
         return;
     }
 
+    // Check state again in case it was changed during scheduling
+    auto state = GetDeviceState();
+    if (state != kDeviceStateConnecting && state != kDeviceStateIdle) {
+        return;
+    }
+    if (opening_audio_channel_) {
+        return;
+    }
+
+    opening_audio_channel_ = true;
+    if (!Board::GetInstance().EnsureNetworkReadyForWake(30000)) {
+        opening_audio_channel_ = false;
+        SetDeviceState(kDeviceStateIdle);
+        audio_service_.EnableWakeWordDetection(true);
+        return;
+    }
     if (!protocol_->IsAudioChannelOpened()) {
         if (!protocol_->OpenAudioChannel()) {
+            opening_audio_channel_ = false;
+            ResumeSdCardMp3AfterVoice();
             audio_service_.EnableWakeWordDetection(true);
             return;
         }
     }
+    opening_audio_channel_ = false;
 
     ESP_LOGI(TAG, "Wake word detected: %s", wake_word.c_str());
 #if CONFIG_SEND_WAKE_WORD_DATA
     // Encode and send the wake word data to the server
     while (auto packet = audio_service_.PopWakeWordPacket()) {
-        protocol_->SendAudio(std::move(packet));
+        if (!protocol_->SendAudio(std::move(packet))) {
+            break;
+        }
     }
     // Set the chat state to wake word detected
     protocol_->SendWakeWordDetected(wake_word);
@@ -857,10 +1102,29 @@
     auto display = board.GetDisplay();
     auto led = board.GetLed();
     led->OnStateChanged();
-    
+
+    if (power_off_standby_active_.load()) {
+        opening_audio_channel_ = false;
+        play_popup_on_listening_ = false;
+        audio_service_.EnableVoiceProcessing(false);
+        audio_service_.EnableWakeWordDetection(false);
+        audio_service_.ResetDecoder();
+        return;
+    }
+
     switch (new_state) {
         case kDeviceStateUnknown:
         case kDeviceStateIdle:
+            opening_audio_channel_ = false;
+            tts_audio_expected_ = false;
+            tts_response_started_ = false;
+            tts_audio_received_ = false;
+            tts_stop_pending_ = false;
+            tts_stop_waiting_ = false;
+            tts_idle_waiting_ = false;
+            tts_speaking_state_ready_ = false;
+            tts_activity_generation_++;
+            ResumeSdCardMp3AfterVoice();
             display->SetStatus(Lang::Strings::STANDBY);
             display->ClearChatMessages();  // Clear messages first
             display->SetEmotion("neutral"); // Then set emotion (wechat mode checks child count)
@@ -873,6 +1137,7 @@
             display->SetChatMessage("system", "");
             break;
         case kDeviceStateListening:
+            tts_speaking_state_ready_ = false;
             display->SetStatus(Lang::Strings::LISTENING);
             display->SetEmotion("neutral");
 
@@ -904,6 +1169,7 @@
             }
             break;
         case kDeviceStateSpeaking:
+            tts_speaking_state_ready_ = false;
             display->SetStatus(Lang::Strings::SPEAKING);
 
             if (listening_mode_ != kListeningModeRealtime) {
@@ -912,10 +1178,11 @@
                 audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());
             }
             audio_service_.ResetDecoder();
+            tts_speaking_state_ready_ = true;
             break;
         case kDeviceStateWifiConfiguring:
             audio_service_.EnableVoiceProcessing(false);
-            audio_service_.EnableWakeWordDetection(false);
+            audio_service_.EnableWakeWordDetection(true);
             break;
         default:
             // Do nothing
@@ -961,7 +1228,9 @@
     esp_restart();
 }
 
-bool Application::UpgradeFirmware(const std::string& url, const std::string& version) {
+bool Application::UpgradeFirmware(const std::string& url, const std::string& version,
+                                  std::function<void(int progress, size_t speed)> progress_callback,
+                                  std::function<void(bool success)> complete_callback) {
     auto& board = Board::GetInstance();
     auto display = board.GetDisplay();
 
@@ -978,6 +1247,7 @@
     Alert(Lang::Strings::OTA_UPGRADE, Lang::Strings::UPGRADING, "download", Lang::Sounds::OGG_UPGRADE);
     vTaskDelay(pdMS_TO_TICKS(3000));
 
+    AcquireSleepInhibit();
     SetDeviceState(kDeviceStateUpgrading);
 
     std::string message = std::string(Lang::Strings::NEW_VERSION) + version_info;
@@ -987,12 +1257,15 @@
     audio_service_.Stop();
     vTaskDelay(pdMS_TO_TICKS(1000));
 
-    bool upgrade_success = Ota::Upgrade(upgrade_url, [this, display](int progress, size_t speed) {
+    bool upgrade_success = Ota::Upgrade(upgrade_url, [this, display, progress_callback](int progress, size_t speed) {
         char buffer[32];
-        snprintf(buffer, sizeof(buffer), "%d%% %uKB/s", progress, speed / 1024);
+        snprintf(buffer, sizeof(buffer), "%d%% %uKB/s", progress, static_cast<unsigned>(speed / 1024));
         Schedule([display, message = std::string(buffer)]() {
             display->SetChatMessage("system", message.c_str());
         });
+        if (progress_callback) {
+            progress_callback(progress, speed);
+        }
     });
 
     if (!upgrade_success) {
@@ -1000,6 +1273,10 @@
         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
+        ReleaseSleepInhibit();
+        if (complete_callback) {
+            complete_callback(false);
+        }
         Alert(Lang::Strings::ERROR, Lang::Strings::UPGRADE_FAILED, "circle_xmark", Lang::Sounds::OGG_EXCLAMATION);
         vTaskDelay(pdMS_TO_TICKS(3000));
         return false;
@@ -1008,12 +1285,21 @@
         ESP_LOGI(TAG, "Firmware upgrade successful, rebooting...");
         display->SetChatMessage("system", "Upgrade successful, rebooting...");
         vTaskDelay(pdMS_TO_TICKS(1000)); // Brief pause to show message
+        ReleaseSleepInhibit();
+        if (complete_callback) {
+            complete_callback(true);
+        }
         Reboot();
         return true;
     }
 }
 
 void Application::WakeWordInvoke(const std::string& wake_word) {
+    if (power_off_standby_active_.load()) {
+        ESP_LOGI(TAG, "Ignore wake word invoke while power-off standby is active");
+        return;
+    }
+
     if (!protocol_) {
         return;
     }
@@ -1031,8 +1317,9 @@
             });
             return;
         }
-        // Channel already opened, continue directly
-        ContinueWakeWordInvoke(wake_word);
+        Schedule([this, wake_word]() {
+            ContinueWakeWordInvoke(wake_word);
+        });
     } else if (state == kDeviceStateSpeaking) {
         Schedule([this]() {
             AbortSpeaking(kAbortReasonNone);
@@ -1046,12 +1333,226 @@
     }
 }
 
+void Application::EnterStandby() {
+    Schedule([this]() {
+        standby_requested_ = true;
+        auto state = GetDeviceState();
+        if (state == kDeviceStateIdle) {
+            standby_requested_ = false;
+        } else if (state == kDeviceStateSpeaking && tts_stop_pending_) {
+            FinishTtsResponseAfterPlayback();
+        }
+    });
+}
+
+void Application::RequestPowerOffStandby() {
+    Schedule([this]() {
+        power_off_requested_ = true;
+        auto state = GetDeviceState();
+        if (state == kDeviceStateIdle) {
+            power_off_requested_ = false;
+            Board_Enter_Power_Off();
+        } else if (state == kDeviceStateSpeaking && tts_stop_pending_) {
+            FinishTtsResponseAfterPlayback();
+        }
+    });
+}
+
+void Application::EnterPowerOffStandby() {
+    power_off_standby_active_.store(true);
+    Schedule([this]() {
+        ESP_LOGI(TAG, "Entering power-off standby");
+
+        standby_requested_ = false;
+        power_off_requested_ = false;
+        opening_audio_channel_ = false;
+        play_popup_on_listening_ = false;
+        resume_sdcard_mp3_after_tts_ = false;
+        resume_sdcard_mp3_after_voice_ = false;
+        sdcard_mp3_stop_requested_ = false;
+        tts_audio_expected_ = false;
+        tts_response_started_ = false;
+        tts_audio_received_ = false;
+        tts_stop_pending_ = false;
+        tts_stop_waiting_ = false;
+        tts_idle_waiting_ = false;
+        tts_speaking_state_ready_ = false;
+        tts_activity_generation_++;
+
+        if (GetDeviceState() == kDeviceStateSpeaking) {
+            AbortSpeaking(kAbortReasonNone);
+        }
+        if (protocol_ && protocol_->IsAudioChannelOpened()) {
+            protocol_->CloseAudioChannel();
+        }
+
+        audio_service_.EnableVoiceProcessing(false);
+        audio_service_.EnableWakeWordDetection(false);
+        audio_service_.ResetDecoder();
+        while (audio_service_.PopPacketFromSendQueue()) {
+        }
+        BoardMqtt_StopPlaylistPlayback();
+
+        auto state = GetDeviceState();
+        if (state == kDeviceStateConnecting || state == kDeviceStateListening || state == kDeviceStateSpeaking) {
+            SetDeviceState(kDeviceStateIdle);
+        }
+    });
+}
+
+void Application::RestoreFromPowerOffStandby(bool network_ready) {
+    Schedule([this, network_ready]() {
+        ESP_LOGI(TAG, "Restoring from power-off standby, network_ready=%d", network_ready);
+
+        power_off_standby_active_.store(false);
+        standby_requested_ = false;
+        power_off_requested_ = false;
+        opening_audio_channel_ = false;
+        play_popup_on_listening_ = false;
+        tts_activity_generation_++;
+
+        audio_service_.EnableVoiceProcessing(false);
+        audio_service_.ResetDecoder();
+        if (GetDeviceState() != kDeviceStateIdle) {
+            SetDeviceState(kDeviceStateIdle);
+        }
+        audio_service_.EnableWakeWordDetection(true);
+        BoardMqtt_ResumePlaylistIfUnfinished();
+    });
+}
+
+void Application::AcquireSleepInhibit() {
+    std::lock_guard<std::mutex> lock(mutex_);
+    sleep_inhibit_count_++;
+}
+
+void Application::ReleaseSleepInhibit() {
+    std::lock_guard<std::mutex> lock(mutex_);
+    if (sleep_inhibit_count_ > 0) {
+        sleep_inhibit_count_--;
+    }
+}
+
+void Application::FinishTtsResponse() {
+    tts_audio_expected_ = false;
+    tts_response_started_ = false;
+    tts_audio_received_ = false;
+    tts_stop_pending_ = false;
+    tts_stop_waiting_ = false;
+    tts_idle_waiting_ = false;
+    tts_speaking_state_ready_ = false;
+    if (GetDeviceState() == kDeviceStateSpeaking) {
+        if (listening_mode_ == kListeningModeManualStop) {
+            SetDeviceState(kDeviceStateIdle);
+        } else {
+            SetDeviceState(kDeviceStateListening);
+        }
+    }
+    ResumeSdCardMp3AfterVoice();
+    if (resume_sdcard_mp3_after_tts_ && !sdcard_mp3_stop_requested_) {
+        resume_sdcard_mp3_after_tts_ = false;
+        ResumeSdCardMp3();
+    } else {
+        resume_sdcard_mp3_after_tts_ = false;
+    }
+    if (power_off_requested_) {
+        power_off_requested_ = false;
+        Board_Enter_Power_Off();
+        return;
+    }
+    if (standby_requested_) {
+        standby_requested_ = false;
+        SetDeviceState(kDeviceStateIdle);
+    }
+}
+
+void Application::ScheduleTtsAudio(std::shared_ptr<AudioStreamPacket> packet) {
+    Schedule([this, packet]() {
+        if (GetDeviceState() != kDeviceStateSpeaking || !tts_audio_expected_) {
+            return;
+        }
+        if (!tts_speaking_state_ready_) {
+            ScheduleTtsAudio(packet);
+            return;
+        }
+        tts_audio_received_ = true;
+        tts_activity_generation_++;
+        auto audio_packet = std::make_unique<AudioStreamPacket>(std::move(*packet));
+        audio_service_.PushPacketToDecodeQueue(std::move(audio_packet));
+        ScheduleTtsIdleFallback();
+        if (tts_stop_pending_) {
+            FinishTtsResponseAfterPlayback();
+        }
+    });
+}
+
+void Application::FinishTtsResponseAfterPlayback() {
+    if (tts_stop_waiting_) {
+        return;
+    }
+    if (tts_audio_expected_ && !tts_audio_received_) {
+        ScheduleTtsIdleFallback();
+        return;
+    }
+    tts_stop_waiting_ = true;
+    xTaskCreate([](void* arg) {
+        auto* app = static_cast<Application*>(arg);
+        uint32_t generation = app->tts_activity_generation_;
+        app->audio_service_.WaitForPlaybackQueueEmpty();
+        vTaskDelay(pdMS_TO_TICKS(800));
+        app->Schedule([app, generation]() {
+            if (app->tts_activity_generation_ == generation) {
+                app->FinishTtsResponse();
+            } else {
+                app->tts_stop_waiting_ = false;
+                if (app->GetDeviceState() == kDeviceStateSpeaking) {
+                    if (app->tts_stop_pending_) {
+                        app->FinishTtsResponseAfterPlayback();
+                    } else {
+                        app->ScheduleTtsIdleFallback();
+                    }
+                }
+            }
+        });
+        vTaskDelete(NULL);
+    }, "tts_finish", 4096, this, 2, NULL);
+}
+
+void Application::ScheduleTtsIdleFallback() {
+    if (tts_idle_waiting_ || tts_stop_waiting_) {
+        return;
+    }
+    tts_idle_waiting_ = true;
+    xTaskCreate([](void* arg) {
+        auto* app = static_cast<Application*>(arg);
+        uint32_t generation = app->tts_activity_generation_;
+        vTaskDelay(pdMS_TO_TICKS(1500));
+        app->Schedule([app, generation]() {
+            app->tts_idle_waiting_ = false;
+            if (app->tts_activity_generation_ != generation || app->GetDeviceState() != kDeviceStateSpeaking) {
+                return;
+            }
+            if (app->tts_audio_received_) {
+                app->FinishTtsResponseAfterPlayback();
+                return;
+            }
+            if (app->tts_stop_pending_ || app->tts_audio_expected_) {
+                app->FinishTtsResponse();
+            }
+        });
+        vTaskDelete(NULL);
+    }, "tts_idle", 4096, this, 2, NULL);
+}
+
 bool Application::CanEnterSleepMode() {
-    if (GetDeviceState() != kDeviceStateIdle) {
-        return false;
+    {
+        std::lock_guard<std::mutex> lock(mutex_);
+        if (sleep_inhibit_count_ > 0) {
+            return false;
+        }
     }
 
-    if (protocol_ && protocol_->IsAudioChannelOpened()) {
+    if (GetDeviceState() != kDeviceStateIdle) {
         return false;
     }
 
@@ -1063,10 +1564,16 @@
     return true;
 }
 
+void Application::PrepareProtocolForNetworkStandby() {
+    if (protocol_) {
+        protocol_->PrepareForNetworkStandby();
+    }
+}
+
 void Application::SendMcpMessage(const std::string& payload) {
     // Always schedule to run in main task for thread safety
     Schedule([this, payload = std::move(payload)]() {
-        if (protocol_) {
+        if (protocol_ && !power_off_standby_active_.load()) {
             protocol_->SendMcpMessage(payload);
         }
     });
diff --git a/xiaozhi-esp32-2.2.6/main/application.h b/xiaozhi-esp32-2.2.6/main/application.h
index 7ca7af4..1f72922 100644
--- a/xiaozhi-esp32-2.2.6/main/application.h
+++ b/xiaozhi-esp32-2.2.6/main/application.h
@@ -10,6 +10,8 @@
 #include <mutex>
 #include <deque>
 #include <memory>
+#include <functional>
+#include <atomic>
 
 #include "protocol.h"
 #include "ota.h"
@@ -105,8 +107,18 @@
 
     void Reboot();
     void WakeWordInvoke(const std::string& wake_word);
-    bool UpgradeFirmware(const std::string& url, const std::string& version = "");
+    bool UpgradeFirmware(const std::string& url, const std::string& version = "",
+                         std::function<void(int progress, size_t speed)> progress_callback = nullptr,
+                         std::function<void(bool success)> complete_callback = nullptr);
+    void EnterStandby();
+    void RequestPowerOffStandby();
+    void EnterPowerOffStandby();
+    void RestoreFromPowerOffStandby(bool network_ready);
+    bool IsPowerOffStandby() const { return power_off_standby_active_.load(); }
+    void AcquireSleepInhibit();
+    void ReleaseSleepInhibit();
     bool CanEnterSleepMode();
+    void PrepareProtocolForNetworkStandby();
     void SendMcpMessage(const std::string& payload);
     void SetAecMode(AecMode mode);
     AecMode GetAecMode() const { return aec_mode_; }
@@ -139,7 +151,24 @@
     bool has_server_time_ = false;
     bool aborted_ = false;
     bool assets_version_checked_ = false;
+    bool local_assets_applied_ = false;
     bool play_popup_on_listening_ = false;  // Flag to play popup sound after state changes to listening
+    bool resume_sdcard_mp3_after_tts_ = false;
+    bool resume_sdcard_mp3_after_voice_ = false;
+    bool sdcard_mp3_stop_requested_ = false;
+    bool opening_audio_channel_ = false;
+    bool tts_audio_expected_ = false;
+    bool tts_response_started_ = false;
+    bool tts_audio_received_ = false;
+    bool tts_stop_pending_ = false;
+    bool tts_stop_waiting_ = false;
+    bool tts_idle_waiting_ = false;
+    bool tts_speaking_state_ready_ = false;
+    uint32_t tts_activity_generation_ = 0;
+    bool standby_requested_ = false;
+    bool power_off_requested_ = false;
+    std::atomic_bool power_off_standby_active_{false};
+    int sleep_inhibit_count_ = 0;
     int clock_ticks_ = 0;
     TaskHandle_t activation_task_handle_ = nullptr;
 
@@ -160,10 +189,17 @@
     void ActivationTask();
 
     // Helper methods
+    void LoadLocalAssetsForWakeWord();
     void CheckAssetsVersion();
     void CheckNewVersion();
     void InitializeProtocol();
     void ShowActivationCode(const std::string& code, const std::string& message);
+    void FinishTtsResponse();
+    void FinishTtsResponseAfterPlayback();
+    void ScheduleTtsAudio(std::shared_ptr<AudioStreamPacket> packet);
+    void ScheduleTtsIdleFallback();
+    void PauseSdCardMp3ForVoice();
+    void ResumeSdCardMp3AfterVoice();
     void SetListeningMode(ListeningMode mode);
     ListeningMode GetDefaultListeningMode() const;
     
diff --git a/xiaozhi-esp32-2.2.6/main/audio/audio_service.cc b/xiaozhi-esp32-2.2.6/main/audio/audio_service.cc
index 350ac58..3d47ef3 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/audio_service.cc
+++ b/xiaozhi-esp32-2.2.6/main/audio/audio_service.cc
@@ -701,10 +701,10 @@
     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) {
+    if (esp_srmodel_filter(models_list_, ESP_WN_PREFIX, NULL) != nullptr) {
         wake_word_ = std::make_unique<AfeWakeWord>();
+    } else if (esp_srmodel_filter(models_list_, ESP_MN_PREFIX, NULL) != nullptr) {
+        wake_word_ = std::make_unique<CustomWakeWord>();
     } else {
         wake_word_ = nullptr;
     }
@@ -722,6 +722,11 @@
                 callbacks_.on_wake_word_detected(wake_word);
             }
         });
+        wake_word_->OnOfflineCommandDetected([this](const std::string& command) {
+            if (callbacks_.on_offline_command_detected) {
+                callbacks_.on_offline_command_detected(command);
+            }
+        });
     }
 }
 
diff --git a/xiaozhi-esp32-2.2.6/main/audio/audio_service.h b/xiaozhi-esp32-2.2.6/main/audio/audio_service.h
index 99db546..3732ee4 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/audio_service.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/audio_service.h
@@ -78,6 +78,7 @@
 struct AudioServiceCallbacks {
     std::function<void(void)> on_send_queue_available;
     std::function<void(const std::string&)> on_wake_word_detected;
+    std::function<void(const std::string&)> on_offline_command_detected;
     std::function<void(bool)> on_vad_change;
     std::function<void(void)> on_audio_testing_queue_full;
 };
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_word.h b/xiaozhi-esp32-2.2.6/main/audio/wake_word.h
index 9b8986a..a2daebc 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_word.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_word.h
@@ -15,6 +15,7 @@
     virtual bool Initialize(AudioCodec* codec, srmodel_list_t* models_list) = 0;
     virtual void Feed(const std::vector<int16_t>& data) = 0;
     virtual void OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) = 0;
+    virtual void OnOfflineCommandDetected(std::function<void(const std::string& command)> callback) = 0;
     virtual void Start() = 0;
     virtual void Stop() = 0;
     virtual size_t GetFeedSize() = 0;
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc b/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc
index 8fc5fef..bfae615 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.cc
@@ -1,6 +1,9 @@
 #include "afe_wake_word.h"
 #include "audio_service.h"
+#include "boards/sening-xiaozhi-s3/system.h"
 #include <esp_log.h>
+#include <esp_mn_models.h>
+#include <esp_mn_speech_commands.h>
 #include <sstream>
 
 #define DETECTION_RUNNING_EVENT 1
@@ -18,6 +21,9 @@
 AfeWakeWord::~AfeWakeWord() {
     if (afe_data_ != nullptr) {
         afe_iface_->destroy(afe_data_);
+    }
+    if (multinet_data_ != nullptr && multinet_ != nullptr) {
+        multinet_->destroy(multinet_data_);
     }
 
     if (wake_word_encode_task_stack_ != nullptr) {
@@ -63,6 +69,22 @@
         }
     }
 
+    multinet_model_ = esp_srmodel_filter(models_, ESP_MN_PREFIX, "cn");
+    if (multinet_model_ == nullptr) {
+        multinet_model_ = esp_srmodel_filter(models_, ESP_MN_PREFIX, NULL);
+    }
+    if (multinet_model_ != nullptr) {
+        multinet_ = esp_mn_handle_from_name(multinet_model_);
+        multinet_data_ = multinet_->create(multinet_model_, 3000);
+        multinet_->set_det_threshold(multinet_data_, 0.2f);
+        esp_mn_commands_clear();
+        esp_mn_commands_add(1, OFFLINE_COMMAND_WORD_PINYIN);
+        esp_mn_commands_update();
+        multinet_->print_active_speech_commands(multinet_data_);
+    } else {
+        ESP_LOGW(TAG, "MultiNet model not found, offline command word disabled");
+    }
+
     std::string input_format;
     for (int i = 0; i < codec_->input_channels() - ref_num; i++) {
         input_format.push_back('M');
@@ -93,6 +115,10 @@
     wake_word_detected_callback_ = callback;
 }
 
+void AfeWakeWord::OnOfflineCommandDetected(std::function<void(const std::string& command)> callback) {
+    offline_command_detected_callback_ = callback;
+}
+
 void AfeWakeWord::Start() {
     xEventGroupSetBits(event_group_, DETECTION_RUNNING_EVENT);
 }
@@ -104,7 +130,11 @@
     if (afe_data_ != nullptr) {
         afe_iface_->reset_buffer(afe_data_);
     }
+    if (multinet_data_ != nullptr) {
+        multinet_->clean(multinet_data_);
+    }
     input_buffer_.clear();
+    multinet_input_buffer_.clear();
 }
 
 void AfeWakeWord::Feed(const std::vector<int16_t>& data) {
@@ -149,6 +179,32 @@
         // Store the wake word data for voice recognition, like who is speaking
         StoreWakeWordData(res->data, res->data_size / sizeof(int16_t));
 
+        if (multinet_data_ != nullptr) {
+            multinet_input_buffer_.insert(multinet_input_buffer_.end(), res->data, res->data + res->data_size / sizeof(int16_t));
+            int chunksize = multinet_->get_samp_chunksize(multinet_data_);
+            while (multinet_input_buffer_.size() >= chunksize) {
+                esp_mn_state_t mn_state = multinet_->detect(multinet_data_, multinet_input_buffer_.data());
+                if (mn_state == ESP_MN_STATE_DETECTED) {
+                    esp_mn_results_t *mn_result = multinet_->get_results(multinet_data_);
+                    for (int i = 0; i < mn_result->num; i++) {
+                        ESP_LOGI(TAG, "Offline command detected: command_id=%d, string=%s, prob=%f",
+                            mn_result->command_id[i], mn_result->string, mn_result->prob[i]);
+                        if (mn_result->command_id[i] == 1 && offline_command_detected_callback_) {
+                            offline_command_detected_callback_(OFFLINE_COMMAND_WORD);
+                        }
+                    }
+                    multinet_->clean(multinet_data_);
+                    multinet_input_buffer_.clear();
+                    break;
+                } else if (mn_state == ESP_MN_STATE_TIMEOUT) {
+                    multinet_->clean(multinet_data_);
+                    multinet_input_buffer_.clear();
+                    break;
+                }
+                multinet_input_buffer_.erase(multinet_input_buffer_.begin(), multinet_input_buffer_.begin() + chunksize);
+            }
+        }
+
         if (res->wakeup_state == WAKENET_DETECTED) {
             Stop();
             last_detected_wake_word_ = wake_words_[res->wakenet_model_index - 1];
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h b/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h
index 6c2bf72..1e641ad 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_words/afe_wake_word.h
@@ -6,6 +6,7 @@
 #include <freertos/event_groups.h>
 
 #include <esp_afe_sr_models.h>
+#include <esp_mn_iface.h>
 #include <esp_nsn_models.h>
 #include <model_path.h>
 
@@ -27,6 +28,7 @@
     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 OnOfflineCommandDetected(std::function<void(const std::string& command)> callback);
     void Start();
     void Stop();
     size_t GetFeedSize();
@@ -39,12 +41,17 @@
     const esp_afe_sr_iface_t* afe_iface_ = nullptr;
     esp_afe_sr_data_t* afe_data_ = nullptr;
     char* wakenet_model_ = NULL;
+    char* multinet_model_ = NULL;
+    esp_mn_iface_t* multinet_ = nullptr;
+    model_iface_data_t* multinet_data_ = nullptr;
     std::vector<std::string> wake_words_;
     EventGroupHandle_t event_group_;
     std::function<void(const std::string& wake_word)> wake_word_detected_callback_;
+    std::function<void(const std::string& command)> offline_command_detected_callback_;
     AudioCodec* codec_ = nullptr;
     std::string last_detected_wake_word_;
     std::vector<int16_t> input_buffer_;
+    std::vector<int16_t> multinet_input_buffer_;
     std::mutex input_buffer_mutex_;
 
     TaskHandle_t wake_word_encode_task_ = nullptr;
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc b/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc
index 982bbe9..129b63d 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.cc
@@ -2,6 +2,7 @@
 #include "audio_service.h"
 #include "system_info.h"
 #include "assets.h"
+#include "boards/sening-xiaozhi-s3/system.h"
 
 #include <esp_log.h>
 #include <esp_mn_iface.h>
@@ -89,13 +90,29 @@
     if (models_list == nullptr) {
         language_ = "cn";
         models_ = esp_srmodel_init("model");
-#ifdef CONFIG_CUSTOM_WAKE_WORD
+#if defined(BOARD_CUSTOM_WAKE_WORD_PINYIN) && defined(BOARD_CUSTOM_WAKE_WORD_DISPLAY) && defined(BOARD_CUSTOM_WAKE_WORD_THRESHOLD_PERCENT)
+        threshold_ = BOARD_CUSTOM_WAKE_WORD_THRESHOLD_PERCENT / 100.0f;
+        commands_.push_back({BOARD_CUSTOM_WAKE_WORD_PINYIN, BOARD_CUSTOM_WAKE_WORD_DISPLAY, "wake"});
+#elif defined(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"});
 #endif
+        commands_.push_back({OFFLINE_COMMAND_WORD_PINYIN, OFFLINE_COMMAND_WORD, "offline_command"});
     } else {
         models_ = models_list;
         ParseWakenetModelConfig();
+#if defined(BOARD_CUSTOM_WAKE_WORD_PINYIN) && defined(BOARD_CUSTOM_WAKE_WORD_DISPLAY) && defined(BOARD_CUSTOM_WAKE_WORD_THRESHOLD_PERCENT)
+        threshold_ = BOARD_CUSTOM_WAKE_WORD_THRESHOLD_PERCENT / 100.0f;
+        for (auto it = commands_.begin(); it != commands_.end();) {
+            if (it->action == "wake") {
+                it = commands_.erase(it);
+            } else {
+                ++it;
+            }
+        }
+        commands_.push_back({BOARD_CUSTOM_WAKE_WORD_PINYIN, BOARD_CUSTOM_WAKE_WORD_DISPLAY, "wake"});
+#endif
+        commands_.push_back({OFFLINE_COMMAND_WORD_PINYIN, OFFLINE_COMMAND_WORD, "offline_command"});
     }
 
     if (models_ == nullptr || models_->num == -1) {
@@ -130,6 +147,10 @@
 
 void CustomWakeWord::OnWakeWordDetected(std::function<void(const std::string& wake_word)> callback) {
     wake_word_detected_callback_ = callback;
+}
+
+void CustomWakeWord::OnOfflineCommandDetected(std::function<void(const std::string& command)> callback) {
+    offline_command_detected_callback_ = callback;
 }
 
 void CustomWakeWord::Start() {
@@ -180,10 +201,14 @@
                     last_detected_wake_word_ = command.text;
                     running_ = false;
                     input_buffer_.clear();
-                    
+
                     if (wake_word_detected_callback_) {
                         wake_word_detected_callback_(last_detected_wake_word_);
                     }
+                } else if (command.action == "offline_command") {
+                    if (offline_command_detected_callback_) {
+                        offline_command_detected_callback_(command.text);
+                    }
                 }
             }
             multinet_->clean(multinet_model_data_);
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h b/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h
index 645ad1b..c26c6f3 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_words/custom_wake_word.h
@@ -25,6 +25,7 @@
     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 OnOfflineCommandDetected(std::function<void(const std::string& command)> callback);
     void Start();
     void Stop();
     size_t GetFeedSize();
@@ -50,6 +51,7 @@
     std::deque<Command> commands_;
  
     std::function<void(const std::string& wake_word)> wake_word_detected_callback_;
+    std::function<void(const std::string& command)> offline_command_detected_callback_;
     AudioCodec* codec_ = nullptr;
     std::string last_detected_wake_word_;
     std::atomic<bool> running_ = false;
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_words/esp_wake_word.cc b/xiaozhi-esp32-2.2.6/main/audio/wake_words/esp_wake_word.cc
index 930aa2d..0a0edc1 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_words/esp_wake_word.cc
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_words/esp_wake_word.cc
@@ -48,6 +48,10 @@
     wake_word_detected_callback_ = callback;
 }
 
+void EspWakeWord::OnOfflineCommandDetected(std::function<void(const std::string& command)> callback) {
+    offline_command_detected_callback_ = callback;
+}
+
 void EspWakeWord::Start() {
     running_ = true;
 }
diff --git a/xiaozhi-esp32-2.2.6/main/audio/wake_words/esp_wake_word.h b/xiaozhi-esp32-2.2.6/main/audio/wake_words/esp_wake_word.h
index 87e792d..a8c8d1d 100644
--- a/xiaozhi-esp32-2.2.6/main/audio/wake_words/esp_wake_word.h
+++ b/xiaozhi-esp32-2.2.6/main/audio/wake_words/esp_wake_word.h
@@ -22,6 +22,7 @@
     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 OnOfflineCommandDetected(std::function<void(const std::string& command)> callback);
     void Start();
     void Stop();
     size_t GetFeedSize();
@@ -37,6 +38,7 @@
     std::atomic<bool> running_ = false;
 
     std::function<void(const std::string& wake_word)> wake_word_detected_callback_;
+    std::function<void(const std::string& command)> offline_command_detected_callback_;
     std::string last_detected_wake_word_;
     std::vector<int16_t> input_buffer_;
     std::mutex input_buffer_mutex_;
diff --git a/xiaozhi-esp32-2.2.6/main/boards/common/board.h b/xiaozhi-esp32-2.2.6/main/boards/common/board.h
index 19d3f2f..c76470d 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/common/board.h
+++ b/xiaozhi-esp32-2.2.6/main/boards/common/board.h
@@ -22,6 +22,7 @@
     Connecting,            // Network is connecting (data: SSID/network name)
     Connected,             // Network connected successfully (data: SSID/network name)
     Disconnected,          // Network disconnected
+    ConnectionFailed,      // Saved networks failed before first connection
     WifiConfigModeEnter,   // Entered WiFi configuration mode
     WifiConfigModeExit,    // Exited WiFi configuration mode
     // Cellular modem specific events
@@ -76,6 +77,8 @@
     virtual NetworkInterface* GetNetwork() = 0;
     virtual void StartNetwork() = 0;
     virtual void SetNetworkEventCallback(NetworkEventCallback callback) { (void)callback; }
+    virtual bool EnsureNetworkReadyForWake(int timeout_ms) { (void)timeout_ms; return true; }
+    virtual bool IsNetworkReady() { return true; }
     virtual const char* GetNetworkStateIcon() = 0;
     virtual bool GetBatteryLevel(int &level, bool& charging, bool& discharging);
     virtual std::string GetSystemInfoJson();
diff --git a/xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.cc b/xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.cc
index 377685b..7a70d39 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.cc
+++ b/xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.cc
@@ -73,6 +73,21 @@
             case WifiEvent::Disconnected:
                 OnNetworkEvent(NetworkEvent::Disconnected);
                 break;
+            case WifiEvent::ConnectionFailed:
+                OnNetworkEvent(NetworkEvent::ConnectionFailed);
+                if (suppress_config_mode_on_connection_failed_) {
+                    ESP_LOGW(TAG, "All saved WiFi networks failed, staying offline");
+                    break;
+                }
+                ESP_LOGW(TAG, "All saved WiFi networks failed, entering config mode");
+                esp_timer_stop(connect_timer_);
+                xTaskCreate([](void* arg) {
+                    auto* board = static_cast<WifiBoard*>(arg);
+                    WifiManager::GetInstance().StopStation();
+                    board->StartWifiConfigMode();
+                    vTaskDelete(NULL);
+                }, "wifi_conn_failed", 4096, this, 2, NULL);
+                break;
             case WifiEvent::ConfigModeEnter:
                 OnNetworkEvent(NetworkEvent::WifiConfigModeEnter);
                 break;
@@ -124,6 +139,9 @@
         case NetworkEvent::Disconnected:
             ESP_LOGW(TAG, "WiFi disconnected");
             break;
+        case NetworkEvent::ConnectionFailed:
+            ESP_LOGW(TAG, "WiFi connection failed");
+            break;
         case NetworkEvent::WifiConfigModeEnter:
             ESP_LOGI(TAG, "WiFi config mode entered");
             in_config_mode_ = true;
@@ -148,6 +166,10 @@
     network_event_callback_ = std::move(callback);
 }
 
+void WifiBoard::SetSuppressConfigModeOnConnectionFailed(bool suppress) {
+    suppress_config_mode_on_connection_failed_ = suppress;
+}
+
 void WifiBoard::OnWifiConnectTimeout(void* arg) {
     auto* board = static_cast<WifiBoard*>(arg);
     ESP_LOGW(TAG, "WiFi connection timeout, entering config mode");
diff --git a/xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.h b/xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.h
index 7ee973c..1f75d8f 100644
--- a/xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.h
+++ b/xiaozhi-esp32-2.2.6/main/boards/common/wifi_board.h
@@ -10,8 +10,11 @@
 protected:
     esp_timer_handle_t connect_timer_ = nullptr;
     bool in_config_mode_ = false;
+    bool suppress_config_mode_on_connection_failed_ = false;
     NetworkEventCallback network_event_callback_ = nullptr;
 
+    void SetSuppressConfigModeOnConnectionFailed(bool suppress);
+
     virtual std::string GetBoardJson() override;
 
     /**
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/README.md b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/README.md
new file mode 100644
index 0000000..4a05049
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/README.md
@@ -0,0 +1 @@
+# 灏忔櫤 AI 瀹氬埗椤圭洰
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/config.h b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/config.h
new file mode 100644
index 0000000..2a5249a
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/config.h
@@ -0,0 +1,52 @@
+#ifndef _BOARD_CONFIG_H_
+#define _BOARD_CONFIG_H_
+
+// Movecall Moji configuration
+
+#include <driver/gpio.h>
+
+#define AUDIO_INPUT_SAMPLE_RATE  24000
+#define AUDIO_OUTPUT_SAMPLE_RATE 24000
+
+#define AUDIO_I2S_GPIO_MCLK         GPIO_NUM_6
+#define AUDIO_I2S_GPIO_WS           GPIO_NUM_12
+#define AUDIO_I2S_GPIO_BCLK         GPIO_NUM_14
+#define AUDIO_I2S_GPIO_DIN          GPIO_NUM_13
+#define AUDIO_I2S_GPIO_DOUT         GPIO_NUM_11
+
+#define AUDIO_CODEC_PA_PIN          GPIO_NUM_9
+#define AUDIO_CODEC_I2C_SDA_PIN     GPIO_NUM_5
+#define AUDIO_CODEC_I2C_SCL_PIN     GPIO_NUM_4
+#define AUDIO_CODEC_ES8311_ADDR  ES8311_CODEC_DEFAULT_ADDR
+
+#define RESET_BUTTON_GPIO           GPIO_NUM_1
+#define BOOT_BUTTON_GPIO            GPIO_NUM_0
+
+#define RESET_BUTTON_GPIO           GPIO_NUM_1
+#define BOOT_BUTTON_GPIO            GPIO_NUM_0
+
+#define SD_CARD_CMD_PIN             GPIO_NUM_41
+#define SD_CARD_CLK_PIN             GPIO_NUM_40
+#define SD_CARD_D0_PIN              GPIO_NUM_39
+#define SD_CARD_D1_PIN              GPIO_NUM_38
+#define SD_CARD_D2_PIN              GPIO_NUM_2
+#define SD_CARD_D3_PIN              GPIO_NUM_42
+
+#define SCREEN_WIDTH                160
+#define SCREEN_HEIGHT               160
+#define SCREEN_DC_PIN               GPIO_NUM_7
+#define SCREEN_CS_PIN               GPIO_NUM_15
+#define SCREEN_SCK_PIN              GPIO_NUM_16
+#define SCREEN_MOSI_PIN             GPIO_NUM_17
+#define SCREEN_RESET_PIN            GPIO_NUM_18
+#define SCREEN_BL_PIN               GPIO_NUM_3
+
+#define SCREEN_PI_SCLK_HZ     (40 * 1000 * 1000)
+
+#define RTC_I2C_SCL_PIN             GPIO_NUM_21
+#define RTC_I2C_SDA_PIN             GPIO_NUM_47
+
+#define ESP_BAT_ADC                 GPIO_NUM_10
+#define ESP_BAT_CHRG                GPIO_NUM_46
+
+#endif // _BOARD_CONFIG_H_
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/config.json b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/config.json
new file mode 100644
index 0000000..c62a8fd
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/config.json
@@ -0,0 +1,9 @@
+{
+    "target": "esp32s3",
+    "builds": [
+        {
+            "name": "movecall-moji-esp32s3",
+            "sdkconfig_append": []
+        }
+    ]
+}
\ No newline at end of file
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc
new file mode 100644
index 0000000..0cf4cf0
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mp3_player.cc
@@ -0,0 +1,380 @@
+#include "system.h"
+
+#include "audio_codec.h"
+#include "board.h"
+
+#include <esp_log.h>
+#include <freertos/FreeRTOS.h>
+#include <freertos/task.h>
+
+#include <esp_audio_simple_dec.h>
+#include <esp_audio_simple_dec_default.h>
+#include <esp_audio_dec_default.h>
+#include <esp_ae_rate_cvt.h>
+
+#include <algorithm>
+#include <cstdio>
+#include <cstdlib>
+#include <cstring>
+#include <vector>
+
+#define TAG "sdcard_mp3"
+#define MP3_READ_BUFFER_SIZE 512
+#define MP3_OUTPUT_BUFFER_SIZE 4096
+#define MP3_PLAYER_STACK_SIZE 8192
+#define MP3_PLAYER_PRIORITY 5
+
+typedef struct {
+    char path[SDCARD_PATH_MAX_LEN];
+    float speed;
+} mp3_player_task_arg_t;
+
+static TaskHandle_t mp3_player_task_handle;
+static volatile bool mp3_stop_requested;
+static volatile bool mp3_pause_requested;
+
+static std::vector<int16_t> convert_channels(const int16_t *input, size_t frame_count, uint8_t src_channels, uint8_t dst_channels)
+{
+    std::vector<int16_t> output(frame_count * dst_channels);
+    if (src_channels == dst_channels) {
+        std::memcpy(output.data(), input, output.size() * sizeof(int16_t));
+        return output;
+    }
+
+    for (size_t frame = 0; frame < frame_count; ++frame) {
+        if (dst_channels == 1) {
+            int32_t mixed = 0;
+            for (uint8_t ch = 0; ch < src_channels; ++ch) {
+                mixed += input[frame * src_channels + ch];
+            }
+            output[frame] = static_cast<int16_t>(mixed / src_channels);
+        } else if (src_channels == 1) {
+            for (uint8_t ch = 0; ch < dst_channels; ++ch) {
+                output[frame * dst_channels + ch] = input[frame];
+            }
+        } else {
+            for (uint8_t ch = 0; ch < dst_channels; ++ch) {
+                output[frame * dst_channels + ch] = input[frame * src_channels + std::min<uint8_t>(ch, src_channels - 1)];
+            }
+        }
+    }
+
+    return output;
+}
+
+static bool is_supported_speed(float speed)
+{
+    const float supported_speeds[] = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f};
+    for (size_t i = 0; i < sizeof(supported_speeds) / sizeof(supported_speeds[0]); ++i) {
+        float diff = speed - supported_speeds[i];
+        if (diff < 0) {
+            diff = -diff;
+        }
+        if (diff < 0.001f) {
+            return true;
+        }
+    }
+    return false;
+}
+
+static bool wait_if_paused()
+{
+    while (mp3_pause_requested && !mp3_stop_requested) {
+        vTaskDelay(pdMS_TO_TICKS(20));
+    }
+    return !mp3_stop_requested;
+}
+
+static bool output_pcm(AudioCodec *codec, esp_ae_rate_cvt_handle_t rate_cvt_handle, const uint8_t *buffer,
+    uint32_t size, uint8_t src_channels, uint32_t src_rate)
+{
+    uint8_t dst_channels = codec->output_channels();
+    uint32_t dst_rate = codec->output_sample_rate();
+    size_t frame_count = size / (sizeof(int16_t) * src_channels);
+    if (frame_count == 0) {
+        return true;
+    }
+
+    auto channel_converted = convert_channels(reinterpret_cast<const int16_t *>(buffer), frame_count, src_channels, dst_channels);
+    if (src_rate == dst_rate || !rate_cvt_handle) {
+        codec->OutputData(channel_converted);
+        return true;
+    }
+
+    uint32_t max_out_frames = 0;
+    if (esp_ae_rate_cvt_get_max_out_sample_num(rate_cvt_handle, frame_count, &max_out_frames) != ESP_AE_ERR_OK) {
+        ESP_LOGE(TAG, "get resample output size failed");
+        return false;
+    }
+
+    std::vector<int16_t> resampled(max_out_frames * dst_channels);
+    uint32_t actual_out_frames = max_out_frames;
+    if (esp_ae_rate_cvt_process(rate_cvt_handle, reinterpret_cast<esp_ae_sample_t>(channel_converted.data()), frame_count,
+            reinterpret_cast<esp_ae_sample_t>(resampled.data()), &actual_out_frames) != ESP_AE_ERR_OK) {
+        ESP_LOGE(TAG, "resample MP3 PCM failed");
+        return false;
+    }
+
+    resampled.resize(actual_out_frames * dst_channels);
+    codec->OutputData(resampled);
+    return true;
+}
+
+static void mp3_player_task(void *arg)
+{
+    mp3_player_task_handle = xTaskGetCurrentTaskHandle();
+    mp3_player_task_arg_t *task_arg = static_cast<mp3_player_task_arg_t *>(arg);
+    char path[SDCARD_PATH_MAX_LEN];
+    strlcpy(path, task_arg->path, sizeof(path));
+    float speed = task_arg->speed;
+    free(task_arg);
+
+    FILE *fp = fopen(path, "rb");
+    if (!fp) {
+        ESP_LOGE(TAG, "open MP3 failed: %s", path);
+        mp3_player_task_handle = nullptr;
+        mp3_stop_requested = false;
+        vTaskDelete(nullptr);
+        return;
+    }
+
+    auto codec = Board::GetInstance().GetAudioCodec();
+    if (!codec) {
+        ESP_LOGE(TAG, "audio codec unavailable");
+        fclose(fp);
+        mp3_player_task_handle = nullptr;
+        mp3_stop_requested = false;
+        vTaskDelete(nullptr);
+        return;
+    }
+
+    esp_audio_dec_register_default();
+    esp_audio_simple_dec_register_default();
+
+    esp_audio_simple_dec_handle_t decoder = nullptr;
+    esp_audio_simple_dec_cfg_t dec_cfg = {
+        .dec_type = ESP_AUDIO_SIMPLE_DEC_TYPE_MP3,
+        .dec_cfg = nullptr,
+        .cfg_size = 0,
+        .use_frame_dec = false,
+    };
+
+    esp_audio_err_t ret = esp_audio_simple_dec_open(&dec_cfg, &decoder);
+    if (ret != ESP_AUDIO_ERR_OK) {
+        ESP_LOGE(TAG, "open MP3 decoder failed: %d", ret);
+        esp_audio_simple_dec_unregister_default();
+        esp_audio_dec_unregister_default();
+        fclose(fp);
+        mp3_player_task_handle = nullptr;
+        mp3_stop_requested = false;
+        vTaskDelete(nullptr);
+        return;
+    }
+
+    uint8_t *in_buf = static_cast<uint8_t *>(malloc(MP3_READ_BUFFER_SIZE));
+    uint8_t *out_buf = static_cast<uint8_t *>(malloc(MP3_OUTPUT_BUFFER_SIZE));
+    uint32_t out_buf_size = MP3_OUTPUT_BUFFER_SIZE;
+    bool output_enabled_before = codec->output_enabled();
+    bool audio_info_checked = false;
+    uint32_t mp3_sample_rate = 0;
+    uint8_t mp3_channels = 0;
+    esp_ae_rate_cvt_handle_t rate_cvt_handle = nullptr;
+
+    if (!in_buf || !out_buf) {
+        ESP_LOGE(TAG, "no memory for MP3 playback");
+        ret = ESP_AUDIO_ERR_MEM_LACK;
+    } else {
+        ESP_LOGI(TAG, "play MP3: %s, speed: %.2fx", path, speed);
+
+        if (wait_if_paused()) {
+            codec->EnableOutput(true);
+        }
+
+        while (ret == ESP_AUDIO_ERR_OK && !mp3_stop_requested) {
+            if (!wait_if_paused()) {
+                break;
+            }
+
+            int read_size = fread(in_buf, 1, MP3_READ_BUFFER_SIZE, fp);
+            if (read_size <= 0) {
+                break;
+            }
+
+            esp_audio_simple_dec_raw_t raw = {
+                .buffer = in_buf,
+                .len = static_cast<uint32_t>(read_size),
+                .eos = read_size < MP3_READ_BUFFER_SIZE,
+                .consumed = 0,
+                .frame_recover = ESP_AUDIO_SIMPLE_DEC_RECOVERY_NONE,
+            };
+
+            while (raw.len > 0 && !mp3_stop_requested) {
+                if (!wait_if_paused()) {
+                    break;
+                }
+
+                esp_audio_simple_dec_out_t out_frame = {
+                    .buffer = out_buf,
+                    .len = out_buf_size,
+                    .needed_size = 0,
+                    .decoded_size = 0,
+                };
+
+                ret = esp_audio_simple_dec_process(decoder, &raw, &out_frame);
+                if (ret == ESP_AUDIO_ERR_BUFF_NOT_ENOUGH) {
+                    uint8_t *new_buf = static_cast<uint8_t *>(realloc(out_buf, out_frame.needed_size));
+                    if (!new_buf) {
+                        ESP_LOGE(TAG, "resize MP3 output buffer failed");
+                        ret = ESP_AUDIO_ERR_MEM_LACK;
+                        break;
+                    }
+                    out_buf = new_buf;
+                    out_buf_size = out_frame.needed_size;
+                    continue;
+                }
+
+                if (ret != ESP_AUDIO_ERR_OK) {
+                    ESP_LOGE(TAG, "decode MP3 failed: %d", ret);
+                    break;
+                }
+
+                if (out_frame.decoded_size > 0) {
+                    if (!audio_info_checked) {
+                        esp_audio_simple_dec_info_t info = {};
+                        if (esp_audio_simple_dec_get_info(decoder, &info) == ESP_AUDIO_ERR_OK) {
+                            ESP_LOGI(TAG, "MP3 info: %lu Hz, %u bits, %u ch", info.sample_rate, info.bits_per_sample, info.channel);
+                            if (info.bits_per_sample != 16 || info.channel == 0 || codec->output_channels() == 0) {
+                                ESP_LOGE(TAG, "MP3 format unsupported: %lu Hz, %u bits, %u ch", info.sample_rate, info.bits_per_sample, info.channel);
+                                ret = ESP_AUDIO_ERR_NOT_SUPPORT;
+                                break;
+                            }
+                            mp3_sample_rate = static_cast<uint32_t>(info.sample_rate * speed);
+                            mp3_channels = info.channel;
+                            if (mp3_sample_rate != static_cast<uint32_t>(codec->output_sample_rate())) {
+                                esp_ae_rate_cvt_cfg_t rate_cvt_cfg = {
+                                    .src_rate = mp3_sample_rate,
+                                    .dest_rate = static_cast<uint32_t>(codec->output_sample_rate()),
+                                    .channel = static_cast<uint8_t>(codec->output_channels()),
+                                    .bits_per_sample = 16,
+                                    .complexity = 2,
+                                    .perf_type = ESP_AE_RATE_CVT_PERF_TYPE_SPEED,
+                                };
+                                if (esp_ae_rate_cvt_open(&rate_cvt_cfg, &rate_cvt_handle) != ESP_AE_ERR_OK || !rate_cvt_handle) {
+                                    ESP_LOGE(TAG, "open MP3 resampler failed");
+                                    ret = ESP_AUDIO_ERR_NOT_SUPPORT;
+                                    break;
+                                }
+                            }
+                        }
+                        audio_info_checked = true;
+                    }
+
+                    if (!output_pcm(codec, rate_cvt_handle, out_frame.buffer, out_frame.decoded_size, mp3_channels, mp3_sample_rate)) {
+                        ret = ESP_AUDIO_ERR_FAIL;
+                        break;
+                    }
+                }
+
+                if (raw.consumed == 0) {
+                    break;
+                }
+                raw.len -= raw.consumed;
+                raw.buffer += raw.consumed;
+                raw.consumed = 0;
+            }
+
+            if (raw.eos || ret != ESP_AUDIO_ERR_OK) {
+                break;
+            }
+        }
+    }
+
+    if (rate_cvt_handle) {
+        esp_ae_rate_cvt_close(rate_cvt_handle);
+    }
+    if (!output_enabled_before) {
+        codec->EnableOutput(false);
+    }
+    free(in_buf);
+    free(out_buf);
+    esp_audio_simple_dec_close(decoder);
+    esp_audio_simple_dec_unregister_default();
+    esp_audio_dec_unregister_default();
+    fclose(fp);
+
+    ESP_LOGI(TAG, "MP3 playback %s: %s", mp3_stop_requested ? "stopped" : "finished", path);
+    mp3_player_task_handle = nullptr;
+    mp3_stop_requested = false;
+    mp3_pause_requested = false;
+    vTaskDelete(nullptr);
+}
+
+extern "C" esp_err_t SDCard_Play_MP3_File(const char *path)
+{
+    return SDCard_Play_MP3_File_With_Speed(path, 1.0f);
+}
+
+extern "C" esp_err_t SDCard_Play_MP3_File_With_Speed(const char *path, float speed)
+{
+    if (!path || path[0] == '\0' || !is_supported_speed(speed)) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    bool start_paused = mp3_pause_requested;
+    if (mp3_player_task_handle) {
+        mp3_stop_requested = true;
+        mp3_pause_requested = false;
+        while (mp3_player_task_handle) {
+            vTaskDelay(pdMS_TO_TICKS(20));
+        }
+    }
+
+    mp3_player_task_arg_t *arg = static_cast<mp3_player_task_arg_t *>(calloc(1, sizeof(mp3_player_task_arg_t)));
+    if (!arg) {
+        return ESP_ERR_NO_MEM;
+    }
+    strlcpy(arg->path, path, sizeof(arg->path));
+    arg->speed = speed;
+    mp3_stop_requested = false;
+    mp3_pause_requested = start_paused;
+
+    BaseType_t ret = xTaskCreate(mp3_player_task, "mp3_player", MP3_PLAYER_STACK_SIZE, arg, MP3_PLAYER_PRIORITY, &mp3_player_task_handle);
+    if (ret != pdPASS) {
+        free(arg);
+        mp3_player_task_handle = nullptr;
+        return ESP_FAIL;
+    }
+
+    return ESP_OK;
+}
+
+extern "C" void SDCard_Stop_Play(void)
+{
+    mp3_pause_requested = false;
+    if (!mp3_player_task_handle) {
+        ESP_LOGW(TAG, "MP3 player is not running");
+        return;
+    }
+
+    mp3_stop_requested = true;
+}
+
+extern "C" void SDCard_Pause_Play(void)
+{
+    mp3_pause_requested = true;
+}
+
+extern "C" void SDCard_Resume_Play(void)
+{
+    mp3_pause_requested = false;
+}
+
+extern "C" bool SDCard_Is_Playing(void)
+{
+    return mp3_player_task_handle != nullptr;
+}
+
+extern "C" bool SDCard_Is_Paused(void)
+{
+    return mp3_player_task_handle != nullptr && mp3_pause_requested;
+}
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
new file mode 100644
index 0000000..fce4e82
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.c
@@ -0,0 +1,2243 @@
+#include "mqtt.h"
+
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <time.h>
+#include <unistd.h>
+#include <cJSON.h>
+#include "esp_heap_caps.h"
+#include "esp_log.h"
+#include "esp_timer.h"
+#include "esp_wifi.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include "mqtt_client.h"
+#include "system.h"
+
+#define BOARD_MQTT_KEEPALIVE_SEC 120
+#define BOARD_MQTT_STATE_REPORT_INTERVAL_MS 5000
+#define BOARD_MQTT_RECONNECT_DELAY_MS 30000
+#define BOARD_MQTT_NETWORK_TIMEOUT_MS 15000
+#define BOARD_MQTT_FINAL_REPORT_WAIT_MS 10000
+#define BOARD_MQTT_VOICE_YIELD_WAIT_MS 120000
+#define BOARD_MQTT_TASK_PRIORITY 5
+#define BOARD_MQTT_RECONNECT_TASK_PRIORITY 4
+#define BOARD_MQTT_TOPIC_MAX_LEN 128
+#define BOARD_MQTT_PAYLOAD_MAX_LEN 8192
+#define BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN 256
+#define BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN 128
+#define BOARD_MQTT_PLAYLIST_POLL_MS 200
+#define BOARD_MQTT_PLAYLIST_TASK_STACK_SIZE 6144
+#define BOARD_MQTT_PLAYLIST_TASK_PRIORITY 5
+#define BOARD_MQTT_PLAYLIST_PATH MOUNT_POINT "/.mqtt_play_media_playlist.json"
+#define BOARD_MQTT_QOS 0
+
+#define BOARD_MQTT_ERR_INVALID_PARAM  4001
+#define BOARD_MQTT_ERR_UNSUPPORTED    4002
+#define BOARD_MQTT_ERR_EXEC_FAILED    4003
+#define BOARD_MQTT_ERR_TOPIC_MISMATCH 4004
+#define BOARD_MQTT_ERR_NOT_FOUND      4005
+#define BOARD_MQTT_ERR_BUSY           4006
+
+static const char *TAG = "board_mqtt";
+
+static esp_mqtt_client_handle_t s_mqtt_client = NULL;
+static bool s_mqtt_connected = false;
+static char s_device_id[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_cmd_ctl_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_cmd_task_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_rpt_log_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_rpt_ack_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_rpt_progress_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_rpt_state_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+static char s_rpt_event_topic[BOARD_MQTT_TOPIC_MAX_LEN];
+typedef enum {
+    BOARD_MQTT_TASK_NONE = 0,
+    BOARD_MQTT_TASK_DOWNLOAD,
+    BOARD_MQTT_TASK_MANIFEST,
+    BOARD_MQTT_TASK_OTA,
+    BOARD_MQTT_TASK_PLAYLIST,
+} board_mqtt_task_type_t;
+
+static bool s_task_busy = false;
+static bool s_manifest_pending = false;
+static board_mqtt_task_type_t s_task_type = BOARD_MQTT_TASK_NONE;
+static volatile bool s_download_voice_yield_requested = false;
+static volatile bool s_download_cancel_requested = false;
+static portMUX_TYPE s_task_lock = portMUX_INITIALIZER_UNLOCKED;
+static TaskHandle_t s_reconnect_task_handle = NULL;
+static TaskHandle_t s_state_task_handle = NULL;
+static TaskHandle_t s_playlist_task_handle = NULL;
+static volatile bool s_playlist_stop_requested = false;
+static uint32_t s_state_sequence = 0;
+
+static void BoardMqtt_HandleCommand(const char *topic, int topic_len, const char *data, int data_len);
+static bool BoardMqtt_CopyString(char *dest, size_t dest_size, const char *src);
+static bool BoardMqtt_IsMd5Hex(const char *value);
+static void BoardMqtt_WaitConnectedForFinalReport(void);
+static bool BoardMqtt_BeginTask(board_mqtt_task_type_t type);
+static void BoardMqtt_FinishTask(board_mqtt_task_type_t type);
+static bool BoardMqtt_PlaybackSpeedIsSupported(float speed);
+static void BoardMqtt_StopPlaylistTask(void);
+static esp_err_t BoardMqtt_StartPlaylistTask(void);
+
+typedef struct {
+    char topic[BOARD_MQTT_TOPIC_MAX_LEN];
+    int topic_len;
+    char *payload;
+    int total_len;
+    int received_len;
+} board_mqtt_payload_assembly_t;
+
+static board_mqtt_payload_assembly_t s_payload_assembly = {0};
+
+typedef enum {
+    BOARD_MQTT_TOPIC_UNKNOWN = 0,
+    BOARD_MQTT_TOPIC_CTL,
+    BOARD_MQTT_TOPIC_TASK,
+} board_mqtt_topic_type_t;
+
+typedef struct {
+    bool success;
+    int error_code;
+    const char *info;
+} board_mqtt_action_result_t;
+
+static board_mqtt_action_result_t BoardMqtt_HandleUnsetAlarm(cJSON *payload);
+
+typedef struct {
+    char req_id[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+    char url[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+    char target_path[SDCARD_PATH_MAX_LEN];
+    char md5[33];
+} board_mqtt_download_job_t;
+
+typedef struct {
+    char url[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+    char target_path[SDCARD_PATH_MAX_LEN];
+    char md5[33];
+} board_mqtt_manifest_asset_t;
+
+typedef struct {
+    char req_id[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+    char course_id[ALARM_ID_MAX_LEN];
+    char *manifest_json;
+    size_t asset_count;
+    bool replace_existing_download;
+    board_mqtt_manifest_asset_t assets[];
+} board_mqtt_manifest_job_t;
+
+typedef struct {
+    char req_id[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+    char url[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+    char version[64];
+    int64_t last_publish_us;
+    int last_progress;
+} board_mqtt_ota_job_t;
+
+typedef struct {
+    char task_id[BOARD_MQTT_DOWNLOAD_FIELD_MAX_LEN];
+    char path[SDCARD_PATH_MAX_LEN];
+    char sd_path[SDCARD_PATH_MAX_LEN];
+    int volume;
+    bool has_volume;
+    float speed;
+    int index;
+    int count;
+} board_mqtt_playlist_item_t;
+
+typedef struct {
+    const char *req_id;
+    int64_t last_publish_us;
+    int last_progress;
+} board_mqtt_download_progress_ctx_t;
+
+typedef struct {
+    const char *req_id;
+    size_t asset_index;
+    size_t asset_count;
+    int64_t last_publish_us;
+    int last_progress;
+} board_mqtt_manifest_progress_ctx_t;
+
+static bool BoardMqtt_IsDownloadTask(board_mqtt_task_type_t type)
+{
+    return type == BOARD_MQTT_TASK_DOWNLOAD || type == BOARD_MQTT_TASK_MANIFEST;
+}
+
+static board_mqtt_task_type_t BoardMqtt_GetTaskType(void)
+{
+    board_mqtt_task_type_t type;
+    portENTER_CRITICAL(&s_task_lock);
+    type = s_task_type;
+    portEXIT_CRITICAL(&s_task_lock);
+    return type;
+}
+
+static bool BoardMqtt_BeginTask(board_mqtt_task_type_t type)
+{
+    bool started = false;
+    portENTER_CRITICAL(&s_task_lock);
+    if (!s_task_busy) {
+        s_task_busy = true;
+        s_task_type = type;
+        s_download_cancel_requested = false;
+        started = true;
+    }
+    portEXIT_CRITICAL(&s_task_lock);
+    return started;
+}
+
+static bool BoardMqtt_BeginManifestReplacement(void)
+{
+    bool started = false;
+    portENTER_CRITICAL(&s_task_lock);
+    if (s_task_busy && BoardMqtt_IsDownloadTask(s_task_type) && !s_manifest_pending) {
+        s_manifest_pending = true;
+        s_download_cancel_requested = true;
+        started = true;
+    }
+    portEXIT_CRITICAL(&s_task_lock);
+    return started;
+}
+
+static void BoardMqtt_FinishTask(board_mqtt_task_type_t type)
+{
+    portENTER_CRITICAL(&s_task_lock);
+    if (s_task_type == type) {
+        s_task_busy = false;
+        s_task_type = BOARD_MQTT_TASK_NONE;
+        s_download_cancel_requested = false;
+    }
+    portEXIT_CRITICAL(&s_task_lock);
+}
+
+static bool BoardMqtt_HasPendingManifest(void)
+{
+    bool pending;
+    portENTER_CRITICAL(&s_task_lock);
+    pending = s_manifest_pending;
+    portEXIT_CRITICAL(&s_task_lock);
+    return pending;
+}
+
+static bool BoardMqtt_ActivatePendingManifest(void)
+{
+    bool activated = false;
+    portENTER_CRITICAL(&s_task_lock);
+    if (!s_task_busy && s_manifest_pending) {
+        s_task_busy = true;
+        s_task_type = BOARD_MQTT_TASK_MANIFEST;
+        s_manifest_pending = false;
+        s_download_cancel_requested = false;
+        activated = true;
+    }
+    portEXIT_CRITICAL(&s_task_lock);
+    return activated;
+}
+
+static void BoardMqtt_ClearPendingManifest(void)
+{
+    portENTER_CRITICAL(&s_task_lock);
+    s_manifest_pending = false;
+    portEXIT_CRITICAL(&s_task_lock);
+}
+
+static void BoardMqtt_RequestDownloadCancel(void)
+{
+    portENTER_CRITICAL(&s_task_lock);
+    if (s_task_busy && BoardMqtt_IsDownloadTask(s_task_type)) {
+        s_download_cancel_requested = true;
+    }
+    portEXIT_CRITICAL(&s_task_lock);
+}
+
+static bool BoardMqtt_WaitDownloadCanceled(int timeout_ms)
+{
+    int waited_ms = 0;
+    while (true) {
+        board_mqtt_task_type_t type = BoardMqtt_GetTaskType();
+        if (!BoardMqtt_IsDownloadTask(type)) {
+            return true;
+        }
+        if (waited_ms >= timeout_ms) {
+            return false;
+        }
+        vTaskDelay(pdMS_TO_TICKS(100));
+        waited_ms += 100;
+    }
+}
+
+bool BoardMqtt_CancelDownload(void)
+{
+    board_mqtt_task_type_t type = BoardMqtt_GetTaskType();
+    if (!BoardMqtt_IsDownloadTask(type)) {
+        return false;
+    }
+    BoardMqtt_RequestDownloadCancel();
+    return true;
+}
+
+static esp_err_t BoardMqtt_FormatTopic(char *buffer, size_t buffer_size, const char *format, const char *device_id)
+{
+    int topic_len = snprintf(buffer, buffer_size, format, device_id);
+    if (topic_len < 0 || topic_len >= (int)buffer_size) {
+        ESP_LOGE(TAG, "MQTT topic is too long: %s", format);
+        return ESP_ERR_INVALID_SIZE;
+    }
+    return ESP_OK;
+}
+
+static long BoardMqtt_GetTimestamp(void)
+{
+    time_t now = time(NULL);
+    if (now < 0) {
+        return 0;
+    }
+    return (long)now;
+}
+
+static void BoardMqtt_PublishJson(const char *topic, cJSON *json, int qos)
+{
+    if (!s_mqtt_client || !s_mqtt_connected || !topic || !json) {
+        return;
+    }
+
+    char *payload = cJSON_PrintUnformatted(json);
+    if (!payload) {
+        ESP_LOGE(TAG, "Failed to format MQTT JSON payload");
+        return;
+    }
+
+    int msg_id = esp_mqtt_client_publish(s_mqtt_client, topic, payload, 0, qos, 0);
+    if (msg_id >= 0) {
+        ESP_LOGI(TAG, "Published MQTT message, topic=%s, msg_id=%d", topic, msg_id);
+    } else {
+        ESP_LOGE(TAG, "Failed to publish MQTT message to %s", topic);
+    }
+    cJSON_free(payload);
+}
+
+static void BoardMqtt_WaitConnectedForFinalReport(void)
+{
+    int waited_ms = 0;
+    while (s_mqtt_client && !s_mqtt_connected && waited_ms < BOARD_MQTT_FINAL_REPORT_WAIT_MS) {
+        ESP_LOGW(TAG, "Waiting MQTT reconnect before final report, waited=%dms", waited_ms);
+        vTaskDelay(pdMS_TO_TICKS(500));
+        waited_ms += 500;
+    }
+}
+
+static void BoardMqtt_PublishState(void)
+{
+    cJSON *root = cJSON_CreateObject();
+    cJSON *header = cJSON_CreateObject();
+    cJSON *payload = cJSON_CreateObject();
+    if (!root || !header || !payload) {
+        cJSON_Delete(root);
+        cJSON_Delete(header);
+        cJSON_Delete(payload);
+        return;
+    }
+
+    wifi_ap_record_t ap_info = {0};
+    bool wifi_connected = esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK;
+    size_t free_heap = heap_caps_get_free_size(MALLOC_CAP_8BIT);
+    size_t total_heap = heap_caps_get_total_size(MALLOC_CAP_8BIT);
+    int mem_percent = total_heap > 0 ? (int)(((total_heap - free_heap) * 100) / total_heap) : 0;
+
+    cJSON_AddStringToObject(header, "device_id", s_device_id);
+    cJSON_AddNumberToObject(header, "timestamp", BoardMqtt_GetTimestamp());
+    cJSON_AddItemToObject(root, "header", header);
+    cJSON_AddStringToObject(root, "msg_type", "state");
+
+    ++s_state_sequence;
+    board_mqtt_task_type_t task_type = BoardMqtt_GetTaskType();
+    cJSON_AddNumberToObject(payload, "cpu", Board_Get_CPU_Usage());
+    cJSON_AddNumberToObject(payload, "mem", mem_percent);
+    cJSON_AddNumberToObject(payload, "battery", Battery_Get_Value());
+    cJSON_AddStringToObject(payload, "net", wifi_connected ? "wifi" : "offline");
+    cJSON_AddStringToObject(payload, "mode", task_type != BOARD_MQTT_TASK_NONE ? "task" : (Board_Is_Voice_Session_Active() ? "voice" : "idle"));
+    cJSON_AddItemToObject(root, "payload", payload);
+
+    BoardMqtt_PublishJson(s_rpt_state_topic, root, BOARD_MQTT_QOS);
+    cJSON_Delete(root);
+}
+
+static void BoardMqtt_PublishLog(const char *message)
+{
+    cJSON *root = cJSON_CreateObject();
+    cJSON *header = cJSON_CreateObject();
+    cJSON *payload = cJSON_CreateObject();
+    if (!root || !header || !payload) {
+        cJSON_Delete(root);
+        cJSON_Delete(header);
+        cJSON_Delete(payload);
+        return;
+    }
+
+    cJSON_AddStringToObject(header, "device_id", s_device_id);
+    cJSON_AddNumberToObject(header, "timestamp", BoardMqtt_GetTimestamp());
+    cJSON_AddItemToObject(root, "header", header);
+    cJSON_AddStringToObject(root, "msg_type", "log");
+    cJSON_AddStringToObject(payload, "module", "mqtt");
+    cJSON_AddStringToObject(payload, "message", message);
+    cJSON_AddItemToObject(root, "payload", payload);
+
+    BoardMqtt_PublishJson(s_rpt_log_topic, root, BOARD_MQTT_QOS);
+    cJSON_Delete(root);
+}
+
+void BoardMqtt_PublishSttEvent(const char *text)
+{
+    if (!text || text[0] == '\0') {
+        return;
+    }
+
+    cJSON *root = cJSON_CreateObject();
+    cJSON *header = cJSON_CreateObject();
+    cJSON *payload = cJSON_CreateObject();
+    if (!root || !header || !payload) {
+        cJSON_Delete(root);
+        cJSON_Delete(header);
+        cJSON_Delete(payload);
+        return;
+    }
+
+    cJSON_AddStringToObject(header, "device_id", s_device_id);
+    cJSON_AddNumberToObject(header, "timestamp", BoardMqtt_GetTimestamp());
+    cJSON_AddItemToObject(root, "header", header);
+    cJSON_AddStringToObject(root, "msg_type", "event");
+    cJSON_AddStringToObject(payload, "event", "asr_result");
+    cJSON_AddStringToObject(payload, "text", text);
+    cJSON_AddItemToObject(root, "payload", payload);
+
+    BoardMqtt_PublishJson(s_rpt_event_topic, root, BOARD_MQTT_QOS);
+    cJSON_Delete(root);
+}
+
+static void BoardMqtt_PublishAck(const char *req_id, bool success, int error_code, const char *info)
+{
+    if (!req_id || req_id[0] == '\0') {
+        return;
+    }
+
+    cJSON *root = cJSON_CreateObject();
+    cJSON *header = cJSON_CreateObject();
+    cJSON *payload = cJSON_CreateObject();
+    if (!root || !header || !payload) {
+        cJSON_Delete(root);
+        cJSON_Delete(header);
+        cJSON_Delete(payload);
+        return;
+    }
+
+    cJSON_AddStringToObject(header, "device_id", s_device_id);
+    cJSON_AddNumberToObject(header, "timestamp", BoardMqtt_GetTimestamp());
+    cJSON_AddItemToObject(root, "header", header);
+    cJSON_AddStringToObject(root, "msg_type", "ack");
+    cJSON_AddStringToObject(payload, "req_id", req_id);
+    cJSON_AddStringToObject(payload, "status", success ? "success" : "failed");
+    cJSON_AddNumberToObject(payload, "error_code", success ? 0 : error_code);
+    cJSON_AddStringToObject(payload, "info", info ? info : "");
+    cJSON_AddItemToObject(root, "payload", payload);
+
+    BoardMqtt_PublishJson(s_rpt_ack_topic, root, BOARD_MQTT_QOS);
+    cJSON_Delete(root);
+}
+
+static void BoardMqtt_PublishProgress(const char *task_id, int progress, const char *info)
+{
+    if (!task_id || task_id[0] == '\0') {
+        return;
+    }
+    if (progress < 0) {
+        progress = 0;
+    } else if (progress > 100) {
+        progress = 100;
+    }
+
+    cJSON *root = cJSON_CreateObject();
+    cJSON *header = cJSON_CreateObject();
+    cJSON *payload = cJSON_CreateObject();
+    if (!root || !header || !payload) {
+        cJSON_Delete(root);
+        cJSON_Delete(header);
+        cJSON_Delete(payload);
+        return;
+    }
+
+    cJSON_AddStringToObject(header, "device_id", s_device_id);
+    cJSON_AddNumberToObject(header, "timestamp", BoardMqtt_GetTimestamp());
+    cJSON_AddItemToObject(root, "header", header);
+    cJSON_AddStringToObject(root, "msg_type", "progress");
+    cJSON_AddStringToObject(payload, "task_id", task_id);
+    cJSON_AddStringToObject(payload, "status", "processing");
+    cJSON_AddNumberToObject(payload, "progress", progress);
+    cJSON_AddStringToObject(payload, "info", info ? info : "");
+    cJSON_AddItemToObject(root, "payload", payload);
+
+    ESP_LOGI(TAG, "MQTT progress: task_id=%s progress=%d info=%s", task_id, progress, info ? info : "");
+    BoardMqtt_PublishJson(s_rpt_progress_topic, root, BOARD_MQTT_QOS);
+    cJSON_Delete(root);
+}
+
+static char *BoardMqtt_CopySegment(const char *data, int data_len)
+{
+    if (!data || data_len <= 0) {
+        return NULL;
+    }
+
+    char *copy = (char *)calloc(1, data_len + 1);
+    if (!copy) {
+        ESP_LOGE(TAG, "Failed to allocate MQTT segment buffer");
+        return NULL;
+    }
+    memcpy(copy, data, data_len);
+    copy[data_len] = '\0';
+    return copy;
+}
+
+static bool BoardMqtt_NumberToInt(cJSON *item, int *value)
+{
+    if (!cJSON_IsNumber(item) || !value) {
+        return false;
+    }
+    double number = item->valuedouble;
+    int int_value = item->valueint;
+    if ((double)int_value != number) {
+        return false;
+    }
+    *value = int_value;
+    return true;
+}
+
+static void BoardMqtt_LogReceivedJson(const char *topic, cJSON *payload)
+{
+    cJSON *root = cJSON_CreateObject();
+    if (!root) {
+        ESP_LOGW(TAG, "Failed to format received MQTT JSON");
+        return;
+    }
+
+    cJSON_AddStringToObject(root, "event", "mqtt_command_received");
+    cJSON_AddStringToObject(root, "topic", topic ? topic : "");
+#if BOARD_MQTT_EXECUTE_COMMANDS
+    cJSON_AddBoolToObject(root, "execute_commands", true);
+#else
+    cJSON_AddBoolToObject(root, "execute_commands", false);
+#endif
+    cJSON_AddItemReferenceToObject(root, "payload", payload);
+
+    char *formatted = cJSON_Print(root);
+    if (!formatted) {
+        ESP_LOGW(TAG, "Failed to format received MQTT JSON");
+        cJSON_Delete(root);
+        return;
+    }
+    ESP_LOGI(TAG, "%s", formatted);
+    cJSON_free(formatted);
+    cJSON_Delete(root);
+}
+
+static void BoardMqtt_LogReceivedText(const char *topic, const char *payload)
+{
+    cJSON *root = cJSON_CreateObject();
+    if (!root) {
+        ESP_LOGW(TAG, "Failed to format received MQTT text");
+        return;
+    }
+
+    cJSON_AddStringToObject(root, "event", "mqtt_command_invalid_json");
+    cJSON_AddStringToObject(root, "topic", topic ? topic : "");
+#if BOARD_MQTT_EXECUTE_COMMANDS
+    cJSON_AddBoolToObject(root, "execute_commands", true);
+#else
+    cJSON_AddBoolToObject(root, "execute_commands", false);
+#endif
+    cJSON_AddStringToObject(root, "payload", payload ? payload : "");
+
+    char *formatted = cJSON_Print(root);
+    if (!formatted) {
+        ESP_LOGW(TAG, "Failed to format received MQTT text");
+        cJSON_Delete(root);
+        return;
+    }
+    ESP_LOGW(TAG, "%s", formatted);
+    cJSON_free(formatted);
+    cJSON_Delete(root);
+}
+
+static bool BoardMqtt_CopyBusinessPath(char *dest, size_t dest_size, const char *path)
+{
+    if (!dest || dest_size == 0 || !path || path[0] == '\0') {
+        return false;
+    }
+    if (path[0] == '/') {
+        return BoardMqtt_CopyString(dest, dest_size, path);
+    }
+    int written = snprintf(dest, dest_size, "/%s", path);
+    return written > 0 && (size_t)written < dest_size;
+}
+
+static bool BoardMqtt_IsSupportedResourcePath(const char *sd_path)
+{
+    const char *dot = sd_path ? strrchr(sd_path, '.') : NULL;
+    return dot && (strcasecmp(dot, ".mp3") == 0 || strcasecmp(dot, ".jpg") == 0);
+}
+
+static bool BoardMqtt_IsPlaceholderMd5(const char *md5)
+{
+    return !md5 || md5[0] == '\0' || strcmp(md5, "...") == 0;
+}
+
+static bool BoardMqtt_ValidateOptionalMd5(cJSON *md5)
+{
+    if (!md5) {
+        return true;
+    }
+    return cJSON_IsString(md5) && (BoardMqtt_IsPlaceholderMd5(md5->valuestring) || BoardMqtt_IsMd5Hex(md5->valuestring));
+}
+
+static bool BoardMqtt_CopyOptionalMd5(char *dest, size_t dest_size, cJSON *md5)
+{
+    if (!cJSON_IsString(md5) || BoardMqtt_IsPlaceholderMd5(md5->valuestring)) {
+        return true;
+    }
+    return BoardMqtt_CopyString(dest, dest_size, md5->valuestring);
+}
+
+static bool BoardMqtt_ActionIsCtl(const char *action)
+{
+    return strcmp(action, "SET_VOLUME") == 0 || strcmp(action, "SET_POWER") == 0 ||
+        strcmp(action, "SET_CLOCK") == 0 || strcmp(action, "PLAY_MEDIA") == 0 ||
+        strcmp(action, "STOP_MEDIA") == 0 || strcmp(action, "DELETE_FILE") == 0 ||
+        strcmp(action, "SET_ALARM") == 0 || strcmp(action, "UNSET_ALARM") == 0 ||
+        strcmp(action, "REBOOT") == 0;
+}
+
+static bool BoardMqtt_ActionIsTask(const char *action)
+{
+    return strcmp(action, "DOWNLOAD") == 0 || strcmp(action, "UPDATE_MANIFEST") == 0 ||
+        strcmp(action, "SYNC_COURSE_TASK") == 0 || strcmp(action, "OTA_UPDATE") == 0;
+}
+
+static board_mqtt_action_result_t BoardMqtt_ResultOk(const char *info)
+{
+    return (board_mqtt_action_result_t){ .success = true, .error_code = 0, .info = info };
+}
+
+static board_mqtt_action_result_t BoardMqtt_ResultFail(int error_code, const char *info)
+{
+    return (board_mqtt_action_result_t){ .success = false, .error_code = error_code, .info = info };
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleSetVolume(cJSON *payload)
+{
+    int level = 0;
+    if (!BoardMqtt_NumberToInt(cJSON_GetObjectItem(payload, "level"), &level) || level < 0 || level > 100) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid volume level");
+    }
+    esp_err_t err = Board_Set_Output_Volume(level);
+    return err == ESP_OK ? BoardMqtt_ResultOk("volume updated") :
+        BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleSetPower(cJSON *payload)
+{
+    cJSON *state = cJSON_GetObjectItem(payload, "state");
+    if (!cJSON_IsString(state)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing power state");
+    }
+
+    esp_err_t err = ESP_OK;
+    if (strcmp(state->valuestring, "sleep") == 0) {
+        err = Board_Enter_Standby();
+    } else if (strcmp(state->valuestring, "wake") == 0) {
+        err = Board_Request_Wake(30000);
+    } else {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid power state");
+    }
+
+    return err == ESP_OK ? BoardMqtt_ResultOk("power state updated") :
+        BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleSetClock(cJSON *payload)
+{
+    cJSON *ts = cJSON_GetObjectItem(payload, "ts");
+    if (!cJSON_IsNumber(ts) || ts->valuedouble <= 0) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid timestamp");
+    }
+    esp_err_t err = DS2312_Set_Unix_Time_Saved((time_t)ts->valuedouble);
+    return err == ESP_OK ? BoardMqtt_ResultOk("clock updated") :
+        BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+}
+
+static bool BoardMqtt_PlaybackSpeedIsSupported(float speed)
+{
+    const float supported_speeds[] = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f};
+    for (size_t i = 0; i < sizeof(supported_speeds) / sizeof(supported_speeds[0]); ++i) {
+        float diff = speed - supported_speeds[i];
+        if (diff < 0) {
+            diff = -diff;
+        }
+        if (diff < 0.001f) {
+            return true;
+        }
+    }
+    return false;
+}
+
+static char *BoardMqtt_ReadPlaylistFile(void)
+{
+    FILE *fp = fopen(BOARD_MQTT_PLAYLIST_PATH, "rb");
+    if (!fp) {
+        return NULL;
+    }
+
+    if (fseek(fp, 0, SEEK_END) != 0) {
+        fclose(fp);
+        return NULL;
+    }
+    long file_size = ftell(fp);
+    if (file_size <= 0 || file_size > BOARD_MQTT_PAYLOAD_MAX_LEN || fseek(fp, 0, SEEK_SET) != 0) {
+        fclose(fp);
+        return NULL;
+    }
+
+    char *buffer = (char *)calloc(1, (size_t)file_size + 1);
+    if (!buffer) {
+        fclose(fp);
+        return NULL;
+    }
+
+    size_t read_size = fread(buffer, 1, (size_t)file_size, fp);
+    fclose(fp);
+    if (read_size != (size_t)file_size) {
+        free(buffer);
+        return NULL;
+    }
+    return buffer;
+}
+
+static esp_err_t BoardMqtt_WritePlaylistRoot(cJSON *root)
+{
+    char *json = cJSON_PrintUnformatted(root);
+    if (!json) {
+        return ESP_ERR_NO_MEM;
+    }
+
+    FILE *fp = fopen(BOARD_MQTT_PLAYLIST_PATH, "wb");
+    if (!fp) {
+        cJSON_free(json);
+        return ESP_FAIL;
+    }
+
+    size_t len = strlen(json);
+    size_t written = fwrite(json, 1, len, fp);
+    int close_ret = fclose(fp);
+    cJSON_free(json);
+    return written == len && close_ret == 0 ? ESP_OK : ESP_FAIL;
+}
+
+static cJSON *BoardMqtt_LoadPlaylistRoot(void)
+{
+    char *json = BoardMqtt_ReadPlaylistFile();
+    if (!json) {
+        return NULL;
+    }
+    cJSON *root = cJSON_Parse(json);
+    free(json);
+    return root;
+}
+
+static cJSON *BoardMqtt_GetPlaylistItems(cJSON *root)
+{
+    cJSON *items = cJSON_IsObject(root) ? cJSON_GetObjectItem(root, "items") : NULL;
+    return cJSON_IsArray(items) ? items : NULL;
+}
+
+static bool BoardMqtt_PlaylistHasUnfinished(cJSON *root)
+{
+    cJSON *items = BoardMqtt_GetPlaylistItems(root);
+    if (!items) {
+        return false;
+    }
+
+    cJSON *item = NULL;
+    cJSON_ArrayForEach(item, items) {
+        cJSON *completed = cJSON_IsObject(item) ? cJSON_GetObjectItem(item, "completed") : NULL;
+        if (!cJSON_IsTrue(completed)) {
+            return true;
+        }
+    }
+    return false;
+}
+
+static esp_err_t BoardMqtt_SavePlaylist(const char *req_id, cJSON *items)
+{
+    cJSON *root = cJSON_CreateObject();
+    cJSON *playlist_items = cJSON_CreateArray();
+    if (!root || !playlist_items) {
+        cJSON_Delete(root);
+        cJSON_Delete(playlist_items);
+        return ESP_ERR_NO_MEM;
+    }
+
+    cJSON_AddNumberToObject(root, "version", 1);
+    cJSON_AddStringToObject(root, "task_id", req_id ? req_id : "PLAY_MEDIA");
+    cJSON_AddItemToObject(root, "items", playlist_items);
+
+    if (cJSON_GetArraySize(items) <= 0) {
+        cJSON_Delete(root);
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    cJSON *item = NULL;
+    cJSON_ArrayForEach(item, items) {
+        if (!cJSON_IsObject(item)) {
+            cJSON_Delete(root);
+            return ESP_ERR_INVALID_ARG;
+        }
+
+        cJSON *path = cJSON_GetObjectItem(item, "path");
+        if (!cJSON_IsString(path)) {
+            cJSON_Delete(root);
+            return ESP_ERR_INVALID_ARG;
+        }
+
+        char business_path[SDCARD_PATH_MAX_LEN];
+        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), path->valuestring)) {
+            cJSON_Delete(root);
+            return ESP_ERR_INVALID_ARG;
+        }
+
+        char sd_path[SDCARD_PATH_MAX_LEN];
+        esp_err_t err = SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path));
+        if (err != ESP_OK) {
+            cJSON_Delete(root);
+            return err;
+        }
+        const char *dot = strrchr(sd_path, '.');
+        if (!dot || strcasecmp(dot, ".mp3") != 0) {
+            cJSON_Delete(root);
+            return ESP_ERR_NOT_SUPPORTED;
+        }
+        if (access(sd_path, F_OK) != 0) {
+            cJSON_Delete(root);
+            return ESP_ERR_NOT_FOUND;
+        }
+
+        int volume_value = 0;
+        bool has_volume = false;
+        cJSON *volume = cJSON_GetObjectItem(item, "volume");
+        if (volume) {
+            if (!BoardMqtt_NumberToInt(volume, &volume_value) || volume_value < 0 || volume_value > 100) {
+                cJSON_Delete(root);
+                return ESP_ERR_INVALID_ARG;
+            }
+            has_volume = true;
+        }
+
+        float speed = 1.0f;
+        cJSON *speed_json = cJSON_GetObjectItem(item, "speed");
+        if (speed_json) {
+            if (!cJSON_IsNumber(speed_json)) {
+                cJSON_Delete(root);
+                return ESP_ERR_INVALID_ARG;
+            }
+            speed = (float)speed_json->valuedouble;
+        }
+        if (!BoardMqtt_PlaybackSpeedIsSupported(speed)) {
+            cJSON_Delete(root);
+            return ESP_ERR_INVALID_ARG;
+        }
+
+        cJSON *stored_item = cJSON_CreateObject();
+        if (!stored_item) {
+            cJSON_Delete(root);
+            return ESP_ERR_NO_MEM;
+        }
+        cJSON_AddStringToObject(stored_item, "path", business_path);
+        cJSON_AddStringToObject(stored_item, "sd_path", sd_path);
+        if (has_volume) {
+            cJSON_AddNumberToObject(stored_item, "volume", volume_value);
+        }
+        cJSON_AddNumberToObject(stored_item, "speed", speed);
+        cJSON_AddBoolToObject(stored_item, "completed", false);
+        cJSON_AddItemToArray(playlist_items, stored_item);
+    }
+
+    esp_err_t err = BoardMqtt_WritePlaylistRoot(root);
+    cJSON_Delete(root);
+    return err;
+}
+
+static bool BoardMqtt_LoadNextPlaylistItem(board_mqtt_playlist_item_t *next_item)
+{
+    if (!next_item) {
+        return false;
+    }
+
+    cJSON *root = BoardMqtt_LoadPlaylistRoot();
+    cJSON *items = BoardMqtt_GetPlaylistItems(root);
+    if (!items) {
+        cJSON_Delete(root);
+        return false;
+    }
+
+    cJSON *task_id = cJSON_GetObjectItem(root, "task_id");
+    int index = 0;
+    int count = cJSON_GetArraySize(items);
+    cJSON *item = NULL;
+    cJSON_ArrayForEach(item, items) {
+        cJSON *completed = cJSON_IsObject(item) ? cJSON_GetObjectItem(item, "completed") : NULL;
+        if (!cJSON_IsTrue(completed)) {
+            cJSON *path = cJSON_GetObjectItem(item, "path");
+            cJSON *sd_path = cJSON_GetObjectItem(item, "sd_path");
+            cJSON *volume = cJSON_GetObjectItem(item, "volume");
+            cJSON *speed_json = cJSON_GetObjectItem(item, "speed");
+            if (!cJSON_IsString(path) || !cJSON_IsString(sd_path)) {
+                cJSON_Delete(root);
+                return false;
+            }
+
+            memset(next_item, 0, sizeof(*next_item));
+            if (cJSON_IsString(task_id)) {
+                BoardMqtt_CopyString(next_item->task_id, sizeof(next_item->task_id), task_id->valuestring);
+            }
+            if (next_item->task_id[0] == '\0') {
+                BoardMqtt_CopyString(next_item->task_id, sizeof(next_item->task_id), "PLAY_MEDIA");
+            }
+            if (!BoardMqtt_CopyString(next_item->path, sizeof(next_item->path), path->valuestring) ||
+                !BoardMqtt_CopyString(next_item->sd_path, sizeof(next_item->sd_path), sd_path->valuestring)) {
+                cJSON_Delete(root);
+                return false;
+            }
+            if (volume) {
+                if (!BoardMqtt_NumberToInt(volume, &next_item->volume) || next_item->volume < 0 || next_item->volume > 100) {
+                    cJSON_Delete(root);
+                    return false;
+                }
+                next_item->has_volume = true;
+            }
+            next_item->speed = cJSON_IsNumber(speed_json) ? (float)speed_json->valuedouble : 1.0f;
+            if (!BoardMqtt_PlaybackSpeedIsSupported(next_item->speed)) {
+                cJSON_Delete(root);
+                return false;
+            }
+            next_item->index = index;
+            next_item->count = count;
+            cJSON_Delete(root);
+            return true;
+        }
+        ++index;
+    }
+
+    cJSON_Delete(root);
+    return false;
+}
+
+static void BoardMqtt_ShowPlaylistAlert(const char *message, const char *emotion)
+{
+    Board_Show_Alert("鎾斁娓呭崟", message, emotion ? emotion : "music");
+}
+
+static esp_err_t BoardMqtt_MarkPlaylistItemCompleted(int target_index)
+{
+    cJSON *root = BoardMqtt_LoadPlaylistRoot();
+    cJSON *items = BoardMqtt_GetPlaylistItems(root);
+    if (!items) {
+        cJSON_Delete(root);
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    cJSON *item = cJSON_GetArrayItem(items, target_index);
+    if (!cJSON_IsObject(item)) {
+        cJSON_Delete(root);
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    cJSON_DeleteItemFromObject(item, "completed");
+    cJSON_AddBoolToObject(item, "completed", true);
+    esp_err_t err = BoardMqtt_WritePlaylistRoot(root);
+    cJSON_Delete(root);
+    return err;
+}
+
+static void BoardMqtt_PlaylistTask(void *arg)
+{
+    (void)arg;
+    Board_Acquire_Sleep_Inhibit();
+
+    while (!s_playlist_stop_requested) {
+        board_mqtt_playlist_item_t item;
+        if (!BoardMqtt_LoadNextPlaylistItem(&item)) {
+            if (!s_playlist_stop_requested) {
+                BoardMqtt_ShowPlaylistAlert("娌℃湁鏈畬鎴愮殑鎾斁娓呭崟", "circle_info");
+            }
+            break;
+        }
+
+        if (item.has_volume) {
+            Board_Set_Output_Volume(item.volume);
+        }
+
+        char alert_message[96];
+        snprintf(alert_message, sizeof(alert_message), "姝e湪鎾斁 %d/%d", item.index + 1, item.count);
+        BoardMqtt_ShowPlaylistAlert(alert_message, "music");
+
+        SDCard_Show_Matching_JPG_For_MP3_Path(item.sd_path);
+        ESP_LOGI(TAG, "playlist play item %d/%d: %s speed=%.2fx", item.index + 1, item.count, item.sd_path, item.speed);
+        esp_err_t err = SDCard_Play_MP3_File_With_Speed(item.sd_path, item.speed);
+        if (err != ESP_OK) {
+            ESP_LOGE(TAG, "playlist item playback failed: %s", esp_err_to_name(err));
+            snprintf(alert_message, sizeof(alert_message), "鎾斁澶辫触 %d/%d: %s", item.index + 1, item.count, esp_err_to_name(err));
+            BoardMqtt_ShowPlaylistAlert(alert_message, "circle_xmark");
+            break;
+        }
+
+        while (!s_playlist_stop_requested && SDCard_Is_Playing()) {
+            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_PLAYLIST_POLL_MS));
+        }
+        if (s_playlist_stop_requested) {
+            break;
+        }
+
+        err = BoardMqtt_MarkPlaylistItemCompleted(item.index);
+        if (err != ESP_OK) {
+            ESP_LOGE(TAG, "mark playlist item completed failed: %s", esp_err_to_name(err));
+            snprintf(alert_message, sizeof(alert_message), "淇濆瓨鎾斁杩涘害澶辫触: %s", esp_err_to_name(err));
+            BoardMqtt_ShowPlaylistAlert(alert_message, "circle_xmark");
+            break;
+        }
+        int progress = item.count > 0 ? ((item.index + 1) * 100) / item.count : 100;
+        BoardMqtt_PublishProgress(item.task_id, progress, "playlist item completed");
+        if (progress >= 100) {
+            BoardMqtt_ShowPlaylistAlert("鎾斁娓呭崟宸插畬鎴�", "check");
+        }
+    }
+
+    BoardMqtt_FinishTask(BOARD_MQTT_TASK_PLAYLIST);
+    Board_Release_Sleep_Inhibit();
+    s_playlist_stop_requested = false;
+    s_playlist_task_handle = NULL;
+    vTaskDelete(NULL);
+}
+
+static void BoardMqtt_StopPlaylistTask(void)
+{
+    if (!s_playlist_task_handle) {
+        s_playlist_stop_requested = false;
+        return;
+    }
+
+    s_playlist_stop_requested = true;
+    if (SDCard_Is_Playing()) {
+        SDCard_Stop_Play();
+    }
+
+    int waited_ms = 0;
+    while (s_playlist_task_handle && waited_ms < BOARD_MQTT_NETWORK_TIMEOUT_MS) {
+        vTaskDelay(pdMS_TO_TICKS(50));
+        waited_ms += 50;
+    }
+}
+
+void BoardMqtt_StopPlaylistPlayback(void)
+{
+    BoardMqtt_StopPlaylistTask();
+    Board_Show_Black_Screen();
+}
+
+static esp_err_t BoardMqtt_StartPlaylistTask(void)
+{
+    BoardMqtt_StopPlaylistTask();
+    if (!BoardMqtt_BeginTask(BOARD_MQTT_TASK_PLAYLIST)) {
+        return ESP_ERR_INVALID_STATE;
+    }
+
+    s_playlist_stop_requested = false;
+    BaseType_t ret = xTaskCreate(BoardMqtt_PlaylistTask, "mqtt_playlist", BOARD_MQTT_PLAYLIST_TASK_STACK_SIZE,
+                                 NULL, BOARD_MQTT_PLAYLIST_TASK_PRIORITY, &s_playlist_task_handle);
+    if (ret != pdPASS) {
+        s_playlist_task_handle = NULL;
+        BoardMqtt_FinishTask(BOARD_MQTT_TASK_PLAYLIST);
+        return ESP_FAIL;
+    }
+    return ESP_OK;
+}
+
+void BoardMqtt_ResumePlaylistIfUnfinished(void)
+{
+    ESP_LOGI(TAG, "checking unfinished MQTT playlist for offline resume");
+
+    char *json = BoardMqtt_ReadPlaylistFile();
+    if (!json) {
+        ESP_LOGW(TAG, "no local MQTT playlist file for offline resume: %s", BOARD_MQTT_PLAYLIST_PATH);
+        BoardMqtt_ShowPlaylistAlert("娌℃湁鏈湴鎾斁娓呭崟", "circle_info");
+        return;
+    }
+
+    cJSON *root = cJSON_Parse(json);
+    free(json);
+    if (!root) {
+        ESP_LOGW(TAG, "local MQTT playlist file is not valid JSON");
+        BoardMqtt_ShowPlaylistAlert("鏈湴鎾斁娓呭崟鏍煎紡閿欒", "circle_xmark");
+        return;
+    }
+
+    bool has_unfinished = BoardMqtt_PlaylistHasUnfinished(root);
+    cJSON_Delete(root);
+    if (!has_unfinished) {
+        ESP_LOGW(TAG, "local MQTT playlist has no unfinished items");
+        BoardMqtt_ShowPlaylistAlert("鎾斁娓呭崟宸插畬鎴�", "check");
+        return;
+    }
+
+    BoardMqtt_ShowPlaylistAlert("Wi-Fi 杩炴帴澶辫触锛岀户缁挱鏀炬湰鍦版竻鍗�", "music");
+    esp_err_t err = BoardMqtt_StartPlaylistTask();
+    if (err == ESP_OK) {
+        ESP_LOGI(TAG, "resumed unfinished MQTT playlist");
+    } else {
+        ESP_LOGW(TAG, "failed to resume MQTT playlist: %s", esp_err_to_name(err));
+        BoardMqtt_ShowPlaylistAlert("鏈湴娓呭崟缁挱澶辫触", "circle_xmark");
+    }
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandlePlayMedia(const char *req_id, cJSON *payload)
+{
+    cJSON *items = cJSON_GetObjectItem(payload, "items");
+    if (!cJSON_IsArray(items) || cJSON_GetArraySize(items) <= 0) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing media playlist items");
+    }
+
+    BoardMqtt_StopPlaylistTask();
+
+    esp_err_t err = BoardMqtt_SavePlaylist(req_id, items);
+    if (err == ESP_ERR_INVALID_ARG) {
+        BoardMqtt_ShowPlaylistAlert("鎾斁娓呭崟鍙傛暟鏃犳晥", "circle_xmark");
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid media playlist item");
+    }
+    if (err == ESP_ERR_NOT_SUPPORTED) {
+        BoardMqtt_ShowPlaylistAlert("鎾斁娓呭崟鍙敮鎸� MP3", "circle_xmark");
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 playback is supported");
+    }
+    if (err == ESP_ERR_NOT_FOUND) {
+        BoardMqtt_ShowPlaylistAlert("鎾斁娓呭崟鏂囦欢涓嶅瓨鍦�", "circle_xmark");
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_NOT_FOUND, "media file not found");
+    }
+    if (err != ESP_OK) {
+        BoardMqtt_ShowPlaylistAlert("淇濆瓨鎾斁娓呭崟澶辫触", "circle_xmark");
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+    }
+
+    err = BoardMqtt_StartPlaylistTask();
+    if (err != ESP_OK) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+    }
+    BoardMqtt_ShowPlaylistAlert("宸叉帴鏀舵挱鏀炬竻鍗�", "music");
+    BoardMqtt_PublishProgress(req_id, 0, "media playlist accepted");
+    return BoardMqtt_ResultOk("media playlist started");
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleStopMedia(cJSON *payload)
+{
+    (void)payload;
+    BoardMqtt_StopPlaylistPlayback();
+    return BoardMqtt_ResultOk("media playback stopped");
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleDeleteFile(cJSON *payload)
+{
+    cJSON *path = cJSON_GetObjectItem(payload, "path");
+    if (!cJSON_IsString(path)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing file path");
+    }
+    esp_err_t err = SDCard_Delete_File_By_Business_Path(path->valuestring);
+    if (err == ESP_OK) {
+        return BoardMqtt_ResultOk("file deleted");
+    }
+    return BoardMqtt_ResultFail(err == ESP_ERR_NOT_FOUND ? BOARD_MQTT_ERR_NOT_FOUND : BOARD_MQTT_ERR_EXEC_FAILED,
+        esp_err_to_name(err));
+}
+
+static bool BoardMqtt_ParseTime(const char *value, int *hour, int *minute, int *second)
+{
+    char tail = 0;
+    if (!value || !hour || !minute || !second) {
+        return false;
+    }
+    if (sscanf(value, "%2d:%2d:%2d%c", hour, minute, second, &tail) == 3) {
+        return *hour >= 0 && *hour <= 23 && *minute >= 0 && *minute <= 59 && *second >= 0 && *second <= 59;
+    }
+    *second = 0;
+    return sscanf(value, "%2d:%2d%c", hour, minute, &tail) == 2 &&
+        *hour >= 0 && *hour <= 23 && *minute >= 0 && *minute <= 59;
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleSetAlarm(cJSON *payload)
+{
+    cJSON *id = cJSON_GetObjectItem(payload, "id");
+    if (!cJSON_IsString(id)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing alarm id");
+    }
+
+    cJSON *command = cJSON_GetObjectItem(payload, "action");
+    if (cJSON_IsString(command) && strcmp(command->valuestring, "delete") == 0) {
+        return BoardMqtt_HandleUnsetAlarm(payload);
+    }
+
+    cJSON *time_value = cJSON_GetObjectItem(payload, "time");
+    cJSON *repeat = cJSON_GetObjectItem(payload, "repeat");
+    if (!cJSON_IsString(time_value) || !cJSON_IsArray(repeat) || cJSON_GetArraySize(repeat) == 0) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid alarm payload");
+    }
+
+    int hour = 0, minute = 0, second = 0;
+    if (!BoardMqtt_ParseTime(time_value->valuestring, &hour, &minute, &second)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid alarm time");
+    }
+
+    uint8_t repeat_mask = 0;
+    cJSON *day = NULL;
+    cJSON_ArrayForEach(day, repeat) {
+        int value = 0;
+        if (!BoardMqtt_NumberToInt(day, &value) || value < 1 || value > 7) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid alarm repeat");
+        }
+        switch (value) {
+        case 1: repeat_mask |= ALARM_REPEAT_MON; break;
+        case 2: repeat_mask |= ALARM_REPEAT_TUE; break;
+        case 3: repeat_mask |= ALARM_REPEAT_WED; break;
+        case 4: repeat_mask |= ALARM_REPEAT_THU; break;
+        case 5: repeat_mask |= ALARM_REPEAT_FRI; break;
+        case 6: repeat_mask |= ALARM_REPEAT_SAT; break;
+        case 7: repeat_mask |= ALARM_REPEAT_SUN; break;
+        default: break;
+        }
+    }
+
+    esp_err_t err = DS2312_Set_Alarm_Id_Saved(id->valuestring, hour, minute, second, repeat_mask);
+    return err == ESP_OK ? BoardMqtt_ResultOk("alarm updated") :
+        BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleUnsetAlarm(cJSON *payload)
+{
+    cJSON *id = cJSON_GetObjectItem(payload, "id");
+    if (!cJSON_IsString(id)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing alarm id");
+    }
+    esp_err_t err = DS2312_Delete_Alarm_Id_Saved(id->valuestring);
+    if (err == ESP_OK) {
+        return BoardMqtt_ResultOk("alarm deleted");
+    }
+    return BoardMqtt_ResultFail(err == ESP_ERR_NOT_FOUND ? BOARD_MQTT_ERR_NOT_FOUND : BOARD_MQTT_ERR_EXEC_FAILED,
+        esp_err_to_name(err));
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleReboot(cJSON *payload)
+{
+    int delay = 0;
+    cJSON *delay_item = cJSON_GetObjectItem(payload, "delay");
+    if (delay_item && (!BoardMqtt_NumberToInt(delay_item, &delay) || delay < 0 || delay > 3600)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid reboot delay");
+    }
+    esp_err_t err = Board_Reboot_Delayed(delay);
+    return err == ESP_OK ? BoardMqtt_ResultOk("reboot scheduled") :
+        BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+}
+
+static bool BoardMqtt_HasPrefix(const char *value, const char *prefix)
+{
+    return value && prefix && strncmp(value, prefix, strlen(prefix)) == 0;
+}
+
+static bool BoardMqtt_IsMd5Hex(const char *value)
+{
+    if (!value || strlen(value) != 32) {
+        return false;
+    }
+    for (const char *p = value; *p; ++p) {
+        if (!((*p >= '0' && *p <= '9') || (*p >= 'a' && *p <= 'f') || (*p >= 'A' && *p <= 'F'))) {
+            return false;
+        }
+    }
+    return true;
+}
+
+static bool BoardMqtt_IsSafeCourseId(const char *course_id)
+{
+    if (!course_id || course_id[0] == '\0' || strlen(course_id) >= ALARM_ID_MAX_LEN) {
+        return false;
+    }
+    for (const char *p = course_id; *p; ++p) {
+        if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') ||
+              *p == '_' || *p == '-')) {
+            return false;
+        }
+    }
+    return true;
+}
+
+static bool BoardMqtt_CopyString(char *dest, size_t dest_size, const char *src)
+{
+    if (!dest || dest_size == 0 || !src) {
+        return false;
+    }
+    int written = snprintf(dest, dest_size, "%s", src);
+    return written >= 0 && (size_t)written < dest_size;
+}
+
+static bool BoardMqtt_DownloadProgressCallback(int progress, const char *info, void *ctx)
+{
+    board_mqtt_download_progress_ctx_t *progress_ctx = (board_mqtt_download_progress_ctx_t *)ctx;
+    if (!progress_ctx || !progress_ctx->req_id) {
+        return true;
+    }
+
+    if (s_download_cancel_requested) {
+        ESP_LOGI(TAG, "Cancel download by request");
+        return false;
+    }
+    if (Board_Is_Voice_Session_Active()) {
+        s_download_voice_yield_requested = true;
+        ESP_LOGI(TAG, "Cancel download for voice session");
+        return false;
+    }
+
+    int64_t now = esp_timer_get_time();
+    bool force = progress >= 100 || progress_ctx->last_progress < 0;
+    if (!force && progress == progress_ctx->last_progress) {
+        return true;
+    }
+    if (!force && progress_ctx->last_publish_us > 0 && now - progress_ctx->last_publish_us < 2000000) {
+        return true;
+    }
+
+    progress_ctx->last_publish_us = now;
+    progress_ctx->last_progress = progress;
+    BoardMqtt_PublishProgress(progress_ctx->req_id, progress, info);
+    return true;
+}
+
+static bool BoardMqtt_ManifestProgressCallback(int progress, const char *info, void *ctx)
+{
+    board_mqtt_manifest_progress_ctx_t *progress_ctx = (board_mqtt_manifest_progress_ctx_t *)ctx;
+    if (!progress_ctx || !progress_ctx->req_id || progress_ctx->asset_count == 0) {
+        return true;
+    }
+
+    if (s_download_cancel_requested) {
+        ESP_LOGI(TAG, "Cancel manifest download by request");
+        return false;
+    }
+    if (Board_Is_Voice_Session_Active()) {
+        s_download_voice_yield_requested = true;
+        ESP_LOGI(TAG, "Cancel manifest download for voice session");
+        return false;
+    }
+
+    if (progress < 0) {
+        progress = 0;
+    } else if (progress > 100) {
+        progress = 100;
+    }
+    int aggregate_progress = (int)(((progress_ctx->asset_index * 100) + (size_t)progress) / progress_ctx->asset_count);
+
+    int64_t now = esp_timer_get_time();
+    bool force = progress <= 0 || progress >= 100 || aggregate_progress >= 100;
+    if (!force && aggregate_progress == progress_ctx->last_progress) {
+        return true;
+    }
+    if (!force && progress_ctx->last_publish_us > 0 && now - progress_ctx->last_publish_us < 2000000) {
+        return true;
+    }
+
+    char progress_info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN];
+    snprintf(progress_info, sizeof(progress_info), "asset %u/%u %s",
+             (unsigned)(progress_ctx->asset_index + 1), (unsigned)progress_ctx->asset_count, info ? info : "downloading");
+    progress_ctx->last_publish_us = now;
+    progress_ctx->last_progress = aggregate_progress;
+    BoardMqtt_PublishProgress(progress_ctx->req_id, aggregate_progress, progress_info);
+    return true;
+}
+
+static void BoardMqtt_OtaProgressCallback(int progress, size_t speed, void *ctx)
+{
+    board_mqtt_ota_job_t *job = (board_mqtt_ota_job_t *)ctx;
+    if (!job) {
+        return;
+    }
+
+    int64_t now = esp_timer_get_time();
+    bool force = progress >= 100 || job->last_progress < 0;
+    if (!force && progress == job->last_progress) {
+        return;
+    }
+    if (!force && job->last_publish_us > 0 && now - job->last_publish_us < 2000000) {
+        return;
+    }
+
+    job->last_publish_us = now;
+    job->last_progress = progress;
+    char info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN];
+    snprintf(info, sizeof(info), "upgrading firmware %uKB/s", (unsigned)(speed / 1024));
+    BoardMqtt_PublishProgress(job->req_id, progress, info);
+}
+
+static void BoardMqtt_OtaCompleteCallback(bool success, void *ctx)
+{
+    board_mqtt_ota_job_t *job = (board_mqtt_ota_job_t *)ctx;
+    if (!job) {
+        BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA);
+        return;
+    }
+
+    BoardMqtt_WaitConnectedForFinalReport();
+    if (success) {
+        BoardMqtt_PublishProgress(job->req_id, 100, "ota completed");
+        BoardMqtt_PublishAck(job->req_id, true, 0, "ota completed, rebooting");
+    } else {
+        BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED, "ota failed");
+    }
+    BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA);
+    free(job);
+}
+
+static void BoardMqtt_OtaTask(void *arg)
+{
+    board_mqtt_ota_job_t *job = (board_mqtt_ota_job_t *)arg;
+    if (!job) {
+        vTaskDelete(NULL);
+        return;
+    }
+
+    BoardMqtt_PublishProgress(job->req_id, 0, "starting ota");
+    esp_err_t err = Board_Start_Firmware_Upgrade(job->url, job->version,
+                                                 BoardMqtt_OtaProgressCallback,
+                                                 BoardMqtt_OtaCompleteCallback,
+                                                 job);
+    if (err != ESP_OK) {
+        BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED, esp_err_to_name(err));
+        BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA);
+        free(job);
+    }
+    vTaskDelete(NULL);
+}
+
+static void BoardMqtt_ManifestTask(void *arg)
+{
+    board_mqtt_manifest_job_t *job = (board_mqtt_manifest_job_t *)arg;
+    if (!job) {
+        vTaskDelete(NULL);
+        return;
+    }
+
+    if (job->replace_existing_download) {
+        if (!BoardMqtt_WaitDownloadCanceled(30000) || !BoardMqtt_ActivatePendingManifest()) {
+            BoardMqtt_ClearPendingManifest();
+            BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_BUSY, "previous download stopping");
+            cJSON_free(job->manifest_json);
+            free(job);
+            vTaskDelete(NULL);
+            return;
+        }
+    }
+
+    Board_Acquire_Sleep_Inhibit();
+    s_download_voice_yield_requested = false;
+
+    char final_path[SDCARD_PATH_MAX_LEN] = {0};
+    char part_path[SDCARD_PATH_MAX_LEN] = {0};
+    char error_info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN] = {0};
+    esp_err_t err = ESP_OK;
+
+    vTaskDelay(pdMS_TO_TICKS(100));
+    BoardMqtt_PublishProgress(job->req_id, 0, "saving manifest");
+    err = SDCard_Save_Manifest(job->course_id, job->manifest_json);
+    if (err != ESP_OK) {
+        snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err));
+    }
+
+    for (size_t i = 0; err == ESP_OK && i < job->asset_count; ++i) {
+        board_mqtt_manifest_asset_t *asset = &job->assets[i];
+        memset(final_path, 0, sizeof(final_path));
+        memset(part_path, 0, sizeof(part_path));
+        memset(error_info, 0, sizeof(error_info));
+
+        err = SDCard_Prepare_Business_File(asset->target_path, final_path, sizeof(final_path), part_path, sizeof(part_path));
+        if (err != ESP_OK) {
+            snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err));
+            break;
+        }
+
+        board_mqtt_manifest_progress_ctx_t progress_ctx = {
+            .req_id = job->req_id,
+            .asset_index = i,
+            .asset_count = job->asset_count,
+            .last_publish_us = 0,
+            .last_progress = -1,
+        };
+        err = Board_Download_File_To_Path(asset->url, part_path, asset->md5, BoardMqtt_ManifestProgressCallback,
+                                          &progress_ctx, error_info, sizeof(error_info));
+        if (err != ESP_OK) {
+            SDCard_Remove_File_If_Exists(part_path);
+            if (s_download_voice_yield_requested || s_download_cancel_requested) {
+                int canceled_progress = progress_ctx.last_progress >= 0 ? progress_ctx.last_progress : 0;
+                const char *cancel_info = s_download_voice_yield_requested ?
+                    "download canceled for voice session" : "download canceled";
+                s_download_voice_yield_requested = false;
+                BoardMqtt_PublishProgress(job->req_id, canceled_progress, cancel_info);
+                snprintf(error_info, sizeof(error_info), "%s", cancel_info);
+            }
+            break;
+        }
+
+        err = SDCard_Commit_Part_File(part_path, final_path);
+        if (err != ESP_OK) {
+            snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err));
+            SDCard_Remove_File_If_Exists(part_path);
+            break;
+        }
+
+        int completed_progress = (int)(((i + 1) * 100) / job->asset_count);
+        char progress_info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN];
+        snprintf(progress_info, sizeof(progress_info), "asset %u/%u completed",
+                 (unsigned)(i + 1), (unsigned)job->asset_count);
+        BoardMqtt_PublishProgress(job->req_id, completed_progress, progress_info);
+    }
+
+    bool replaced_by_new_manifest = err != ESP_OK && s_download_cancel_requested && BoardMqtt_HasPendingManifest();
+    if (!replaced_by_new_manifest) {
+        BoardMqtt_WaitConnectedForFinalReport();
+        if (err == ESP_OK) {
+            BoardMqtt_PublishProgress(job->req_id, 100, "manifest assets downloaded");
+            BoardMqtt_PublishAck(job->req_id, true, 0, "manifest saved and assets downloaded");
+        } else {
+            BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED,
+                                 error_info[0] ? error_info : esp_err_to_name(err));
+        }
+    }
+
+    s_download_voice_yield_requested = false;
+    Board_Release_Sleep_Inhibit();
+    BoardMqtt_FinishTask(BOARD_MQTT_TASK_MANIFEST);
+    cJSON_free(job->manifest_json);
+    free(job);
+    vTaskDelete(NULL);
+}
+
+static void BoardMqtt_DownloadTask(void *arg)
+{
+    board_mqtt_download_job_t *job = (board_mqtt_download_job_t *)arg;
+    if (!job) {
+        vTaskDelete(NULL);
+        return;
+    }
+
+    Board_Acquire_Sleep_Inhibit();
+    s_download_voice_yield_requested = false;
+
+    char final_path[SDCARD_PATH_MAX_LEN] = {0};
+    char part_path[SDCARD_PATH_MAX_LEN] = {0};
+    char error_info[BOARD_MQTT_DOWNLOAD_INFO_MAX_LEN] = {0};
+    board_mqtt_download_progress_ctx_t progress_ctx = {
+        .req_id = job->req_id,
+        .last_publish_us = 0,
+        .last_progress = -1,
+    };
+
+    BoardMqtt_PublishProgress(job->req_id, 0, "starting download");
+    esp_err_t err = SDCard_Prepare_Business_File(job->target_path, final_path, sizeof(final_path), part_path, sizeof(part_path));
+    if (err == ESP_OK) {
+        err = Board_Download_File_To_Path(job->url, part_path, job->md5, BoardMqtt_DownloadProgressCallback,
+                                          &progress_ctx, error_info, sizeof(error_info));
+    } else {
+        snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err));
+    }
+
+    if (err != ESP_OK) {
+        SDCard_Remove_File_If_Exists(part_path);
+        if (s_download_voice_yield_requested || s_download_cancel_requested) {
+            int canceled_progress = progress_ctx.last_progress >= 0 ? progress_ctx.last_progress : 0;
+            const char *cancel_info = s_download_voice_yield_requested ?
+                "download canceled for voice session" : "download canceled";
+            s_download_voice_yield_requested = false;
+            BoardMqtt_PublishProgress(job->req_id, canceled_progress, cancel_info);
+            snprintf(error_info, sizeof(error_info), "%s", cancel_info);
+        }
+    } else {
+        err = SDCard_Commit_Part_File(part_path, final_path);
+        if (err != ESP_OK) {
+            snprintf(error_info, sizeof(error_info), "%s", esp_err_to_name(err));
+            SDCard_Remove_File_If_Exists(part_path);
+        }
+    }
+
+    bool replaced_by_new_manifest = err != ESP_OK && s_download_cancel_requested && BoardMqtt_HasPendingManifest();
+    if (!replaced_by_new_manifest) {
+        BoardMqtt_WaitConnectedForFinalReport();
+        if (err == ESP_OK) {
+            BoardMqtt_PublishProgress(job->req_id, 100, "download completed");
+            BoardMqtt_PublishAck(job->req_id, true, 0, "download completed");
+        } else {
+            BoardMqtt_PublishAck(job->req_id, false, BOARD_MQTT_ERR_EXEC_FAILED,
+                                 error_info[0] ? error_info : esp_err_to_name(err));
+        }
+    }
+
+    s_download_voice_yield_requested = false;
+    BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD);
+    Board_Release_Sleep_Inhibit();
+    free(job);
+    vTaskDelete(NULL);
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleDownload(const char *req_id, cJSON *payload)
+{
+    cJSON *url = cJSON_GetObjectItem(payload, "url");
+    cJSON *target_path = cJSON_GetObjectItem(payload, "target_path");
+    cJSON *md5 = cJSON_GetObjectItem(payload, "md5");
+    if (!cJSON_IsString(url) || !cJSON_IsString(target_path)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid download payload");
+    }
+    if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid download url");
+    }
+    if (!BoardMqtt_ValidateOptionalMd5(md5)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid md5");
+    }
+
+    char business_path[SDCARD_PATH_MAX_LEN];
+    if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), target_path->valuestring)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid target path");
+    }
+
+    char sd_path[SDCARD_PATH_MAX_LEN];
+    esp_err_t err = SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path));
+    if (err != ESP_OK) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid target path");
+    }
+    if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg download is supported");
+    }
+    BoardMqtt_StopPlaylistTask();
+    if (!BoardMqtt_BeginTask(BOARD_MQTT_TASK_DOWNLOAD)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "download busy");
+    }
+
+    board_mqtt_download_job_t *job = (board_mqtt_download_job_t *)calloc(1, sizeof(board_mqtt_download_job_t));
+    if (!job) {
+        BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "no memory for download job");
+    }
+    if (!BoardMqtt_CopyString(job->req_id, sizeof(job->req_id), req_id) ||
+        !BoardMqtt_CopyString(job->url, sizeof(job->url), url->valuestring) ||
+        !BoardMqtt_CopyString(job->target_path, sizeof(job->target_path), business_path) ||
+        !BoardMqtt_CopyOptionalMd5(job->md5, sizeof(job->md5), md5)) {
+        BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD);
+        free(job);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "download field too long");
+    }
+
+    if (SDCard_Is_Playing()) {
+        ESP_LOGI(TAG, "Stop MP3 playback before MQTT download");
+        SDCard_Stop_Play();
+    }
+
+    BaseType_t ret = xTaskCreate(BoardMqtt_DownloadTask, "mqtt_download", 6144, job, 5, NULL);
+    if (ret != pdPASS) {
+        BoardMqtt_FinishTask(BOARD_MQTT_TASK_DOWNLOAD);
+        free(job);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "failed to create download task");
+    }
+    return BoardMqtt_ResultOk("download accepted");
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleOtaUpdate(const char *req_id, cJSON *payload)
+{
+    cJSON *url = cJSON_GetObjectItem(payload, "url");
+    cJSON *version = cJSON_GetObjectItem(payload, "version");
+    if (!cJSON_IsString(url)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "missing ota url");
+    }
+    if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid ota url");
+    }
+    if (version && !cJSON_IsString(version)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid ota version");
+    }
+    if (!BoardMqtt_BeginTask(BOARD_MQTT_TASK_OTA)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "task busy");
+    }
+
+    board_mqtt_ota_job_t *job = (board_mqtt_ota_job_t *)calloc(1, sizeof(board_mqtt_ota_job_t));
+    if (!job) {
+        BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "no memory for ota job");
+    }
+    job->last_progress = -1;
+    if (!BoardMqtt_CopyString(job->req_id, sizeof(job->req_id), req_id) ||
+        !BoardMqtt_CopyString(job->url, sizeof(job->url), url->valuestring)) {
+        BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA);
+        free(job);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "ota field too long");
+    }
+    if (cJSON_IsString(version) && version->valuestring[0] != '\0' &&
+        !BoardMqtt_CopyString(job->version, sizeof(job->version), version->valuestring)) {
+        BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA);
+        free(job);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "ota version too long");
+    }
+
+    BaseType_t ret = xTaskCreate(BoardMqtt_OtaTask, "mqtt_ota", 4096, job, 5, NULL);
+    if (ret != pdPASS) {
+        BoardMqtt_FinishTask(BOARD_MQTT_TASK_OTA);
+        free(job);
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_EXEC_FAILED, "failed to create ota task");
+    }
+    return BoardMqtt_ResultOk("ota accepted");
+}
+
+static board_mqtt_action_result_t BoardMqtt_HandleUpdateManifest(const char *req_id, cJSON *payload)
+{
+    cJSON *course_id = cJSON_GetObjectItem(payload, "course_id");
+    cJSON *assets = cJSON_GetObjectItem(payload, "assets");
+    if (!cJSON_IsString(course_id) || !BoardMqtt_IsSafeCourseId(course_id->valuestring) ||
+        !cJSON_IsArray(assets) || cJSON_GetArraySize(assets) <= 0) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest payload");
+    }
+    BoardMqtt_StopPlaylistTask();
+
+    bool replace_existing_download = false;
+    board_mqtt_task_type_t current_type = BoardMqtt_GetTaskType();
+    if (BoardMqtt_IsDownloadTask(current_type)) {
+        ESP_LOGI(TAG, "Cancel current download before starting new manifest task");
+        if (!BoardMqtt_BeginManifestReplacement()) {
+            return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "task busy");
+        }
+        replace_existing_download = true;
+    } else if (!BoardMqtt_BeginTask(BOARD_MQTT_TASK_MANIFEST)) {
+        return BoardMqtt_ResultFail(BOARD_MQTT_ERR_BUSY, "task busy");
+    }
+
+#define BOARD_MQTT_RETURN_MANIFEST_FAIL(code, message) do { \
+        if (replace_existing_download) { \
+            BoardMqtt_ClearPendingManifest(); \
+        } else { \
+            BoardMqtt_FinishTask(BOARD_MQTT_TASK_MANIFEST); \
+        } \
+        return BoardMqtt_ResultFail((code), (message)); \
+    } while (0)
+
+    int asset_count_int = cJSON_GetArraySize(assets);
+    size_t source_asset_count = (size_t)asset_count_int;
+    if (source_asset_count > SIZE_MAX / 2) {
+        BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "manifest asset count too large");
+    }
+    size_t max_asset_count = source_asset_count * 2;
+    if (max_asset_count > (SIZE_MAX - sizeof(board_mqtt_manifest_job_t)) / sizeof(board_mqtt_manifest_asset_t)) {
+        BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "manifest asset count too large");
+    }
+
+    char *manifest_json = cJSON_PrintUnformatted((cJSON *)payload);
+    if (!manifest_json) {
+        BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "failed to format manifest");
+    }
+
+    size_t job_size = sizeof(board_mqtt_manifest_job_t) + max_asset_count * sizeof(board_mqtt_manifest_asset_t);
+    board_mqtt_manifest_job_t *job = (board_mqtt_manifest_job_t *)calloc(1, job_size);
+    if (!job) {
+        cJSON_free(manifest_json);
+        BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "no memory for manifest job");
+    }
+    job->manifest_json = manifest_json;
+    job->replace_existing_download = replace_existing_download;
+
+#define BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(code, message) do { \
+        cJSON_free(job->manifest_json); \
+        free(job); \
+        BOARD_MQTT_RETURN_MANIFEST_FAIL((code), (message)); \
+    } while (0)
+
+    if (!BoardMqtt_CopyString(job->req_id, sizeof(job->req_id), req_id) ||
+        !BoardMqtt_CopyString(job->course_id, sizeof(job->course_id), course_id->valuestring)) {
+        BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest field too long");
+    }
+
+    size_t index = 0;
+    cJSON *asset = NULL;
+    cJSON_ArrayForEach(asset, assets) {
+        cJSON *url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "url") : NULL;
+        cJSON *target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "target_path") : NULL;
+        cJSON *md5 = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "md5") : NULL;
+        if (!cJSON_IsString(url) || !cJSON_IsString(target_path)) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset");
+        }
+        if (!BoardMqtt_HasPrefix(url->valuestring, "http://") && !BoardMqtt_HasPrefix(url->valuestring, "https://")) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset url");
+        }
+        if (!BoardMqtt_ValidateOptionalMd5(md5)) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest asset md5");
+        }
+
+        char business_path[SDCARD_PATH_MAX_LEN];
+        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), target_path->valuestring)) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+        }
+
+        char sd_path[SDCARD_PATH_MAX_LEN];
+        if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest target path");
+        }
+        if (!BoardMqtt_IsSupportedResourcePath(sd_path)) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only mp3 and jpg manifest assets are supported");
+        }
+
+        board_mqtt_manifest_asset_t *job_asset = &job->assets[index];
+        if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), url->valuestring) ||
+            !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path) ||
+            !BoardMqtt_CopyOptionalMd5(job_asset->md5, sizeof(job_asset->md5), md5)) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest asset field too long");
+        }
+        ++index;
+
+        cJSON *cover_url = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_url") : NULL;
+        cJSON *cover_target_path = cJSON_IsObject(asset) ? cJSON_GetObjectItem(asset, "cover_target_path") : NULL;
+        if (!cJSON_IsString(cover_url) || !cJSON_IsString(cover_target_path) ||
+            cover_url->valuestring[0] == '\0' || cover_target_path->valuestring[0] == '\0') {
+            continue;
+        }
+        if (!BoardMqtt_HasPrefix(cover_url->valuestring, "http://") && !BoardMqtt_HasPrefix(cover_url->valuestring, "https://")) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover url");
+        }
+        if (!BoardMqtt_CopyBusinessPath(business_path, sizeof(business_path), cover_target_path->valuestring)) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
+        }
+        if (SDCard_Map_Business_Path(business_path, sd_path, sizeof(sd_path)) != ESP_OK) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "invalid manifest cover path");
+        }
+        const char *cover_dot = strrchr(sd_path, '.');
+        if (!cover_dot || strcasecmp(cover_dot, ".jpg") != 0) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_UNSUPPORTED, "only jpg cover assets are supported");
+        }
+
+        job_asset = &job->assets[index];
+        if (!BoardMqtt_CopyString(job_asset->url, sizeof(job_asset->url), cover_url->valuestring) ||
+            !BoardMqtt_CopyString(job_asset->target_path, sizeof(job_asset->target_path), business_path)) {
+            BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL(BOARD_MQTT_ERR_INVALID_PARAM, "manifest cover field too long");
+        }
+        ++index;
+    }
+    job->asset_count = index;
+
+    if (SDCard_Is_Playing()) {
+        ESP_LOGI(TAG, "Stop MP3 playback before MQTT manifest download");
+        SDCard_Stop_Play();
+    }
+
+    BaseType_t ret = xTaskCreate(BoardMqtt_ManifestTask, "mqtt_manifest", 6144, job, 5, NULL);
+    if (ret != pdPASS) {
+        cJSON_free(job->manifest_json);
+        free(job);
+        BOARD_MQTT_RETURN_MANIFEST_FAIL(BOARD_MQTT_ERR_EXEC_FAILED, "failed to create manifest task");
+    }
+#undef BOARD_MQTT_FREE_MANIFEST_JOB_AND_FAIL
+#undef BOARD_MQTT_RETURN_MANIFEST_FAIL
+    return BoardMqtt_ResultOk("manifest accepted");
+}
+
+static board_mqtt_action_result_t BoardMqtt_DispatchAction(const char *req_id, const char *action, cJSON *payload)
+{
+    if (strcmp(action, "SET_VOLUME") == 0) return BoardMqtt_HandleSetVolume(payload);
+    if (strcmp(action, "SET_POWER") == 0) return BoardMqtt_HandleSetPower(payload);
+    if (strcmp(action, "SET_CLOCK") == 0) return BoardMqtt_HandleSetClock(payload);
+    if (strcmp(action, "PLAY_MEDIA") == 0) return BoardMqtt_HandlePlayMedia(req_id, payload);
+    if (strcmp(action, "STOP_MEDIA") == 0) return BoardMqtt_HandleStopMedia(payload);
+    if (strcmp(action, "DELETE_FILE") == 0) return BoardMqtt_HandleDeleteFile(payload);
+    if (strcmp(action, "SET_ALARM") == 0) return BoardMqtt_HandleSetAlarm(payload);
+    if (strcmp(action, "UNSET_ALARM") == 0) return BoardMqtt_HandleUnsetAlarm(payload);
+    if (strcmp(action, "REBOOT") == 0) return BoardMqtt_HandleReboot(payload);
+    if (strcmp(action, "UPDATE_MANIFEST") == 0 || strcmp(action, "SYNC_COURSE_TASK") == 0) return BoardMqtt_HandleUpdateManifest(req_id, payload);
+    if (strcmp(action, "DOWNLOAD") == 0) return BoardMqtt_HandleDownload(req_id, payload);
+    if (strcmp(action, "OTA_UPDATE") == 0) return BoardMqtt_HandleOtaUpdate(req_id, payload);
+    return BoardMqtt_ResultFail(BOARD_MQTT_ERR_UNSUPPORTED, "unsupported action");
+}
+
+static board_mqtt_topic_type_t BoardMqtt_GetTopicType(const char *topic)
+{
+    if (strcmp(topic, s_cmd_ctl_topic) == 0) {
+        return BOARD_MQTT_TOPIC_CTL;
+    }
+    if (strcmp(topic, s_cmd_task_topic) == 0) {
+        return BOARD_MQTT_TOPIC_TASK;
+    }
+    return BOARD_MQTT_TOPIC_UNKNOWN;
+}
+
+static void BoardMqtt_ResetPayloadAssembly(void)
+{
+    free(s_payload_assembly.payload);
+    memset(&s_payload_assembly, 0, sizeof(s_payload_assembly));
+}
+
+static void BoardMqtt_HandleDataEvent(esp_mqtt_event_t *event)
+{
+    if (!event || event->data_len <= 0 || !event->data || event->total_data_len <= 0 || event->current_data_offset < 0 ||
+        event->current_data_offset > event->total_data_len || event->data_len > event->total_data_len - event->current_data_offset) {
+        ESP_LOGW(TAG, "Invalid MQTT data event");
+        BoardMqtt_ResetPayloadAssembly();
+        return;
+    }
+
+    if (event->total_data_len > BOARD_MQTT_PAYLOAD_MAX_LEN) {
+        ESP_LOGW(TAG, "MQTT payload too large: %d", event->total_data_len);
+        BoardMqtt_ResetPayloadAssembly();
+        return;
+    }
+
+    if (event->current_data_offset == 0) {
+        BoardMqtt_ResetPayloadAssembly();
+        if (!event->topic || event->topic_len <= 0 || event->topic_len >= BOARD_MQTT_TOPIC_MAX_LEN) {
+            ESP_LOGW(TAG, "Invalid MQTT command topic");
+            return;
+        }
+
+        if (event->data_len == event->total_data_len) {
+            BoardMqtt_HandleCommand(event->topic, event->topic_len, event->data, event->data_len);
+            return;
+        }
+
+        s_payload_assembly.payload = (char *)calloc(1, event->total_data_len + 1);
+        if (!s_payload_assembly.payload) {
+            ESP_LOGE(TAG, "Failed to allocate MQTT payload assembly buffer");
+            BoardMqtt_ResetPayloadAssembly();
+            return;
+        }
+        memcpy(s_payload_assembly.topic, event->topic, event->topic_len);
+        s_payload_assembly.topic[event->topic_len] = '\0';
+        s_payload_assembly.topic_len = event->topic_len;
+        s_payload_assembly.total_len = event->total_data_len;
+        s_payload_assembly.received_len = 0;
+    } else if (!s_payload_assembly.payload || s_payload_assembly.total_len != event->total_data_len ||
+               event->current_data_offset != s_payload_assembly.received_len) {
+        ESP_LOGW(TAG, "Invalid MQTT payload fragment sequence");
+        BoardMqtt_ResetPayloadAssembly();
+        return;
+    }
+
+    memcpy(s_payload_assembly.payload + event->current_data_offset, event->data, event->data_len);
+    s_payload_assembly.received_len += event->data_len;
+
+    if (s_payload_assembly.received_len == s_payload_assembly.total_len) {
+        BoardMqtt_HandleCommand(s_payload_assembly.topic, s_payload_assembly.topic_len,
+                                s_payload_assembly.payload, s_payload_assembly.total_len);
+        BoardMqtt_ResetPayloadAssembly();
+    } else if (s_payload_assembly.received_len > s_payload_assembly.total_len) {
+        ESP_LOGW(TAG, "Invalid MQTT payload assembly length");
+        BoardMqtt_ResetPayloadAssembly();
+    }
+}
+
+static void BoardMqtt_HandleCommand(const char *topic, int topic_len, const char *data, int data_len)
+{
+    char *topic_text = BoardMqtt_CopySegment(topic, topic_len);
+    char *payload_text = BoardMqtt_CopySegment(data, data_len);
+    if (!topic_text || !payload_text) {
+        free(topic_text);
+        free(payload_text);
+        return;
+    }
+
+    cJSON *root = cJSON_Parse(payload_text);
+    if (!root) {
+        BoardMqtt_LogReceivedText(topic_text, payload_text);
+        free(topic_text);
+        free(payload_text);
+        return;
+    }
+    BoardMqtt_LogReceivedJson(topic_text, root);
+
+#if !BOARD_MQTT_EXECUTE_COMMANDS
+    cJSON_Delete(root);
+    free(topic_text);
+    free(payload_text);
+    return;
+#endif
+
+    const char *req_id_text = NULL;
+    cJSON *header = cJSON_GetObjectItem(root, "header");
+    cJSON *req_id = cJSON_IsObject(header) ? cJSON_GetObjectItem(header, "req_id") : NULL;
+    if (cJSON_IsString(req_id) && req_id->valuestring && req_id->valuestring[0] != '\0') {
+        req_id_text = req_id->valuestring;
+    } else {
+        ESP_LOGW(TAG, "MQTT command has no header.req_id, skip ACK");
+        cJSON_Delete(root);
+        free(topic_text);
+        free(payload_text);
+        return;
+    }
+
+    board_mqtt_topic_type_t topic_type = BoardMqtt_GetTopicType(topic_text);
+    cJSON *version = cJSON_IsObject(header) ? cJSON_GetObjectItem(header, "version") : NULL;
+    cJSON *device_id = cJSON_IsObject(header) ? cJSON_GetObjectItem(header, "device_id") : NULL;
+    cJSON *timestamp = cJSON_IsObject(header) ? cJSON_GetObjectItem(header, "timestamp") : NULL;
+    cJSON *action = cJSON_GetObjectItem(root, "action");
+    cJSON *payload = cJSON_GetObjectItem(root, "payload");
+
+    board_mqtt_action_result_t result = BoardMqtt_ResultOk("ok");
+    if (topic_type == BOARD_MQTT_TOPIC_UNKNOWN) {
+        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_TOPIC_MISMATCH, "invalid command topic");
+    } else if (!cJSON_IsString(version) || strcmp(version->valuestring, "1.0") != 0) {
+        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.version");
+    } else if (!cJSON_IsString(device_id) || strcmp(device_id->valuestring, s_device_id) != 0) {
+        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.device_id");
+    } else if (!cJSON_IsNumber(timestamp)) {
+        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid header.timestamp");
+    } else if (!cJSON_IsString(action) || action->valuestring[0] == '\0') {
+        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid action");
+    } else if (!cJSON_IsObject(payload)) {
+        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_INVALID_PARAM, "invalid payload");
+    } else if ((topic_type == BOARD_MQTT_TOPIC_CTL && !BoardMqtt_ActionIsCtl(action->valuestring)) ||
+               (topic_type == BOARD_MQTT_TOPIC_TASK && !BoardMqtt_ActionIsTask(action->valuestring))) {
+        result = BoardMqtt_ResultFail(BOARD_MQTT_ERR_TOPIC_MISMATCH, "action not allowed on this topic");
+    } else {
+        result = BoardMqtt_DispatchAction(req_id_text, action->valuestring, payload);
+    }
+
+    BoardMqtt_PublishAck(req_id_text, result.success, result.error_code, result.info);
+    cJSON_Delete(root);
+    free(topic_text);
+    free(payload_text);
+}
+
+static void BoardMqtt_ReconnectTask(void *arg)
+{
+    (void)arg;
+    while (s_mqtt_client && !s_mqtt_connected) {
+        if (Board_Is_Voice_Session_Active()) {
+            ESP_LOGI(TAG, "Delay local MQTT reconnect while voice session is active");
+            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_DELAY_MS));
+            continue;
+        }
+        ESP_LOGI(TAG, "Reconnecting local MQTT client");
+        esp_err_t err = esp_mqtt_client_reconnect(s_mqtt_client);
+        if (err != ESP_OK) {
+            ESP_LOGW(TAG, "Local MQTT reconnect failed: %s", esp_err_to_name(err));
+            vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_RECONNECT_DELAY_MS));
+            continue;
+        }
+        break;
+    }
+    s_reconnect_task_handle = NULL;
+    vTaskDelete(NULL);
+}
+
+static void BoardMqtt_ScheduleReconnect(void)
+{
+    if (!s_mqtt_client || s_reconnect_task_handle) {
+        return;
+    }
+    BaseType_t ret = xTaskCreate(BoardMqtt_ReconnectTask, "mqtt_reconnect", 4096, NULL, BOARD_MQTT_RECONNECT_TASK_PRIORITY, &s_reconnect_task_handle);
+    if (ret != pdPASS) {
+        ESP_LOGE(TAG, "Failed to create local MQTT reconnect task");
+        s_reconnect_task_handle = NULL;
+    }
+}
+
+static void BoardMqtt_StateTask(void *arg)
+{
+    (void)arg;
+    while (s_mqtt_client) {
+        vTaskDelay(pdMS_TO_TICKS(BOARD_MQTT_STATE_REPORT_INTERVAL_MS));
+        BoardMqtt_PublishState();
+    }
+    s_state_task_handle = NULL;
+    vTaskDelete(NULL);
+}
+
+static void BoardMqtt_StartStateTask(void)
+{
+    if (s_state_task_handle) {
+        return;
+    }
+    BaseType_t ret = xTaskCreate(BoardMqtt_StateTask, "mqtt_state", 4096, NULL, 2, &s_state_task_handle);
+    if (ret != pdPASS) {
+        ESP_LOGE(TAG, "Failed to create MQTT state report task");
+        s_state_task_handle = NULL;
+    }
+}
+
+static void BoardMqtt_Subscribe(const char *topic)
+{
+    int msg_id = esp_mqtt_client_subscribe_single(s_mqtt_client, topic, BOARD_MQTT_QOS);
+    if (msg_id >= 0) {
+        ESP_LOGI(TAG, "Subscribing to %s, qos=%d, msg_id=%d", topic, BOARD_MQTT_QOS, msg_id);
+    } else {
+        ESP_LOGE(TAG, "Failed to subscribe to %s", topic);
+    }
+}
+
+static void BoardMqtt_EventHandler(void *handler_args, esp_event_base_t base, int32_t event_id, void *event_data)
+{
+    (void)handler_args;
+    (void)base;
+    esp_mqtt_event_t *event = (esp_mqtt_event_t *)event_data;
+
+    switch (event_id) {
+    case MQTT_EVENT_CONNECTED:
+        s_mqtt_connected = true;
+        ESP_LOGI(TAG, "MQTT business server connected, host=%s, port=%d, username=%s", BOARD_MQTT_BROKER_HOST, BOARD_MQTT_BROKER_PORT, BOARD_MQTT_USERNAME);
+        ESP_LOGI(TAG, "MQTT command topics ready, ctl=%s, task=%s", s_cmd_ctl_topic, s_cmd_task_topic);
+        BoardMqtt_Subscribe(s_cmd_ctl_topic);
+        BoardMqtt_Subscribe(s_cmd_task_topic);
+        BoardMqtt_PublishLog("MQTT connected");
+        BoardMqtt_PublishState();
+        break;
+    case MQTT_EVENT_DISCONNECTED:
+        s_mqtt_connected = false;
+        ESP_LOGW(TAG, "Disconnected from broker%s", BoardMqtt_GetTaskType() != BOARD_MQTT_TASK_NONE ? ", current task will continue locally" : "");
+        BoardMqtt_ScheduleReconnect();
+        break;
+    case MQTT_EVENT_SUBSCRIBED:
+        ESP_LOGI(TAG, "Subscribed, msg_id=%d", event->msg_id);
+        break;
+    case MQTT_EVENT_DATA:
+        BoardMqtt_HandleDataEvent(event);
+        break;
+    case MQTT_EVENT_ERROR:
+        s_mqtt_connected = false;
+        if (event->error_handle) {
+            ESP_LOGE(TAG, "MQTT error, esp_tls=%s, transport_sock_errno=%d",
+                     esp_err_to_name(event->error_handle->esp_tls_last_esp_err),
+                     event->error_handle->esp_transport_sock_errno);
+        } else {
+            ESP_LOGE(TAG, "MQTT error");
+        }
+        BoardMqtt_ScheduleReconnect();
+        break;
+    default:
+        break;
+    }
+}
+
+esp_err_t BoardMqtt_Start(const char *client_id)
+{
+    if (s_mqtt_client) {
+        ESP_LOGI(TAG, "MQTT client already started");
+        return ESP_OK;
+    }
+
+    if (!client_id || strlen(client_id) == 0) {
+        ESP_LOGE(TAG, "Invalid MQTT client id");
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    int id_len = snprintf(s_device_id, sizeof(s_device_id), "%s", client_id);
+    if (id_len < 0 || id_len >= (int)sizeof(s_device_id)) {
+        ESP_LOGE(TAG, "MQTT client id is too long");
+        return ESP_ERR_INVALID_SIZE;
+    }
+
+    esp_err_t ret = BoardMqtt_FormatTopic(s_cmd_ctl_topic, sizeof(s_cmd_ctl_topic), BOARD_MQTT_CMD_CTL_TOPIC_FORMAT, client_id);
+    if (ret != ESP_OK) {
+        return ret;
+    }
+    ret = BoardMqtt_FormatTopic(s_cmd_task_topic, sizeof(s_cmd_task_topic), BOARD_MQTT_CMD_TASK_TOPIC_FORMAT, client_id);
+    if (ret != ESP_OK) {
+        return ret;
+    }
+    ret = BoardMqtt_FormatTopic(s_rpt_log_topic, sizeof(s_rpt_log_topic), BOARD_MQTT_RPT_LOG_TOPIC_FORMAT, client_id);
+    if (ret != ESP_OK) {
+        return ret;
+    }
+    ret = BoardMqtt_FormatTopic(s_rpt_ack_topic, sizeof(s_rpt_ack_topic), BOARD_MQTT_RPT_ACK_TOPIC_FORMAT, client_id);
+    if (ret != ESP_OK) {
+        return ret;
+    }
+    ret = BoardMqtt_FormatTopic(s_rpt_progress_topic, sizeof(s_rpt_progress_topic), BOARD_MQTT_RPT_PROGRESS_TOPIC_FORMAT, client_id);
+    if (ret != ESP_OK) {
+        return ret;
+    }
+    ret = BoardMqtt_FormatTopic(s_rpt_state_topic, sizeof(s_rpt_state_topic), BOARD_MQTT_RPT_STATE_TOPIC_FORMAT, client_id);
+    if (ret != ESP_OK) {
+        return ret;
+    }
+    ret = BoardMqtt_FormatTopic(s_rpt_event_topic, sizeof(s_rpt_event_topic), BOARD_MQTT_RPT_EVENT_TOPIC_FORMAT, client_id);
+    if (ret != ESP_OK) {
+        return ret;
+    }
+
+    esp_mqtt_client_config_t mqtt_config = {0};
+    mqtt_config.task.priority = BOARD_MQTT_TASK_PRIORITY;
+    mqtt_config.task.stack_size = 6144;
+    mqtt_config.buffer.size = 8192;
+    mqtt_config.buffer.out_size = 8192;
+    mqtt_config.broker.address.hostname = BOARD_MQTT_BROKER_HOST;
+    mqtt_config.broker.address.port = BOARD_MQTT_BROKER_PORT;
+    mqtt_config.broker.address.transport = MQTT_TRANSPORT_OVER_TCP;
+    mqtt_config.credentials.client_id = client_id;
+    if (strlen(BOARD_MQTT_USERNAME) > 0) {
+        mqtt_config.credentials.username = BOARD_MQTT_USERNAME;
+        mqtt_config.credentials.authentication.password = BOARD_MQTT_PASSWORD;
+    }
+    mqtt_config.session.keepalive = BOARD_MQTT_KEEPALIVE_SEC;
+    mqtt_config.network.disable_auto_reconnect = true;
+    mqtt_config.network.timeout_ms = BOARD_MQTT_NETWORK_TIMEOUT_MS;
+
+    s_mqtt_client = esp_mqtt_client_init(&mqtt_config);
+    if (!s_mqtt_client) {
+        ESP_LOGE(TAG, "Failed to initialize MQTT client");
+        return ESP_FAIL;
+    }
+
+    ret = esp_mqtt_client_register_event(s_mqtt_client, MQTT_EVENT_ANY, BoardMqtt_EventHandler, NULL);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "Failed to register MQTT event handler: %s", esp_err_to_name(ret));
+        esp_mqtt_client_destroy(s_mqtt_client);
+        s_mqtt_client = NULL;
+        return ret;
+    }
+
+    ret = esp_mqtt_client_start(s_mqtt_client);
+    if (ret != ESP_OK) {
+        ESP_LOGE(TAG, "Failed to start MQTT client: %s", esp_err_to_name(ret));
+        esp_mqtt_client_destroy(s_mqtt_client);
+        s_mqtt_client = NULL;
+        return ret;
+    }
+
+    BoardMqtt_StartStateTask();
+    ESP_LOGI(TAG, "Starting MQTT client, host=%s, port=%d, ctl_topic=%s, task_topic=%s, state_topic=%s",
+             BOARD_MQTT_BROKER_HOST, BOARD_MQTT_BROKER_PORT, s_cmd_ctl_topic, s_cmd_task_topic, s_rpt_state_topic);
+    return ESP_OK;
+}
+
+void BoardMqtt_Stop(void)
+{
+    if (!s_mqtt_client) {
+        s_mqtt_connected = false;
+        return;
+    }
+
+    ESP_LOGI(TAG, "Stopping MQTT client");
+    esp_mqtt_client_stop(s_mqtt_client);
+    if (s_state_task_handle) {
+        TaskHandle_t state_task = s_state_task_handle;
+        s_state_task_handle = NULL;
+        vTaskDelete(state_task);
+    }
+    esp_mqtt_client_destroy(s_mqtt_client);
+    s_mqtt_client = NULL;
+    s_mqtt_connected = false;
+}
+
+bool BoardMqtt_IsConnected(void)
+{
+    return s_mqtt_connected;
+}
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h
new file mode 100644
index 0000000..640b74c
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt.h
@@ -0,0 +1,23 @@
+#ifndef __MQTT_H__
+#define __MQTT_H__
+
+#include <stdbool.h>
+#include "esp_err.h"
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+esp_err_t BoardMqtt_Start(const char *client_id);
+void BoardMqtt_Stop(void);
+bool BoardMqtt_IsConnected(void);
+bool BoardMqtt_CancelDownload(void);
+void BoardMqtt_PublishSttEvent(const char *text);
+void BoardMqtt_ResumePlaylistIfUnfinished(void);
+void BoardMqtt_StopPlaylistPlayback(void);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt_download_bridge.cc b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt_download_bridge.cc
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt_download_bridge.cc
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt_interface.md b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt_interface.md
new file mode 100644
index 0000000..ecf7655
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/mqtt_interface.md
@@ -0,0 +1,437 @@
+# 鎺ュ彛鏂囨。
+
+> 浜や簰寮� API 鏂囨。锛氬惎鍔ㄦ湇鍔″悗璁块棶 `/docs`  
+> 鍗忚鐗堟湰锛�/ MQTT snlp/v1
+
+---
+
+## 鐩綍
+- [涓�銆丮QTT 鎺ュ彛](#涓�mqtt-鎺ュ彛)
+  - [1.1 Topic 瑙勫垯](#11-topic-瑙勫垯)
+  - [1.2 娑堟伅閫氱敤鏍煎紡](#12-娑堟伅閫氱敤鏍煎紡)
+  - [1.3 缁堢涓婃姤锛坮pt锛塢(#13-缁堢涓婃姤rpt)
+  - [1.4 浜戠涓嬪彂锛坈md锛塢(#14-浜戠涓嬪彂cmd)
+  - [1.5 鏈嶅姟绔嚜鍔ㄤ笅鍙戞祦绋媇(#15-鏈嶅姟绔嚜鍔ㄤ笅鍙戞祦绋�)
+- [浜屻�佹帴鍙i�熸煡琛╙(#浜屾帴鍙i�熸煡琛�)
+
+---
+
+
+## 涓�銆丮QTT 鎺ュ彛
+
+### 1.1 Topic 瑙勫垯
+
+```
+snlp/v1/{device_id}/{direct}/{msg_type}
+```
+
+| 鏂瑰悜 | Topic | QoS | 璇存槑 |
+|------|-------|-----|------|
+| 缁堢 鈫� 浜� | `snlp/v1/{device_id}/rpt/state` | 0 | 蹇冭烦/鐘舵�佷笂鎶� |
+| 缁堢 鈫� 浜� | `snlp/v1/{device_id}/rpt/event` | 0 | 浜嬩欢涓婃姤 |
+| 缁堢 鈫� 浜� | `snlp/v1/{device_id}/rpt/ack` | 1 | 鎸囦护鎵ц鍥炴墽 |
+| 缁堢 鈫� 浜� | `snlp/v1/{device_id}/rpt/progress` | 0 | 寮傛浠诲姟杩涘害 |
+| 缁堢 鈫� 浜� | `snlp/v1/{device_id}/rpt/log` | 0 | 绯荤粺鏃ュ織 |
+| 浜� 鈫� 缁堢 | `snlp/v1/{device_id}/cmd/ctl` | 1 | 瀹炴椂鎺у埗鎸囦护 |
+| 浜� 鈫� 缁堢 | `snlp/v1/{device_id}/cmd/task` | 1 | 寮傛浠诲姟鎸囦护 |
+
+**鍙傛暟璇存槑锛�**
+
+| 鍙傛暟 | 璇存槑 |
+|------|------|
+| v1 | 鍗忚鐗堟湰鍙� |
+| device_id | 璁惧鍞竴 ID锛屼笉鍙慨鏀� |
+| direct | `rpt`锛堢粓绔笂鎶ワ級/ `cmd`锛堜簯绔笅鍙戯級 |
+| msg_type | `state` / `event` / `ack` / `progress` / `log` / `ctl` / `task` |
+
+---
+
+### 1.2 娑堟伅閫氱敤鏍煎紡
+
+#### 浜戠涓嬪彂锛坈md/ctl銆乧md/task锛�
+
+```json
+{
+  "header": {
+    "version": "1.0",
+    "device_id": "SN-88293021",
+    "req_id": "550e8400-e29b-41d4-a716-446655440000",
+    "timestamp": 1718365200,
+    "ttl": 3600
+  },
+  "action": "ACTION_TYPE",
+  "payload": {}
+}
+```
+
+| header 瀛楁 | 绫诲瀷 | 璇存槑 |
+|-------------|------|------|
+| version | string | 鍗忚璇箟鐗堟湰锛屽浐瀹� `"1.0"` |
+| device_id | string | 鐩爣璁惧 ID |
+| req_id | string | 鎸囦护鍞竴 ID锛圲UID锛夛紝缁堢 ack 鏃堕』鍥炰紶 |
+| timestamp | int | Unix 鏃堕棿鎴� |
+| ttl | int | 鎸囦护鏈夋晥鏈燂紙绉掞級 |
+
+#### 缁堢涓婃姤锛坮pt/*锛�
+
+```json
+{
+  "header": {
+    "device_id": "SN-88293021",
+    "timestamp": 1718365205
+  },
+  "msg_type": "state",
+  "payload": {}
+}
+```
+
+---
+
+### 1.3 缁堢涓婃姤锛坮pt锛�
+
+#### rpt/state 鈥� 蹇冭烦/鐘舵��
+
+**Topic锛�** `snlp/v1/{device_id}/rpt/state`
+
+**鍙戝竷绀轰緥锛堢粓绔� 鈫� 浜戯級锛�**
+
+```json
+{
+  "header": {
+    "device_id": "SN-88293021",
+    "timestamp": 1718365205
+  },
+  "msg_type": "state",
+  "payload": {
+    "cpu": 15,
+    "mem": 45,
+    "battery": 80,
+    "net": "wifi",
+    "mode": "idle"
+  }
+}
+```
+
+**鏈嶅姟绔涓猴細**
+
+- 鏇存柊璁惧鍦ㄧ嚎鐘舵��
+- 鑻ヨ澶囨湭鍒濆鍖栦笖 MQTT 宸茶繛鎺� 鈫� 鑷姩瑙﹀彂瀹屾暣鍒濆鍖栨祦绋�
+
+**mode 甯歌鍊硷細** `idle` / `dialogue` / `translate` / `study` / `sleep`
+
+---
+
+#### rpt/event 鈥� 浜嬩欢涓婃姤
+
+**Topic锛�** `snlp/v1/{device_id}/rpt/event`
+
+**鍙戝竷绀轰緥锛�**
+
+```json
+{
+  "header": {
+    "device_id": "SN-88293021",
+    "timestamp": 1718365210
+  },
+  "msg_type": "event",
+  "payload": {
+    "event": "asr_result",
+    "text": "Good morning",
+    "confidence": 0.95
+  }
+}
+```
+
+**甯歌 event 绫诲瀷锛�**
+
+| event | 璇存槑 |
+|-------|------|
+| wake | 鍞ら啋瑙﹀彂 |
+| sleep | 浼戠湢瑙﹀彂 |
+| asr_result | ASR 璇嗗埆缁撴灉 |
+| alarm | 寮傚父鎶ヨ |
+
+---
+
+#### rpt/ack 鈥� 鎸囦护鍥炴墽
+
+**Topic锛�** `snlp/v1/{device_id}/rpt/ack`
+
+**鍙戝竷绀轰緥锛�**
+
+```json
+{
+  "header": {
+    "device_id": "SN-88293021",
+    "timestamp": 1718365215
+  },
+  "msg_type": "ack",
+  "payload": {
+    "req_id": "550e8400-e29b-41d4-a716-446655440000",
+    "status": "success",
+    "error_code": 0
+  }
+}
+```
+
+| payload 瀛楁 | 璇存槑 |
+|--------------|------|
+| req_id | 瀵瑰簲涓嬪彂鎸囦护鐨� req_id |
+| status | `success` / `failed` |
+| error_code | 0 琛ㄧず鎴愬姛锛岄潪 0 涓洪敊璇爜 |
+
+---
+
+#### rpt/progress 鈥� 浠诲姟杩涘害
+
+**Topic锛�** `snlp/v1/{device_id}/rpt/progress`
+
+**鍙戝竷绀轰緥锛�**
+
+```json
+{
+  "header": {
+    "device_id": "SN-88293021",
+    "timestamp": 1718365220
+  },
+  "msg_type": "progress",
+  "payload": {
+    "task_id": "550e8400-e29b-41d4-a716-446655440000",
+    "status": "downloading",
+    "progress": 45,
+    "info": "Downloading courses/course01/chapter01/resources/001.mp3"
+  }
+}
+```
+
+---
+
+#### rpt/log 鈥� 绯荤粺鏃ュ織
+
+**Topic锛�** `snlp/v1/{device_id}/rpt/log`
+
+**鍙戝竷绀轰緥锛�**
+
+```json
+{
+  "header": {
+    "device_id": "SN-88293021",
+    "timestamp": 1718365230
+  },
+  "msg_type": "log",
+  "payload": {
+    "level": "error",
+    "module": "audio",
+    "message": "Microphone init failed"
+  }
+}
+```
+
+---
+
+### 1.4 浜戠涓嬪彂锛坈md锛�
+
+#### cmd/ctl 鈥� 瀹炴椂鎺у埗鎸囦护
+
+**Topic锛�** `snlp/v1/{device_id}/cmd/ctl`
+
+**Action 鍒楄〃锛�**
+
+| Action | payload 绀轰緥 | 璇存槑 |
+|--------|-------------|------|
+| SET_VOLUME | `{"level": 50}` | 璁剧疆闊抽噺 0鈥�100 |
+| SET_POWER | `{"state": "wake"}` | 鍞ら啋 / `sleep` 浼戠湢 |
+| SET_CLOCK | `{"ts": 1718365200}` | 鍚屾 Unix 鏃堕棿鎴� |
+| PLAY_MEDIA | `{"items":[{"path":"courses/course01/chapter01/resources/001.mp3","volume":50,"speed":1.25,"loop":false}]}` | 淇濆瓨骞舵挱鏀炬湰鍦� MP3 娓呭崟锛涘彧淇濈暀涓�浠芥竻鍗曪紝鍚庣画瑕嗙洊锛泂peed 鍙�夛紝浠呮敮鎸� 0.5/0.75/1.0/1.25/1.5/2.0锛岄潪 1.0 浼氬彉璋冿紱ttl 鍜� loop 蹇界暐 |
+| STOP_MEDIA | `{}` | 鍋滄鎾斁 |
+| SET_ALARM | `{"id": "alarm_01", "time": "07:00", "repeat": [1,2,3,4,5], "action": "add"}` | 璁剧疆闂归挓 |
+| UNSET_ALARM | `{"id": "alarm_01"}` | 鍒犻櫎闂归挓 |
+| REBOOT | `{"delay": 5}` | 寤舵椂閲嶅惎 |
+
+**PLAY_MEDIA 娓呭崟琛屼负锛�**
+
+- `payload.items` 涓烘挱鏀炬竻鍗曟暟缁勶紝姣忛」 `path` 蹇呭~锛宍volume` 鍜� `speed` 鍙�夈��
+- 鏀跺埌娓呭崟鍚庣粓绔繚瀛樺埌 SD 鍗℃湰鍦帮紝浠呬繚鐣欎竴浠斤紱鍚庣画鏂版竻鍗曚細瑕嗙洊鏃ф竻鍗曘��
+- 姣忔挱鏀惧畬涓�棣栵紝缁堢浼氬湪鏈湴娓呭崟涓爣璁拌椤瑰畬鎴愶紝骞堕�氳繃 `rpt/progress` 涓婃姤杩涘害銆�
+- `ttl` 鍜屾瘡椤� `loop` 褰撳墠蹇界暐銆�
+- 鑻ュ紑鏈鸿繛鎺� Wi-Fi 澶辫触锛屼笖鏈湴娓呭崟浠嶆湁鏈畬鎴愰」鐩紝缁堢浼氱户缁挱鏀炬湭瀹屾垚椤圭洰銆�
+
+**SET_CLOCK 涓嬪彂绀轰緥锛�**
+
+```json
+{
+  "header": {
+    "version": "1.0",
+    "device_id": "SN-88293021",
+    "req_id": "8f932149-719c-4464-99be-66dfcfa14f29",
+    "timestamp": 1718365200,
+    "ttl": 60
+  },
+  "action": "SET_CLOCK",
+  "payload": {
+    "ts": 1718365200
+  }
+}
+```
+
+**SET_VOLUME 涓嬪彂绀轰緥锛�**
+
+```json
+{
+  "header": {
+    "version": "1.0",
+    "device_id": "SN-88293021",
+    "req_id": "34dc4157-9e52-4a99-bdad-b3daf665df11",
+    "timestamp": 1718365200,
+    "ttl": 60
+  },
+  "action": "SET_VOLUME",
+  "payload": {
+    "level": 50
+  }
+}
+```
+
+**SET_POWER 涓嬪彂绀轰緥锛�**
+
+```json
+{
+  "header": {
+    "version": "1.0",
+    "device_id": "SN-88293021",
+    "req_id": "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
+    "timestamp": 1718365200,
+    "ttl": 60
+  },
+  "action": "SET_POWER",
+  "payload": {
+    "state": "wake"
+  }
+}
+```
+
+---
+
+#### cmd/task 鈥� 寮傛浠诲姟鎸囦护
+
+**Topic锛�** `snlp/v1/{device_id}/cmd/task`
+
+**褰撳墠瀹炵幇浣跨敤鐨� Action锛�**
+
+| Action | 璇存槑 |
+|--------|------|
+| SYNC_COURSE_TASK | 涓嬪彂瀹屾暣璇剧▼璧勬簮鍖咃紙courseTask.json 鏍煎紡锛夛紝缁堢鎸� assets 鎵归噺涓嬭浇 |
+
+**SYNC_COURSE_TASK 涓嬪彂绀轰緥锛�**
+
+```json
+{
+  "header": {
+    "version": "1.0",
+    "device_id": "SN-88293021",
+    "req_id": "e1c1de2a-3067-417d-b533-67c9a9827a8e",
+    "timestamp": 1718365201,
+    "ttl": 7200
+  },
+  "action": "SYNC_COURSE_TASK",
+  "payload": {
+    "version": "1.0",
+    "course_id": "course01",
+    "course_name": "灏忔櫤瀛︿範璇剧▼01",
+    "description": "璇剧▼01缁堢鍒濆鍖栦换鍔★細涓嬭浇绔犺妭瑙嗛骞舵洿鏂版湰鍦拌祫婧愭竻鍗�",
+    "assets": [
+      {
+        "url": "http://your-server:9500/static/courses/course01/chapter01/resources/001.mp3",
+        "md5": "...",
+        "title": "绗竴绔� 璇剧▼001",
+        "target_path": "courses/course01/chapter01/resources/001.mp3",
+        "cover_url": "http://your-server:9500/static/courses/course01/chapter01/resources/001.png",
+        "cover_target_path": "courses/course01/chapter01/resources/001.png"
+      },
+      {
+        "url": "http://your-server:9500/static/courses/course01/chapter01/resources/002.mp3",
+        "md5": "...",
+        "title": "绗竴绔� 璇剧▼002",
+        "target_path": "courses/course01/chapter01/resources/002.mp3",
+        "cover_url": "http://your-server:9500/static/courses/course01/chapter01/resources/002.png",
+        "cover_target_path": "courses/course01/chapter01/resources/002.png"
+      }
+    ]
+  }
+}
+```
+
+**assets 瀛楁璇存槑锛�**
+
+| 瀛楁 | 璇存槑 |
+|------|------|
+| url | 璧勬簮瀹屾暣 HTTP 涓嬭浇鍦板潃锛堟湇鍔$鑷姩琛ュ叏锛� |
+| md5 | 鏂囦欢鏍¢獙鍊� |
+| title | 璧勬簮鏍囬 |
+| target_path | 缁堢瀛樺偍鐩稿璺緞锛堜笌鏈嶅姟绔洰褰曠粨鏋勪竴鑷达級 |
+| cover_url | 灏侀潰瀹屾暣 HTTP 涓嬭浇鍦板潃 |
+| cover_target_path | 灏侀潰缁堢瀛樺偍鐩稿璺緞 |
+
+**缁堢澶勭悊閫昏緫锛�**
+
+1. 鏀跺埌 `SYNC_COURSE_TASK` 鍚庤В鏋� `assets` 鍒楄〃
+2. 鎸夋瘡椤圭殑 `url` 涓嬭浇鏂囦欢锛屼繚瀛樺埌瀛樺偍鍗� `target_path` 璺緞
+1. 鎸� `cover_url` 涓嬭浇灏侀潰锛屼繚瀛樺埌 `cover_target_path`
+4. 涓嬭浇杩囩▼涓�氳繃 `rpt/progress` 涓婃姤杩涘害
+5. 瀹屾垚鍚庨�氳繃 `rpt/ack` 鍥炰紶 `req_id` 涓庢墽琛岀粨鏋�
+
+**PRD 棰勭暀 Action锛堝彲閫氳繃 HTTP `/devices/{id}/task` 鎵嬪姩涓嬪彂锛夛細**
+
+| Action | payload 绀轰緥 | 璇存槑 |
+|--------|-------------|------|
+| DOWNLOAD | `{"task_id":"uuid","url":"...","md5":"...","target_path":"..."}` | 鍗曟枃浠朵笅杞� |
+| UPDATE_MANIFEST | `{"course_id":"...","assets":[...]}` | 鏇存柊璧勬簮娓呭崟 |
+| OTA_UPDATE | `{"url":"...","version":"1.0.2"}` | 鍥轰欢鍗囩骇 |
+| DELETE_FILE | `{"path":"..."}` | 鍒犻櫎鏂囦欢 |
+
+---
+
+### 1.5 鏈嶅姟绔嚜鍔ㄤ笅鍙戞祦绋�
+
+璁惧棣栨涓婃姤 `rpt/state` 鏃讹紝鏈嶅姟绔嚜鍔ㄦ墽琛岋細
+
+```
+1. cmd/ctl  SET_CLOCK   {"ts": <褰撳墠鏃堕棿鎴�>}
+2. cmd/ctl  SET_VOLUME  {"level": 50}
+1. cmd/ctl  SET_POWER   {"state": "wake"}
+4. cmd/task SYNC_COURSE_TASK  {course01 瀹屾暣 payload}
+5. cmd/task SYNC_COURSE_TASK  {course02 瀹屾暣 payload}
+   ... index.json 涓瘡闂ㄥ熀纭�璇剧▼鍚� 1 鏉�
+```
+
+**鎵嬪姩 MQTT 鍙戝竷绀轰緥锛坢osquitto_pub锛夛細**
+
+```bash
+mosquitto_pub \
+  -h your-mqtt-broker -p 2100 \
+  -u your-username -P your-password \
+  -t "snlp/v1/SN-88293021/cmd/task" \
+  -q 1 \
+  -m '{"header":{"version":"1.0","device_id":"SN-88293021","req_id":"manual-uuid","timestamp":1718365200,"ttl":7200},"action":"SYNC_COURSE_TASK","payload":{"version":"1.0","course_id":"course01","course_name":"灏忔櫤瀛︿範璇剧▼01","assets":[]}}'
+```
+
+---
+
+## 浜屻�佹帴鍙i�熸煡琛�
+
+### MQTT 鎺ュ彛
+
+| 鏂瑰悜 | Topic | 鍏抽敭 Action / 鐢ㄩ�� |
+|------|-------|-------------------|
+| 缁堢 鈫� 浜� | `snlp/v1/{id}/rpt/state` | 蹇冭烦锛岃Е鍙戣嚜鍔ㄥ垵濮嬪寲 |
+| 缁堢 鈫� 浜� | `snlp/v1/{id}/rpt/event` | ASR 缁撴灉銆佸敜閱�/浼戠湢浜嬩欢 |
+| 缁堢 鈫� 浜� | `snlp/v1/{id}/rpt/ack` | 鎸囦护鎵ц鍥炴墽 |
+| 缁堢 鈫� 浜� | `snlp/v1/{id}/rpt/progress` | 涓嬭浇/OTA 杩涘害 |
+| 缁堢 鈫� 浜� | `snlp/v1/{id}/rpt/log` | 杩滅▼鎺掗殰鏃ュ織 |
+| 浜� 鈫� 缁堢 | `snlp/v1/{id}/cmd/ctl` | SET_CLOCK / SET_VOLUME / SET_POWER / PLAY_MEDIA 绛� |
+| 浜� 鈫� 缁堢 | `snlp/v1/{id}/cmd/task` | SYNC_COURSE_TASK锛堣绋嬭祫婧愬寘鍚屾锛� |
+
+---
+
+*鏂囨。鐗堟湰锛�1.0 | 瀵瑰簲鏈嶅姟绔増鏈細1.0.0*
+
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc
new file mode 100644
index 0000000..b7a2ba1
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc
@@ -0,0 +1,985 @@
+#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 "led/single_led.h"
+#include "mcp_server.h"
+#include "power_save_timer.h"
+#include "adc_battery_monitor.h"
+#include "wifi_manager.h"
+
+#include <esp_log.h>
+#include <esp_efuse_table.h>
+#include <esp_timer.h>
+#include <esp_heap_caps.h>
+#include <driver/i2c_master.h>
+#include <freertos/event_groups.h>
+#include <cJSON.h>
+#include <mbedtls/md5.h>
+
+#include <algorithm>
+#include <cctype>
+#include <cmath>
+#include <cstdio>
+#include <cstring>
+#include <string>
+
+#include <esp_lcd_panel_io.h>
+#include <esp_lcd_panel_ops.h>
+#include <esp_lcd_gc9d01.h>
+
+#include "driver/gpio.h"
+#include "driver/spi_master.h"
+#include "nvs_flash.h"
+#include "esp_vfs_fat.h"
+#include "sdmmc_cmd.h"
+#include "driver/sdmmc_host.h"
+
+#include <ds3231.h>
+#include "system.h"
+#include "mqtt.h"
+
+#define TAG "sening_xiaozhi_s3"
+#define WIFI_RECONNECT_CONNECTED_BIT BIT0
+#define WIFI_RECONNECT_FAILED_BIT BIT1
+#define WIFI_RECONNECT_TIMEOUT_MS 30000
+
+static esp_lcd_panel_handle_t s_board_panel_handle = NULL;
+
+extern "C" esp_err_t Board_Set_Output_Volume(int level)
+{
+    if (level < 0 || level > 100) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    auto codec = Board::GetInstance().GetAudioCodec();
+    if (!codec) {
+        return ESP_ERR_INVALID_STATE;
+    }
+    codec->SetOutputVolume(level);
+    return ESP_OK;
+}
+
+extern "C" int Board_Get_Output_Volume(void)
+{
+    auto codec = Board::GetInstance().GetAudioCodec();
+    if (!codec) {
+        return -1;
+    }
+    return codec->output_volume();
+}
+
+extern "C" esp_err_t Board_Enter_Standby(void)
+{
+    Application::GetInstance().EnterStandby();
+    return ESP_OK;
+}
+
+extern "C" esp_err_t Board_Request_Wake(int timeout_ms)
+{
+    if (timeout_ms < 0) {
+        timeout_ms = 0;
+    }
+    return Board::GetInstance().EnsureNetworkReadyForWake(timeout_ms) ? ESP_OK : ESP_FAIL;
+}
+
+extern "C" esp_err_t Board_Reboot_Delayed(int delay_seconds)
+{
+    if (delay_seconds < 0 || delay_seconds > 3600) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    BaseType_t ret = xTaskCreate([](void *arg) {
+        int delay = (int)(intptr_t)arg;
+        if (delay > 0) {
+            vTaskDelay(pdMS_TO_TICKS(delay * 1000));
+        }
+        ESP_LOGW(TAG, "MQTT requested reboot after %d seconds", delay);
+        Application::GetInstance().Reboot();
+        vTaskDelete(nullptr);
+    }, "mqtt_reboot", 4096, (void *)(intptr_t)delay_seconds, 5, nullptr);
+
+    return ret == pdPASS ? ESP_OK : ESP_FAIL;
+}
+
+extern "C" void Board_Acquire_Sleep_Inhibit(void)
+{
+    Application::GetInstance().AcquireSleepInhibit();
+}
+
+extern "C" void Board_Release_Sleep_Inhibit(void)
+{
+    Application::GetInstance().ReleaseSleepInhibit();
+}
+
+extern "C" bool Board_Is_Voice_Session_Active(void)
+{
+    DeviceState state = Application::GetInstance().GetDeviceState();
+    return state == kDeviceStateConnecting || state == kDeviceStateListening || state == kDeviceStateSpeaking;
+}
+
+extern "C" void Board_Show_JPG(const char *jpg_name)
+{
+    if (!jpg_name || jpg_name[0] == '\0') {
+        return;
+    }
+    SDCard_Play_JPG(s_board_panel_handle, const_cast<char *>(jpg_name));
+}
+
+extern "C" void Board_Show_Black_Screen(void)
+{
+    if (!s_board_panel_handle) {
+        return;
+    }
+
+    uint16_t black_line[SCREEN_WIDTH] = {0};
+    for (int y = 0; y < SCREEN_HEIGHT; ++y) {
+        esp_err_t err = esp_lcd_panel_draw_bitmap(s_board_panel_handle, 0, y, SCREEN_WIDTH, y + 1, black_line);
+        if (err != ESP_OK) {
+            ESP_LOGW(TAG, "clear screen failed at line %d: %s", y, esp_err_to_name(err));
+            return;
+        }
+    }
+}
+
+extern "C" void Board_Show_Alert(const char *status, const char *message, const char *emotion)
+{
+    if (!status || !message) {
+        return;
+    }
+    std::string status_text(status);
+    std::string message_text(message);
+    std::string emotion_text(emotion ? emotion : "");
+    Application::GetInstance().Schedule([status_text, message_text, emotion_text]() {
+        Application::GetInstance().Alert(status_text.c_str(), message_text.c_str(), emotion_text.c_str());
+    });
+}
+
+extern "C" esp_err_t Board_Start_Firmware_Upgrade(const char *url,
+                                                   const char *version,
+                                                   board_ota_progress_cb_t progress_cb,
+                                                   board_ota_complete_cb_t complete_cb,
+                                                   void *ctx)
+{
+    if (!url || url[0] == '\0') {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    std::string upgrade_url(url);
+    std::string upgrade_version(version ? version : "");
+    Application::GetInstance().Schedule([upgrade_url, upgrade_version, progress_cb, complete_cb, ctx]() {
+        Application::GetInstance().UpgradeFirmware(upgrade_url, upgrade_version,
+            [progress_cb, ctx](int progress, size_t speed) {
+                if (progress_cb) {
+                    progress_cb(progress, speed, ctx);
+                }
+            },
+            [complete_cb, ctx](bool success) {
+                if (complete_cb) {
+                    complete_cb(success, ctx);
+                }
+            });
+    });
+    return ESP_OK;
+}
+
+static void SetDownloadError(char *error_info, size_t error_info_size, const char *message)
+{
+    if (error_info && error_info_size > 0) {
+        snprintf(error_info, error_info_size, "%s", message ? message : "download failed");
+    }
+}
+
+static std::string Md5ToHex(const unsigned char digest[16])
+{
+    static const char hex[] = "0123456789abcdef";
+    std::string value;
+    value.resize(32);
+    for (int i = 0; i < 16; ++i) {
+        value[i * 2] = hex[(digest[i] >> 4) & 0x0f];
+        value[i * 2 + 1] = hex[digest[i] & 0x0f];
+    }
+    return value;
+}
+
+static bool Md5Equals(const std::string& actual, const char *expected)
+{
+    if (!expected || expected[0] == '\0') {
+        return true;
+    }
+    std::string normalized(expected);
+    std::transform(normalized.begin(), normalized.end(), normalized.begin(), [](unsigned char c) {
+        return static_cast<char>(std::tolower(c));
+    });
+    return actual == normalized;
+}
+
+extern "C" esp_err_t Board_Download_File_To_Path(const char *url,
+                                                  const char *part_path,
+                                                  const char *expected_md5,
+                                                  board_download_progress_cb_t progress_cb,
+                                                  void *ctx,
+                                                  char *error_info,
+                                                  size_t error_info_size)
+{
+    if (!url || !part_path || url[0] == '\0' || part_path[0] == '\0') {
+        SetDownloadError(error_info, error_info_size, "invalid download argument");
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    FILE *fp = fopen(part_path, "wb");
+    if (!fp) {
+        ESP_LOGE(TAG, "open part file failed: %s", part_path);
+        SetDownloadError(error_info, error_info_size, "open part file failed");
+        return ESP_FAIL;
+    }
+
+    auto network = Board::GetInstance().GetNetwork();
+    auto http = network->CreateHttp(0);
+    http->SetTimeout(30000);
+    if (!http->Open("GET", url)) {
+        fclose(fp);
+        SetDownloadError(error_info, error_info_size, "open http failed");
+        return ESP_FAIL;
+    }
+
+    int status = http->GetStatusCode();
+    if (status != 200) {
+        fclose(fp);
+        http->Close();
+        char message[64];
+        snprintf(message, sizeof(message), "http status %d", status);
+        SetDownloadError(error_info, error_info_size, message);
+        return ESP_FAIL;
+    }
+
+    size_t content_length = http->GetBodyLength();
+    constexpr size_t BUFFER_SIZE = 1024;
+    char *buffer = (char *)heap_caps_malloc(BUFFER_SIZE, MALLOC_CAP_INTERNAL);
+    if (!buffer) {
+        fclose(fp);
+        http->Close();
+        SetDownloadError(error_info, error_info_size, "no memory for download buffer");
+        return ESP_ERR_NO_MEM;
+    }
+
+    mbedtls_md5_context md5_ctx;
+    bool check_md5 = expected_md5 && expected_md5[0] != '\0';
+    if (check_md5) {
+        mbedtls_md5_init(&md5_ctx);
+        mbedtls_md5_starts(&md5_ctx);
+    }
+
+    size_t total_read = 0;
+    int last_progress = -1;
+    int64_t last_report = 0;
+    bool ok = true;
+    bool canceled = false;
+    while (true) {
+        int ret = http->Read(buffer, BUFFER_SIZE);
+        if (ret < 0) {
+            ESP_LOGE(TAG, "read HTTP failed: %d", ret);
+            SetDownloadError(error_info, error_info_size, "read http failed");
+            ok = false;
+            break;
+        }
+        if (ret == 0) {
+            break;
+        }
+
+        if (fwrite(buffer, 1, ret, fp) != (size_t)ret) {
+            ESP_LOGE(TAG, "write part file failed: %s", part_path);
+            SetDownloadError(error_info, error_info_size, "write part file failed");
+            ok = false;
+            break;
+        }
+        if (check_md5) {
+            mbedtls_md5_update(&md5_ctx, (const unsigned char *)buffer, ret);
+        }
+
+        total_read += ret;
+        if (progress_cb) {
+            int progress = content_length > 0 ? (int)(total_read * 100 / content_length) : 0;
+            int64_t now = esp_timer_get_time();
+            bool report = content_length <= 0 || progress != last_progress || now - last_report >= 1000000 || progress >= 100;
+            if (report) {
+                last_progress = progress;
+                last_report = now;
+                if (!progress_cb(progress, "downloading", ctx)) {
+                    SetDownloadError(error_info, error_info_size, "download canceled");
+                    canceled = true;
+                    ok = false;
+                    break;
+                }
+            }
+        }
+    }
+
+    if (!canceled && content_length > 0 && total_read != content_length) {
+        SetDownloadError(error_info, error_info_size, "download size mismatch");
+        ok = false;
+    }
+    if (fflush(fp) != 0) {
+        SetDownloadError(error_info, error_info_size, "flush part file failed");
+        ok = false;
+    }
+    fclose(fp);
+    http->Close();
+    heap_caps_free(buffer);
+
+    if (!ok) {
+        if (check_md5) {
+            unsigned char discard[16];
+            mbedtls_md5_finish(&md5_ctx, discard);
+            mbedtls_md5_free(&md5_ctx);
+        }
+        return ESP_FAIL;
+    }
+
+    if (check_md5) {
+        unsigned char digest[16];
+        mbedtls_md5_finish(&md5_ctx, digest);
+        mbedtls_md5_free(&md5_ctx);
+        std::string actual = Md5ToHex(digest);
+        if (!Md5Equals(actual, expected_md5)) {
+            ESP_LOGE(TAG, "md5 mismatch: actual=%s expected=%s", actual.c_str(), expected_md5);
+            SetDownloadError(error_info, error_info_size, "md5 mismatch");
+            return ESP_FAIL;
+        }
+    }
+
+    if (progress_cb && !progress_cb(100, "downloaded", ctx)) {
+        SetDownloadError(error_info, error_info_size, "download canceled");
+        return ESP_FAIL;
+    }
+    ESP_LOGI(TAG, "download completed: %s -> %s (%u bytes)", url, part_path, (unsigned)total_read);
+    return ESP_OK;
+}
+
+class sening_xiaozhi_s3 : public WifiBoard {
+private:
+    Button boot_button_;
+    i2c_master_bus_handle_t codec_i2c_bus_;
+    i2c_dev_t ds2321_dev = {};
+    esp_lcd_panel_handle_t panel_handle = NULL;
+    PowerSaveTimer* wifi_standby_timer_ = nullptr;
+    EventGroupHandle_t wifi_reconnect_event_group_ = nullptr;
+    bool wifi_standby_active_ = false;
+    bool wifi_reconnect_waiting_ = false;
+    bool power_off_active_ = false;
+    bool power_off_restore_waiting_ = false;
+
+    void OnWifiConnected(const std::string& ssid) {
+        ESP_LOGI(TAG, "WiFi connected callback, ssid: %s", ssid.c_str());
+        SNTP_To_DS2312(&ds2321_dev);
+        BoardMqtt_Start(uuid_.c_str());
+    }
+
+    void InitializeDS2321() {
+        i2cdev_init();
+        ESP_ERROR_CHECK(ds3231_init_desc(&ds2321_dev, I2C_NUM_1, RTC_I2C_SDA_PIN, RTC_I2C_SCL_PIN));
+        ESP_ERROR_CHECK(ds3231_clear_alarm_flags(&ds2321_dev, DS3231_ALARM_1));
+        DS2312_Update_Alarm(&ds2321_dev, nullptr);
+        DS2312_Start_Alarm_Monitor(&ds2321_dev);
+    }
+    
+    void InitializeCodecI2c() {
+        // Initialize I2C peripheral
+        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_));
+
+        ESP_LOGI(TAG, "Scanning codec I2C bus...");
+        int found_count = 0;
+        for (uint8_t addr = 0x03; addr <= 0x77; ++addr) {
+            esp_err_t ret = i2c_master_probe(codec_i2c_bus_, addr, 50);
+            if (ret == ESP_OK) {
+                ESP_LOGI(TAG, "Found I2C device at address 0x%02X", addr);
+                ++found_count;
+            }
+        }
+        ESP_LOGI(TAG, "Codec I2C scan complete, found %d device(s)", found_count);
+    }
+
+    void EnterWifiStandby() {
+        if (wifi_reconnect_waiting_ || wifi_standby_active_ || power_off_active_) {
+            return;
+        }
+        if (!Application::GetInstance().CanEnterSleepMode()) {
+            return;
+        }
+
+        ESP_LOGI(TAG, "Entering WiFi standby");
+        wifi_standby_active_ = true;
+        Application::GetInstance().PrepareProtocolForNetworkStandby();
+        BoardMqtt_Stop();
+        WifiManager::GetInstance().StopStation();
+    }
+
+    void SetScreenBacklight(bool enabled) {
+        gpio_set_level(SCREEN_BL_PIN, enabled ? 1 : 0);
+    }
+
+public:
+    esp_err_t EnterPowerOffStandby() {
+        if (power_off_active_) {
+            return ESP_OK;
+        }
+
+        ESP_LOGI(TAG, "Entering board power-off standby");
+        power_off_active_ = true;
+        wifi_standby_active_ = true;
+        SetScreenBacklight(false);
+        Application::GetInstance().EnterPowerOffStandby();
+        Application::GetInstance().PrepareProtocolForNetworkStandby();
+        BoardMqtt_Stop();
+        WifiManager::GetInstance().StopStation();
+        return ESP_OK;
+    }
+
+    esp_err_t RestoreFromPowerOffStandby(int timeout_ms) {
+        if (power_off_restore_waiting_) {
+            return ESP_ERR_INVALID_STATE;
+        }
+
+        ESP_LOGI(TAG, "Restoring board from power-off standby");
+        SetScreenBacklight(true);
+        power_off_restore_waiting_ = true;
+        bool network_ready = EnsureNetworkReadyForWake(timeout_ms);
+        power_off_restore_waiting_ = false;
+        power_off_active_ = false;
+        Application::GetInstance().RestoreFromPowerOffStandby(network_ready);
+        return network_ready ? ESP_OK : ESP_FAIL;
+    }
+
+    bool IsPowerOffStandby() const {
+        return power_off_active_;
+    }
+
+private:
+    void InitializeWifiStandbyTimer() {
+#if WIFI_STANDBY_TIMEOUT_SEC > 0
+        wifi_reconnect_event_group_ = xEventGroupCreate();
+        wifi_standby_timer_ = new PowerSaveTimer(-1, WIFI_STANDBY_TIMEOUT_SEC, -1);
+        wifi_standby_timer_->OnEnterSleepMode([this]() {
+            xTaskCreate([](void* arg) {
+                auto* board = static_cast<sening_xiaozhi_s3*>(arg);
+                board->EnterWifiStandby();
+                vTaskDelete(NULL);
+            }, "wifi_standby", 4096, this, 2, NULL);
+        });
+        wifi_standby_timer_->SetEnabled(true);
+#endif
+    }
+
+    void InitializeButtons() {
+        boot_button_.OnClick([this]() {
+            if (SDCard_Is_Playing()) {
+                ESP_LOGI(TAG, "Button stops MP3 playback");
+                SDCard_Stop_Play();
+                return;
+            }
+            if (BoardMqtt_CancelDownload()) {
+                ESP_LOGI(TAG, "Button cancels MQTT download");
+                return;
+            }
+            if (power_off_active_) {
+                ESP_LOGI(TAG, "Ignore button while power-off standby is active");
+                return;
+            }
+
+            auto& app = Application::GetInstance();
+            auto state = app.GetDeviceState();
+            if (state == kDeviceStateStarting) {
+                EnterWifiConfigMode();
+                return;
+            }
+            if (state == kDeviceStateWifiConfiguring) {
+                ESP_LOGI(TAG, "Button retries WiFi connection from config mode");
+                xTaskCreate([](void *arg) {
+                    (void)arg;
+                    WifiManager::GetInstance().StopConfigAp();
+                    vTaskDelete(nullptr);
+                }, "wifi_retry", 4096, nullptr, 2, nullptr);
+                return;
+            }
+            if (state == kDeviceStateAudioTesting) {
+                app.ToggleChatState();
+                return;
+            }
+            if (!EnsureNetworkReadyForWake(WIFI_RECONNECT_TIMEOUT_MS)) {
+                return;
+            }
+            app.ToggleChatState();
+        });
+    }
+
+    // SPI鍒濆鍖�
+    void InitializeSpi() {
+        ESP_LOGI(TAG, "Initialize SPI bus");
+
+            spi_bus_config_t buscfg = {
+            .mosi_io_num = SCREEN_MOSI_PIN,
+            .miso_io_num = -1,
+            .sclk_io_num = SCREEN_SCK_PIN,
+            .quadwp_io_num = -1,
+            .quadhd_io_num = -1,
+            .data4_io_num = -1,
+            .data5_io_num = -1,
+            .data6_io_num = -1,
+            .data7_io_num = -1,
+            .data_io_default_level = false,
+            .max_transfer_sz = SCREEN_WIDTH * SCREEN_HEIGHT * sizeof(uint16_t),
+            .flags = 0,
+            .isr_cpu_id = ESP_INTR_CPU_AFFINITY_AUTO,
+            .intr_flags = 0,
+        };
+   
+        ESP_ERROR_CHECK(spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO));
+    }
+
+    // GC9A01鍒濆鍖�
+    void InitializeGc9d01Display() {
+        ESP_LOGI(TAG, "Init GC9D01 display");
+
+        gpio_config_t bl_config = {
+            .pin_bit_mask = (1ull << SCREEN_BL_PIN),
+            .mode = GPIO_MODE_OUTPUT,
+            .pull_up_en = GPIO_PULLUP_DISABLE,
+            .pull_down_en = GPIO_PULLDOWN_DISABLE,
+            .intr_type = GPIO_INTR_DISABLE,
+        };
+        gpio_config(&bl_config);
+
+        gpio_set_level(SCREEN_BL_PIN, 1);
+
+        ESP_LOGI(TAG, "Install panel IO");
+        esp_lcd_panel_io_handle_t io_handle = NULL;
+        esp_lcd_panel_io_spi_config_t io_config = GC9D01_PANEL_IO_SPI_CONFIG(SCREEN_CS_PIN, SCREEN_DC_PIN, NULL, NULL);
+        io_config.pclk_hz = SCREEN_PI_SCLK_HZ;
+        ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI3_HOST, &io_config, &io_handle));
+    
+        ESP_LOGI(TAG, "Install GC9D01 panel driver");
+        esp_lcd_panel_dev_config_t panel_config = {};
+        panel_config.reset_gpio_num = SCREEN_RESET_PIN;         
+        panel_config.rgb_endian = LCD_RGB_ENDIAN_BGR;           
+        panel_config.bits_per_pixel = 16;                     
+        
+        ESP_ERROR_CHECK(esp_lcd_new_panel_gc9d01(io_handle, &panel_config, &panel_handle));
+        ESP_ERROR_CHECK(esp_lcd_panel_reset(panel_handle));
+        ESP_ERROR_CHECK(esp_lcd_panel_init(panel_handle));
+        ESP_ERROR_CHECK(esp_lcd_panel_invert_color(panel_handle, false));
+        ESP_ERROR_CHECK(esp_lcd_panel_mirror(panel_handle, SCREEN_MIRROR_X, SCREEN_MIRROR_Y));
+        ESP_ERROR_CHECK(esp_lcd_panel_swap_xy(panel_handle, SCREEN_SWAP_XY));
+        ESP_ERROR_CHECK(esp_lcd_panel_disp_on_off(panel_handle, true)); 
+
+        s_board_panel_handle = panel_handle;
+
+        uint16_t *color_buf = (uint16_t*)malloc(160 * 160 * sizeof(uint16_t));
+        static int pix = 160 * 160;
+        for(int i = 0; i < pix; i++)
+        {
+            color_buf[i] = 0xF800;
+
+        }
+        esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, 160, 160, color_buf);
+
+        vTaskDelay(pdMS_TO_TICKS(500));
+
+        for(int i = 0; i < pix; i++)
+        {
+            color_buf[i] = 0x07E0;
+
+        }
+        esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, 160, 160, color_buf);
+
+        vTaskDelay(pdMS_TO_TICKS(500));
+
+        for(int i = 0; i < pix; i++)
+        {
+            color_buf[i] = 0x001F;
+
+        }
+        esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, 160, 160, color_buf);
+
+        vTaskDelay(pdMS_TO_TICKS(500));
+
+        for(int i = 0; i < pix; i++)
+        {
+            color_buf[i] = 0x0000;
+
+        }
+        esp_lcd_panel_draw_bitmap(panel_handle, 0, 0, 160, 160, color_buf);
+
+        free(color_buf);
+
+        Board_Show_Black_Screen();
+    }
+
+    void InitializeCard() {
+        esp_vfs_fat_sdmmc_mount_config_t mount_config = {
+            .format_if_mount_failed = false,    // 鎸傝浇澶辫触鏃朵笉鏍煎紡鍖�
+            .max_files = 5,                     // 鏈�澶у悓鏃舵墦寮�鏂囦欢鏁�
+            .allocation_unit_size = 16 * 1024   // 鍒嗛厤鍗曞厓澶у皬16KB
+        };
+
+        sdmmc_host_t host = SDMMC_HOST_DEFAULT(); // 浣跨敤榛樿閰嶇疆
+        host.flags = SDMMC_HOST_FLAG_4BIT;       
+        host.max_freq_khz = SDMMC_FREQ_HIGHSPEED; 
+
+        sdmmc_slot_config_t slot_config = SDMMC_SLOT_CONFIG_DEFAULT();
+        slot_config.clk = SD_CARD_CLK_PIN;
+        slot_config.cmd = SD_CARD_CMD_PIN;
+        slot_config.d0 = SD_CARD_D0_PIN;
+        slot_config.d1 = SD_CARD_D1_PIN;  
+        slot_config.d2 = SD_CARD_D2_PIN;
+        slot_config.d3 = SD_CARD_D3_PIN;
+        slot_config.width = 4;  
+
+        sdmmc_card_t* card;
+        esp_err_t ret = esp_vfs_fat_sdmmc_mount(MOUNT_POINT, &host, &slot_config, &mount_config, &card);
+
+        if (ret != ESP_OK) {
+            if (ret == ESP_FAIL) {
+                ESP_LOGE("TAG", "鎸傝浇鏂囦欢绯荤粺澶辫触");
+            } else {
+                ESP_LOGE("TAG", "SD 鍗″垵濮嬪寲澶辫触 (%s)", esp_err_to_name(ret));
+            }
+            return;
+        }
+
+        sdmmc_card_print_info(stdout, card);
+        
+        uint64_t capacity_bytes = (uint64_t)card->csd.capacity * card->csd.sector_size;
+        double capacity_mib = capacity_bytes / (1024.0 * 1024.0);
+        ESP_LOGI(TAG, "Card size: %.2f MiB (%.2f GB)", capacity_mib, capacity_mib / 1024.0);
+    }
+
+    void InitializeTools() {
+        auto& mcp_server = McpServer::GetInstance();
+
+        mcp_server.AddTool("self.alarm.add",
+            "Add a daily RTC alarm at an absolute clock time. Args are the target wall-clock time of day: hour 0-23, minute 0-59, second 0-59. Do not pass relative durations directly; for requests like 'in 2 minutes', first calculate the absolute local time, then pass that time of day.",
+            PropertyList({
+                Property("hour", kPropertyTypeInteger, 0, 23),
+                Property("minute", kPropertyTypeInteger, 0, 59),
+                Property("second", kPropertyTypeInteger, 0, 0, 59)
+            }),
+            [this](const PropertyList& properties) -> ReturnValue {
+                int hour = properties["hour"].value<int>();
+                int minute = properties["minute"].value<int>();
+                int second = properties["second"].value<int>();
+                DS2312_Add_Alarm_Time(&ds2321_dev, hour, minute, second);
+                return std::string(DS2312_Get_Alarm_List(&ds2321_dev));
+            });
+
+        mcp_server.AddTool("self.alarm.get_list",
+            "Get all daily RTC alarms as a JSON string. Each alarm has index, time, hour, min and sec fields.",
+            PropertyList(),
+            [this](const PropertyList& properties) -> ReturnValue {
+                return std::string(DS2312_Get_Alarm_List(&ds2321_dev));
+            });
+
+        mcp_server.AddTool("self.alarm.delete",
+            "Delete a daily RTC alarm by its index from self.alarm.get_list.",
+            PropertyList({
+                Property("index", kPropertyTypeInteger, 0, ALARM_MAX_NUM - 1)
+            }),
+            [this](const PropertyList& properties) -> ReturnValue {
+                int index = properties["index"].value<int>();
+                DS2312_Delete_Alarm_Index(&ds2321_dev, static_cast<uint8_t>(index));
+                return std::string(DS2312_Get_Alarm_List(&ds2321_dev));
+            });
+
+        mcp_server.AddTool("self.alarm.delete_all",
+            "Delete all daily RTC alarms.",
+            PropertyList(),
+            [this](const PropertyList& properties) -> ReturnValue {
+                DS2312_Delete_Alarm_All(&ds2321_dev);
+                return std::string(DS2312_Get_Alarm_List(&ds2321_dev));
+            });
+
+        mcp_server.AddTool("self.mp3.get_list",
+            "List MP3 files on the SD card. For list/query requests, only return this list. For play, random play, switch song, or named-song requests, use this only to choose an exact files[].name, then call self.mp3.play before replying to the user.",
+            PropertyList(),
+            [](const PropertyList& properties) -> ReturnValue {
+                return std::string(SDCard_Get_MP3_List());
+            });
+
+        static std::string last_mp3_play_name;
+        static float last_mp3_play_speed = 1.0f;
+        static int64_t last_mp3_play_time = 0;
+        mcp_server.AddTool("self.mp3.play",
+            "Play one MP3 file from the SD card. The name argument must exactly match a files[].name from self.mp3.get_list. Optional speed supports only 0.5, 0.75, 1.0, 1.25, 1.5, or 2.0; non-1.0 speeds change pitch and affect only SD card MP3 playback. For play, random play, switch song, or named-song requests, call this tool before telling the user playback has started. Do not call this tool repeatedly for the same user request after it has returned accepted=true.",
+            PropertyList({
+                Property("name", kPropertyTypeString),
+                Property("speed", kPropertyTypeNumber, 1.0)
+            }),
+            [](const PropertyList& properties) -> ReturnValue {
+                std::string name = properties["name"].value<std::string>();
+                float speed = static_cast<float>(properties["speed"].value<double>());
+                int64_t now = esp_timer_get_time();
+                if (name == last_mp3_play_name && std::fabs(speed - last_mp3_play_speed) < 0.001f && now - last_mp3_play_time < 15000000) {
+                    return std::string("{\"accepted\":true,\"duplicate_ignored\":true}");
+                }
+
+                esp_err_t err = SDCard_Play_MP3_With_Speed(name.data(), speed);
+                if (err != ESP_OK) {
+                    return std::string("{\"accepted\":false,\"error\":\"") + esp_err_to_name(err) +
+                        "\",\"retry\":\"use exact files[].name from self.mp3.get_list\"}";
+                }
+                last_mp3_play_name = name;
+                last_mp3_play_speed = speed;
+                last_mp3_play_time = now;
+                return std::string("{\"accepted\":true}");
+            });
+
+        mcp_server.AddTool("self.mp3.get_status",
+            "Get current SD card MP3 playback status. playing means an MP3 track is active; tts_paused only means playback is temporarily muted while the assistant is speaking.",
+            PropertyList(),
+            [](const PropertyList& properties) -> ReturnValue {
+                return std::string("{\"playing\":") + (SDCard_Is_Playing() ? "true" : "false") +
+                    ",\"tts_paused\":" + (SDCard_Is_Paused() ? "true" : "false") + "}";
+            });
+
+        mcp_server.AddTool("self.mp3.stop",
+            "Stop the current SD card MP3 playback and any active MQTT playlist. Use this when the user asks to pause or stop music.",
+            PropertyList(),
+            [](const PropertyList& properties) -> ReturnValue {
+                SDCard_Stop_Play();
+                return std::string("{\"stopped\":true,\"playing\":") + (SDCard_Is_Playing() ? "true" : "false") +
+                    ",\"tts_paused\":" + (SDCard_Is_Paused() ? "true" : "false") + "}";
+            });
+
+        mcp_server.AddTool("self.enter_standby",
+            "Request standby mode. You must call this tool, not just say it is done, when the user asks for 寰呭懡, 寰呭懡鐘舵��, 杩涘叆寰呭懡鐘舵��, 寰呮満, 浼戠湢, sleep, wait, stop listening, or stop responding. The device will finish the current assistant reply first, then return to standby.",
+            PropertyList(),
+            [](const PropertyList& properties) -> ReturnValue {
+                Application::GetInstance().EnterStandby();
+                return std::string("{\"standby_requested\":true}");
+            });
+
+        mcp_server.AddTool("self.power_off",
+            "Request power off into alarm-wake standby. Always call this when the user asks to power off, shut down, or enter 鍏虫満鐘舵��. The device will finish the current assistant reply first, then disconnect WiFi, stop playback, and disable wake word and command word recognition until an RTC alarm restores it.",
+            PropertyList(),
+            [](const PropertyList& properties) -> ReturnValue {
+                Application::GetInstance().RequestPowerOffStandby();
+                return std::string("{\"power_off_requested\":true}");
+            });
+
+        mcp_server.AddTool("self.mp3.delete",
+            "Delete an MP3 file from the SD card by file name from self.mp3.get_list.",
+            PropertyList({
+                Property("name", kPropertyTypeString)
+            }),
+            [](const PropertyList& properties) -> ReturnValue {
+                std::string name = properties["name"].value<std::string>();
+                SDCard_Delete_MP3(name.data());
+                return std::string(SDCard_Get_MP3_List());
+            });
+
+        mcp_server.AddTool("self.jpg.get_list",
+            "List JPG files on the SD card. For list/query requests, only return this list. For display/show/open-image requests, use this only to choose an exact files[].name, then call self.jpg.play before replying to the user.",
+            PropertyList(),
+            [](const PropertyList& properties) -> ReturnValue {
+                return std::string(SDCard_Get_JPG_List());
+            });
+
+        mcp_server.AddTool("self.jpg.play",
+            "Display one JPG file on the screen. The name argument must exactly match a files[].name from self.jpg.get_list. For display/show/open-image requests, call this tool before telling the user the image is shown.",
+            PropertyList({
+                Property("name", kPropertyTypeString)
+            }),
+            [this](const PropertyList& properties) -> ReturnValue {
+                std::string name = properties["name"].value<std::string>();
+                SDCard_Play_JPG(panel_handle, name.data());
+                return std::string("{\"shown\":true}");
+            });
+
+        mcp_server.AddTool("self.jpg.delete",
+            "Delete a JPG file from the SD card by file name from self.jpg.get_list.",
+            PropertyList({
+                Property("name", kPropertyTypeString)
+            }),
+            [](const PropertyList& properties) -> ReturnValue {
+                std::string name = properties["name"].value<std::string>();
+                SDCard_Delete_JPG(name.data());
+                return std::string(SDCard_Get_JPG_List());
+            });
+
+        mcp_server.AddTool("self.battery.get_status",
+            "Get battery level and charging status. Returns JSON with level 0-100 and charging boolean.",
+            PropertyList(),
+            [](const PropertyList& properties) -> ReturnValue {
+                return std::string("{\"level\":") + std::to_string(Battery_Get_Value()) +
+                    ",\"charging\":" + (Battery_Is_Charging() ? "true" : "false") + "}";
+            });
+
+        mcp_server.AddTool("self.wifi.get_history_list",
+            "Get saved WiFi history as a JSON string. Passwords are not returned.",
+            PropertyList(),
+            [](const PropertyList& properties) -> ReturnValue {
+                return std::string(WIFI_Get_History_List());
+            });
+
+        mcp_server.AddTool("self.wifi.delete_all",
+            "Delete all saved WiFi credentials. The current connection is not disconnected immediately.",
+            PropertyList(),
+            [](const PropertyList& properties) -> ReturnValue {
+                WIFI_Delete_All();
+                return std::string(WIFI_Get_History_List());
+            });
+    }
+
+public:
+    sening_xiaozhi_s3() : boot_button_(BOOT_BUTTON_GPIO) {  
+        InitializeDS2321();
+        InitializeCodecI2c();
+        InitializeButtons();
+        InitializeWifiStandbyTimer();
+        InitializeSpi();
+        InitializeGc9d01Display();
+        InitializeCard();
+        InitializeTools();
+    }
+
+    virtual void SetNetworkEventCallback(NetworkEventCallback callback) override {
+        WifiBoard::SetNetworkEventCallback([this, callback](NetworkEvent event, const std::string& data) {
+            if (event == NetworkEvent::Connected) {
+                wifi_standby_active_ = false;
+                if (wifi_reconnect_waiting_ && wifi_reconnect_event_group_) {
+                    xEventGroupSetBits(wifi_reconnect_event_group_, WIFI_RECONNECT_CONNECTED_BIT);
+                }
+                OnWifiConnected(data);
+            } else if (event == NetworkEvent::ConnectionFailed) {
+                if (wifi_reconnect_waiting_ && wifi_reconnect_event_group_) {
+                    xEventGroupSetBits(wifi_reconnect_event_group_, WIFI_RECONNECT_FAILED_BIT);
+                }
+            } else if (event == NetworkEvent::WifiConfigModeEnter) {
+                if (!power_off_active_) {
+                    ESP_LOGW(TAG, "WiFi config mode entered, stop local MQTT playlist resume");
+                    BoardMqtt_Stop();
+                }
+            } else if (event == NetworkEvent::Disconnected) {
+                BoardMqtt_Stop();
+            }
+
+            bool suppress_disconnected = event == NetworkEvent::Disconnected && (wifi_standby_active_ || power_off_active_);
+            if (callback && !suppress_disconnected) {
+                callback(event, data);
+            }
+        });
+    }
+
+    virtual bool EnsureNetworkReadyForWake(int timeout_ms) override {
+        if (wifi_standby_timer_) {
+            wifi_standby_timer_->WakeUp();
+        }
+        auto& wifi_manager = WifiManager::GetInstance();
+        if (wifi_manager.IsConnected()) {
+            wifi_standby_active_ = false;
+            return true;
+        }
+        if (!wifi_reconnect_event_group_) {
+            return false;
+        }
+
+        ESP_LOGI(TAG, "Restoring WiFi for wake");
+        xEventGroupClearBits(wifi_reconnect_event_group_, WIFI_RECONNECT_CONNECTED_BIT | WIFI_RECONNECT_FAILED_BIT);
+        wifi_reconnect_waiting_ = true;
+        SetSuppressConfigModeOnConnectionFailed(true);
+        wifi_manager.StartStation();
+
+        EventBits_t bits = xEventGroupWaitBits(wifi_reconnect_event_group_,
+            WIFI_RECONNECT_CONNECTED_BIT | WIFI_RECONNECT_FAILED_BIT,
+            pdTRUE, pdFALSE, pdMS_TO_TICKS(timeout_ms));
+
+        wifi_reconnect_waiting_ = false;
+        if (bits & WIFI_RECONNECT_CONNECTED_BIT) {
+            SetSuppressConfigModeOnConnectionFailed(false);
+            wifi_standby_active_ = false;
+            return true;
+        }
+
+        ESP_LOGW(TAG, "WiFi restore failed, keep offline standby");
+        wifi_manager.StopStation();
+        SetSuppressConfigModeOnConnectionFailed(false);
+        wifi_standby_active_ = true;
+        return false;
+    }
+
+    virtual bool IsNetworkReady() override {
+        return !wifi_standby_active_ && !power_off_active_ && WifiManager::GetInstance().IsConnected();
+    }
+
+    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 bool GetBatteryLevel(int& level, bool& charging, bool& discharging) override {
+        level = Battery_Get_Value();
+        charging = Battery_Is_Charging();
+        discharging = !charging;
+        return true;
+    }
+
+    virtual std::string GetDeviceStatusJson() override {
+        std::string base = WifiBoard::GetDeviceStatusJson();
+        cJSON* root = cJSON_Parse(base.c_str());
+        if (!root) {
+            return base;
+        }
+
+        auto mp3 = cJSON_CreateObject();
+        cJSON_AddBoolToObject(mp3, "playing", SDCard_Is_Playing());
+        cJSON_AddBoolToObject(mp3, "tts_paused", SDCard_Is_Paused());
+        cJSON_AddItemToObject(root, "sdcard_mp3", mp3);
+
+        char* json = cJSON_PrintUnformatted(root);
+        std::string result(json ? json : base);
+        if (json) {
+            cJSON_free(json);
+        }
+        cJSON_Delete(root);
+        return result;
+    }
+};
+
+extern "C" esp_err_t Board_Enter_Power_Off(void)
+{
+    auto& board = static_cast<sening_xiaozhi_s3&>(Board::GetInstance());
+    return board.EnterPowerOffStandby();
+}
+
+extern "C" esp_err_t Board_Restore_From_Power_Off(int timeout_ms)
+{
+    if (timeout_ms < 0) {
+        timeout_ms = 0;
+    }
+
+    auto& board = static_cast<sening_xiaozhi_s3&>(Board::GetInstance());
+    return board.RestoreFromPowerOffStandby(timeout_ms);
+}
+
+extern "C" bool Board_Is_Power_Off(void)
+{
+    auto& board = static_cast<sening_xiaozhi_s3&>(Board::GetInstance());
+    return board.IsPowerOffStandby();
+}
+
+DECLARE_BOARD(sening_xiaozhi_s3);
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c
new file mode 100644
index 0000000..87b649b
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.c
@@ -0,0 +1,1666 @@
+#include "system.h"
+#include "config.h"
+#include "display/lvgl_display/jpg/jpeg_to_image.h"
+
+#include <esp_heap_caps.h>
+#include <esp_partition.h>
+#include <nvs.h>
+#include <dirent.h>
+#include <driver/gpio.h>
+#include <esp_adc/adc_oneshot.h>
+#include <esp_adc/adc_cali.h>
+#include <esp_adc/adc_cali_scheme.h>
+#include <stdbool.h>
+#include <stdint.h>
+#include <stdio.h>
+#include <stdlib.h>
+#include <string.h>
+#include <strings.h>
+#include <unistd.h>
+#include <errno.h>
+#include <sys/stat.h>
+
+#define TAG "system"
+#define ALARM_STORE_MAGIC 0x314d5241U
+#define ALARM_STORE_VERSION 2
+#define ALARM_ENTRY_ENABLED 1
+#define SECONDS_PER_DAY (24 * 60 * 60)
+#define ALARM_LIST_BUFFER_SIZE 1024
+#define MP3_LIST_BUFFER_SIZE 2048
+#define JPG_LIST_BUFFER_SIZE 2048
+#define JPG_REQUIRED_WIDTH 160
+#define JPG_REQUIRED_HEIGHT 160
+#define JPG_SETTINGS_NAMESPACE "sdcard_jpg"
+#define JPG_SETTINGS_LAST_NAME "last_name"
+#define BATTERY_MIN_MV 3300
+#define BATTERY_MAX_MV 4200
+#define BATTERY_ADC_SAMPLES 10
+#define BATTERY_ADC_DIVIDER_NUMERATOR 35
+#define BATTERY_ADC_DIVIDER_DENOMINATOR 17
+
+typedef struct __attribute__((packed)) {
+    char id[ALARM_ID_MAX_LEN];
+    uint8_t hour;
+    uint8_t min;
+    uint8_t sec;
+    uint8_t flags;
+    uint8_t repeat_mask;
+    uint8_t reserved[2];
+} alarm_entry_t;
+
+typedef struct __attribute__((packed)) {
+    uint32_t magic;
+    uint8_t version;
+    uint8_t count;
+    uint16_t reserved;
+    uint32_t checksum;
+    alarm_entry_t entries[ALARM_MAX_NUM];
+} alarm_store_t;
+
+static i2c_dev_t *ds2321_dev;
+static char alarm_list_buffer[ALARM_LIST_BUFFER_SIZE];
+static char mp3_list_buffer[MP3_LIST_BUFFER_SIZE];
+static char jpg_list_buffer[JPG_LIST_BUFFER_SIZE];
+static adc_oneshot_unit_handle_t battery_adc_handle;
+static adc_channel_t battery_adc_channel;
+static adc_unit_t battery_adc_unit;
+static adc_cali_handle_t battery_adc_cali_handle;
+static bool battery_adc_initialized;
+static bool battery_adc_cali_initialized;
+static bool battery_charging_gpio_initialized;
+static alarm_entry_t current_alarm1_entry;
+static bool current_alarm1_valid;
+static TaskHandle_t alarm_monitor_task_handle;
+static TaskHandle_t alarm_power_off_restore_task_handle;
+
+static void sntp_finish_callback(struct timeval *tv);
+static uint32_t alarm_store_checksum(const alarm_store_t *store);
+static void alarm_store_init_empty(alarm_store_t *store);
+static bool alarm_entry_is_valid(const alarm_entry_t *entry);
+static bool alarm_id_is_safe(const char *id);
+static int find_alarm_by_id(const alarm_store_t *store, const char *id);
+static int alarm_entry_seconds(const alarm_entry_t *entry);
+static int tm_seconds(const struct tm *time);
+static void alarm_store_sort(alarm_store_t *store);
+static const esp_partition_t *get_config_partition(void);
+static esp_err_t load_alarm_store(alarm_store_t *store);
+static esp_err_t save_alarm_store(alarm_store_t *store);
+static bool get_current_time(i2c_dev_t *i2c_dev, struct tm *timeinfo);
+static int select_next_alarm_index(const alarm_store_t *store, const struct tm *now);
+static int alarm_delta_seconds(const alarm_entry_t *entry, const struct tm *now);
+static int mqtt_weekday_to_tm_wday(int day);
+static uint8_t tm_wday_to_repeat_bit(int tm_wday);
+static bool alarm_entry_equals(const alarm_entry_t *left, const alarm_entry_t *right);
+static void alarm_power_off_restore_task(void *arg);
+static void schedule_power_off_restore_from_alarm(void);
+static void alarm_monitor_task(void *arg);
+static void disable_alarm1(i2c_dev_t *i2c_dev);
+static void program_alarm1(i2c_dev_t *i2c_dev, const alarm_entry_t *entry);
+static bool media_name_is_safe(const char *name);
+static bool course_id_is_safe(const char *course_id);
+static esp_err_t mkdir_recursive(const char *path);
+static bool mp3_name_has_extension(const char *mp3_name);
+static bool jpg_name_has_extension(const char *jpg_name);
+static bool build_media_path(const char *name, char *path, size_t path_size);
+static bool build_mp3_path(const char *mp3_name, char *path, size_t path_size);
+static bool build_jpg_path(const char *jpg_name, char *path, size_t path_size);
+static bool resolve_mp3_path(const char *mp3_name, char *path, size_t path_size);
+static bool build_matching_jpg_name(const char *mp3_path, char *jpg_name, size_t jpg_name_size);
+static void append_media_list_dir_json(const char *dir_path, const char *relative_dir, char *buffer, size_t buffer_size,
+                                       bool (*extension_filter)(const char *), size_t *used, int *index, bool *truncated);
+static const char *build_media_list_json(char *buffer, size_t buffer_size, bool (*extension_filter)(const char *));
+static void convert_rgb565_to_bgr565(uint8_t *image, size_t width, size_t height, size_t stride);
+static void save_last_jpg_name(const char *jpg_name);
+static bool load_last_jpg_name(char *jpg_name, size_t jpg_name_size);
+static esp_err_t initialize_battery_adc(void);
+static void initialize_battery_charging_gpio(void);
+static bool initialize_battery_adc_calibration(void);
+static uint8_t battery_mv_to_percent(uint32_t mv);
+static size_t append_json_escaped_string(char *buffer, size_t buffer_size, size_t used, const char *value);
+
+/* ======================== Public API ======================== */
+
+void Board_Offline_Command_Callback(void)
+{
+    ESP_LOGI(TAG, "offline command triggered: %s", OFFLINE_COMMAND_WORD);
+}
+
+bool Board_Handle_Offline_Command(const char *command)
+{
+    if (!command || Board_Is_Power_Off()) {
+        return false;
+    }
+    if (strcmp(command, OFFLINE_COMMAND_WORD) != 0 && strcmp(command, OFFLINE_COMMAND_WORD_PINYIN) != 0) {
+        return false;
+    }
+
+    Board_Offline_Command_Callback();
+    return true;
+}
+
+void SNTP_To_DS2312(i2c_dev_t *i2c_dev)
+{
+    ds2321_dev = i2c_dev;
+
+    if (esp_sntp_enabled()) {
+        esp_sntp_stop();
+    }
+
+    esp_sntp_setoperatingmode(ESP_SNTP_OPMODE_POLL);
+    esp_sntp_setservername(0, "ntp.aliyun.com");
+    esp_sntp_setservername(1, "time.asia.apple.com");
+    esp_sntp_setservername(2, "pool.ntp.org");
+    esp_sntp_set_time_sync_notification_cb(sntp_finish_callback);
+    esp_sntp_init();
+}
+
+void DS2312_Update_Alarm(i2c_dev_t *i2c_dev, struct tm *time)
+{
+    alarm_store_t store;
+    if (load_alarm_store(&store) != ESP_OK) {
+        disable_alarm1(i2c_dev);
+        return;
+    }
+
+    if (store.count == 0) {
+        disable_alarm1(i2c_dev);
+        ESP_LOGI(TAG, "alarm list is empty");
+        return;
+    }
+
+    struct tm now;
+    if (time) {
+        now = *time;
+    } else if (!get_current_time(i2c_dev, &now)) {
+        ESP_LOGW(TAG, "current time unavailable, skip alarm update");
+        return;
+    }
+
+    int index = select_next_alarm_index(&store, &now);
+    if (index < 0) {
+        disable_alarm1(i2c_dev);
+        return;
+    }
+
+    program_alarm1(i2c_dev, &store.entries[index]);
+}
+
+void DS2312_Start_Alarm_Monitor(i2c_dev_t *i2c_dev)
+{
+    ds2321_dev = i2c_dev;
+    if (alarm_monitor_task_handle) {
+        return;
+    }
+
+    BaseType_t ret = xTaskCreate(alarm_monitor_task, "alarm_monitor", 4096, i2c_dev, 5, &alarm_monitor_task_handle);
+    if (ret != pdPASS) {
+        alarm_monitor_task_handle = NULL;
+        ESP_LOGE(TAG, "create alarm monitor task failed");
+    }
+}
+
+void DS2312_Add_Alarm_Time(i2c_dev_t *i2c_dev, int hour, int min, int sec)
+{
+    char id[ALARM_ID_MAX_LEN];
+    snprintf(id, sizeof(id), "daily_%02d%02d%02d", hour, min, sec);
+    esp_err_t err = DS2312_Set_Alarm_Id(i2c_dev, id, hour, min, sec, ALARM_REPEAT_ALL);
+    if (err != ESP_OK) {
+        ESP_LOGW(TAG, "add alarm failed: %02d:%02d:%02d, %s", hour, min, sec, esp_err_to_name(err));
+    }
+}
+
+const char *DS2312_Get_Alarm_List(i2c_dev_t *i2c_dev)
+{
+    (void)i2c_dev;
+
+    alarm_store_t store;
+    esp_err_t err = load_alarm_store(&store);
+    if (err != ESP_OK) {
+        snprintf(alarm_list_buffer, sizeof(alarm_list_buffer),
+            "{\"error\":\"failed to load alarm list: %s\"}", esp_err_to_name(err));
+        return alarm_list_buffer;
+    }
+
+    size_t used = 0;
+    int written = snprintf(alarm_list_buffer, sizeof(alarm_list_buffer),
+        "{\"count\":%u,\"alarms\":[", store.count);
+    if (written < 0) {
+        alarm_list_buffer[0] = '\0';
+        return alarm_list_buffer;
+    }
+    used = (size_t)written;
+
+    for (uint8_t i = 0; i < store.count && used < sizeof(alarm_list_buffer); ++i) {
+        alarm_entry_t *entry = &store.entries[i];
+        written = snprintf(alarm_list_buffer + used, sizeof(alarm_list_buffer) - used,
+            "%s{\"index\":%u,\"id\":", i == 0 ? "" : ",", i);
+        if (written < 0 || used + (size_t)written >= sizeof(alarm_list_buffer)) {
+            break;
+        }
+        used += (size_t)written;
+        used = append_json_escaped_string(alarm_list_buffer, sizeof(alarm_list_buffer), used, entry->id);
+        written = snprintf(alarm_list_buffer + used, sizeof(alarm_list_buffer) - used,
+            ",\"time\":\"%02u:%02u:%02u\",\"hour\":%u,\"min\":%u,\"sec\":%u,\"repeat\":[",
+            entry->hour, entry->min, entry->sec, entry->hour, entry->min, entry->sec);
+        if (written < 0 || used + (size_t)written >= sizeof(alarm_list_buffer)) {
+            break;
+        }
+        used += (size_t)written;
+        bool first = true;
+        for (int day = 1; day <= 7; ++day) {
+            int tm_wday = mqtt_weekday_to_tm_wday(day);
+            if ((entry->repeat_mask & tm_wday_to_repeat_bit(tm_wday)) == 0) {
+                continue;
+            }
+            written = snprintf(alarm_list_buffer + used, sizeof(alarm_list_buffer) - used,
+                "%s%d", first ? "" : ",", day);
+            if (written < 0 || used + (size_t)written >= sizeof(alarm_list_buffer)) {
+                break;
+            }
+            used += (size_t)written;
+            first = false;
+        }
+        written = snprintf(alarm_list_buffer + used, sizeof(alarm_list_buffer) - used, "]}");
+        if (written < 0 || used + (size_t)written >= sizeof(alarm_list_buffer)) {
+            break;
+        }
+        used += (size_t)written;
+    }
+
+    if (used < sizeof(alarm_list_buffer)) {
+        snprintf(alarm_list_buffer + used, sizeof(alarm_list_buffer) - used, "]}");
+    } else {
+        alarm_list_buffer[sizeof(alarm_list_buffer) - 1] = '\0';
+    }
+
+    return alarm_list_buffer;
+}
+
+void DS2312_Delete_Alarm_Index(i2c_dev_t *i2c_dev, uint8_t index)
+{
+    alarm_store_t store;
+    if (load_alarm_store(&store) != ESP_OK) {
+        return;
+    }
+
+    if (index >= store.count) {
+        ESP_LOGW(TAG, "alarm index out of range: %u", index);
+        return;
+    }
+
+    alarm_entry_t deleted = store.entries[index];
+    for (uint8_t i = index; i + 1 < store.count; ++i) {
+        store.entries[i] = store.entries[i + 1];
+    }
+    store.count--;
+    memset(&store.entries[store.count], 0, sizeof(store.entries[store.count]));
+
+    if (save_alarm_store(&store) == ESP_OK) {
+        DS2312_Update_Alarm(i2c_dev, NULL);
+        ESP_LOGI(TAG, "delete alarm: %02d:%02d:%02d", deleted.hour, deleted.min, deleted.sec);
+    }
+}
+
+void DS2312_Delete_Alarm_All(i2c_dev_t *i2c_dev)
+{
+    alarm_store_t store;
+    alarm_store_init_empty(&store);
+
+    if (save_alarm_store(&store) == ESP_OK) {
+        disable_alarm1(i2c_dev);
+        ESP_LOGI(TAG, "delete all alarms");
+    }
+}
+
+esp_err_t DS2312_Set_Unix_Time(i2c_dev_t *i2c_dev, time_t timestamp)
+{
+    if (!i2c_dev || timestamp <= 0) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    struct timeval tv = {
+        .tv_sec = timestamp,
+        .tv_usec = 0,
+    };
+    esp_err_t err = settimeofday(&tv, NULL) == 0 ? ESP_OK : ESP_FAIL;
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "settimeofday failed");
+        return err;
+    }
+
+    setenv("TZ", "CST-8", 1);
+    tzset();
+
+    struct tm timeinfo = {0};
+    localtime_r(&timestamp, &timeinfo);
+    err = ds3231_set_time(i2c_dev, &timeinfo);
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "set DS3231 time failed: %s", esp_err_to_name(err));
+        return err;
+    }
+
+    DS2312_Update_Alarm(i2c_dev, &timeinfo);
+    ESP_LOGI(TAG, "set clock from MQTT: %04d-%02d-%02d %02d:%02d:%02d",
+        timeinfo.tm_year + 1900, timeinfo.tm_mon + 1, timeinfo.tm_mday,
+        timeinfo.tm_hour, timeinfo.tm_min, timeinfo.tm_sec);
+    return ESP_OK;
+}
+
+esp_err_t DS2312_Set_Unix_Time_Saved(time_t timestamp)
+{
+    return DS2312_Set_Unix_Time(ds2321_dev, timestamp);
+}
+
+esp_err_t DS2312_Set_Alarm_Id(i2c_dev_t *i2c_dev, const char *id, int hour, int min, int sec, uint8_t repeat_mask)
+{
+    if (!alarm_id_is_safe(id) || hour < 0 || hour > 23 || min < 0 || min > 59 || sec < 0 || sec > 59 ||
+        (repeat_mask & ALARM_REPEAT_ALL) == 0) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    repeat_mask &= ALARM_REPEAT_ALL;
+
+    alarm_store_t store;
+    esp_err_t err = load_alarm_store(&store);
+    if (err != ESP_OK) {
+        return err;
+    }
+
+    int index = find_alarm_by_id(&store, id);
+    if (index < 0) {
+        if (store.count >= ALARM_MAX_NUM) {
+            return ESP_ERR_NO_MEM;
+        }
+        index = store.count++;
+    }
+
+    alarm_entry_t *entry = &store.entries[index];
+    memset(entry, 0, sizeof(*entry));
+    strlcpy(entry->id, id, sizeof(entry->id));
+    entry->hour = (uint8_t)hour;
+    entry->min = (uint8_t)min;
+    entry->sec = (uint8_t)sec;
+    entry->flags = ALARM_ENTRY_ENABLED;
+    entry->repeat_mask = repeat_mask;
+
+    err = save_alarm_store(&store);
+    if (err == ESP_OK) {
+        DS2312_Update_Alarm(i2c_dev, NULL);
+        ESP_LOGI(TAG, "set alarm id=%s time=%02d:%02d:%02d repeat=0x%02x", id, hour, min, sec, repeat_mask);
+    }
+    return err;
+}
+
+esp_err_t DS2312_Delete_Alarm_Id(i2c_dev_t *i2c_dev, const char *id)
+{
+    if (!alarm_id_is_safe(id)) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    alarm_store_t store;
+    esp_err_t err = load_alarm_store(&store);
+    if (err != ESP_OK) {
+        return err;
+    }
+
+    int index = find_alarm_by_id(&store, id);
+    if (index < 0) {
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    for (uint8_t i = (uint8_t)index; i + 1 < store.count; ++i) {
+        store.entries[i] = store.entries[i + 1];
+    }
+    store.count--;
+    memset(&store.entries[store.count], 0, sizeof(store.entries[store.count]));
+
+    err = save_alarm_store(&store);
+    if (err == ESP_OK) {
+        DS2312_Update_Alarm(i2c_dev, NULL);
+        ESP_LOGI(TAG, "delete alarm id=%s", id);
+    }
+    return err;
+}
+
+esp_err_t DS2312_Set_Alarm_Id_Saved(const char *id, int hour, int min, int sec, uint8_t repeat_mask)
+{
+    return DS2312_Set_Alarm_Id(ds2321_dev, id, hour, min, sec, repeat_mask);
+}
+
+esp_err_t DS2312_Delete_Alarm_Id_Saved(const char *id)
+{
+    return DS2312_Delete_Alarm_Id(ds2321_dev, id);
+}
+
+/* ======================== Battery API ======================== */
+
+uint8_t Battery_Get_Value(void)
+{
+    if (initialize_battery_adc() != ESP_OK) {
+        return 0;
+    }
+
+    int raw = 0;
+    uint32_t raw_sum = 0;
+    uint32_t adc_mv_sum = 0;
+    uint32_t valid_samples = 0;
+    bool use_calibration = initialize_battery_adc_calibration();
+    for (int i = 0; i < BATTERY_ADC_SAMPLES; ++i) {
+        if (adc_oneshot_read(battery_adc_handle, battery_adc_channel, &raw) == ESP_OK) {
+            raw_sum += raw;
+            if (use_calibration) {
+                int calibrated_mv = 0;
+                if (adc_cali_raw_to_voltage(battery_adc_cali_handle, raw, &calibrated_mv) == ESP_OK) {
+                    adc_mv_sum += calibrated_mv;
+                    valid_samples++;
+                }
+            } else {
+                adc_mv_sum += raw * 3300 / 4095;
+                valid_samples++;
+            }
+        }
+    }
+
+    if (valid_samples == 0) {
+        return 0;
+    }
+
+    uint32_t raw_avg = raw_sum / valid_samples;
+    uint32_t adc_mv = adc_mv_sum / valid_samples;
+    uint32_t voltage_mv = adc_mv * BATTERY_ADC_DIVIDER_NUMERATOR / BATTERY_ADC_DIVIDER_DENOMINATOR;
+    ESP_LOGI(TAG, "battery ADC raw=%lu adc=%lumV battery=%lumV", raw_avg, adc_mv, voltage_mv);
+    return battery_mv_to_percent(voltage_mv);
+}
+
+bool Battery_Is_Charging(void)
+{
+    initialize_battery_charging_gpio();
+    return gpio_get_level(ESP_BAT_CHRG) == 1;
+}
+
+uint8_t Board_Get_CPU_Usage(void)
+{
+#if configGENERATE_RUN_TIME_STATS
+    static uint32_t last_total_runtime;
+    static uint32_t last_idle_runtime;
+    UBaseType_t task_count = uxTaskGetNumberOfTasks();
+    TaskStatus_t *tasks = calloc(task_count, sizeof(TaskStatus_t));
+    if (!tasks) {
+        return 0;
+    }
+
+    uint32_t total_runtime = 0;
+    task_count = uxTaskGetSystemState(tasks, task_count, &total_runtime);
+    uint32_t idle_runtime = 0;
+    for (UBaseType_t i = 0; i < task_count; ++i) {
+        if (strncmp(tasks[i].pcTaskName, "IDLE", 4) == 0) {
+            idle_runtime += tasks[i].ulRunTimeCounter;
+        }
+    }
+    free(tasks);
+
+    uint32_t total_delta = total_runtime - last_total_runtime;
+    uint32_t idle_delta = idle_runtime - last_idle_runtime;
+    last_total_runtime = total_runtime;
+    last_idle_runtime = idle_runtime;
+    if (total_delta == 0) {
+        return 0;
+    }
+
+    uint32_t capacity_delta = total_delta * CONFIG_FREERTOS_NUMBER_OF_CORES;
+    if (idle_delta >= capacity_delta) {
+        return 0;
+    }
+
+    uint32_t usage = ((capacity_delta - idle_delta) * 100) / capacity_delta;
+    return usage > 100 ? 100 : (uint8_t)usage;
+#else
+    return 0;
+#endif
+}
+
+/* ======================= SD Card MP3 API ====================== */
+
+const char *SDCard_Get_MP3_List(void)
+{
+    return build_media_list_json(mp3_list_buffer, sizeof(mp3_list_buffer), mp3_name_has_extension);
+}
+
+static bool is_supported_mp3_speed(float speed)
+{
+    const float supported_speeds[] = {0.5f, 0.75f, 1.0f, 1.25f, 1.5f, 2.0f};
+    for (size_t i = 0; i < sizeof(supported_speeds) / sizeof(supported_speeds[0]); ++i) {
+        float diff = speed - supported_speeds[i];
+        if (diff < 0) {
+            diff = -diff;
+        }
+        if (diff < 0.001f) {
+            return true;
+        }
+    }
+    return false;
+}
+
+esp_err_t SDCard_Play_MP3(char *mp3_name)
+{
+    return SDCard_Play_MP3_With_Speed(mp3_name, 1.0f);
+}
+
+esp_err_t SDCard_Play_MP3_With_Speed(char *mp3_name, float speed)
+{
+    if (!is_supported_mp3_speed(speed)) {
+        ESP_LOGW(TAG, "invalid MP3 playback speed: %.2f", speed);
+        SDCard_Resume_Play();
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    char path[SDCARD_PATH_MAX_LEN];
+    if (!resolve_mp3_path(mp3_name, path, sizeof(path))) {
+        ESP_LOGW(TAG, "MP3 not found or invalid: %s", mp3_name ? mp3_name : "null");
+        SDCard_Resume_Play();
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    esp_err_t err = SDCard_Play_MP3_File_With_Speed(path, speed);
+    if (err != ESP_OK) {
+        SDCard_Resume_Play();
+        ESP_LOGE(TAG, "start MP3 playback failed: %s", esp_err_to_name(err));
+        return err;
+    }
+
+    SDCard_Show_Matching_JPG_For_MP3_Path(path);
+    return ESP_OK;
+}
+
+void SDCard_Show_Matching_JPG_For_MP3_Path(const char *mp3_path)
+{
+    char jpg_name[SDCARD_PATH_MAX_LEN];
+    char jpg_path[SDCARD_PATH_MAX_LEN];
+    if (build_matching_jpg_name(mp3_path, jpg_name, sizeof(jpg_name)) &&
+        build_jpg_path(jpg_name, jpg_path, sizeof(jpg_path)) && access(jpg_path, F_OK) == 0) {
+        Board_Show_JPG(jpg_name);
+    }
+}
+
+esp_err_t SDCard_Map_Business_Path(const char *business_path, char *sd_path, size_t sd_path_size)
+{
+    if (!business_path || !sd_path || sd_path_size == 0 || business_path[0] != '/') {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (strstr(business_path, "..") || strchr(business_path, '\\')) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (strcmp(business_path, "/") == 0) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    int written = snprintf(sd_path, sd_path_size, "%s%s", MOUNT_POINT, business_path);
+    if (written <= 0 || (size_t)written >= sd_path_size) {
+        return ESP_ERR_INVALID_SIZE;
+    }
+    return ESP_OK;
+}
+
+esp_err_t SDCard_Prepare_Business_File(const char *business_path,
+                                       char *final_path,
+                                       size_t final_path_size,
+                                       char *part_path,
+                                       size_t part_path_size)
+{
+    esp_err_t err = SDCard_Map_Business_Path(business_path, final_path, final_path_size);
+    if (err != ESP_OK) {
+        return err;
+    }
+
+    char parent[SDCARD_PATH_MAX_LEN];
+    if (strlen(final_path) >= sizeof(parent)) {
+        return ESP_ERR_INVALID_SIZE;
+    }
+    strcpy(parent, final_path);
+    char *slash = strrchr(parent, '/');
+    if (!slash || slash == parent) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    *slash = '\0';
+    err = mkdir_recursive(parent);
+    if (err != ESP_OK) {
+        return err;
+    }
+
+    int written = snprintf(part_path, part_path_size, "%s.part", final_path);
+    if (written <= 0 || (size_t)written >= part_path_size) {
+        return ESP_ERR_INVALID_SIZE;
+    }
+    SDCard_Remove_File_If_Exists(part_path);
+    return ESP_OK;
+}
+
+esp_err_t SDCard_Commit_Part_File(const char *part_path, const char *final_path)
+{
+    if (!part_path || !final_path) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    unlink(final_path);
+    if (rename(part_path, final_path) != 0) {
+        ESP_LOGE(TAG, "commit part file failed: %s -> %s", part_path, final_path);
+        return ESP_FAIL;
+    }
+    return ESP_OK;
+}
+
+void SDCard_Remove_File_If_Exists(const char *path)
+{
+    if (path && path[0] != '\0') {
+        unlink(path);
+    }
+}
+
+esp_err_t SDCard_Delete_File_By_Business_Path(const char *business_path)
+{
+    char path[SDCARD_PATH_MAX_LEN];
+    esp_err_t err = SDCard_Map_Business_Path(business_path, path, sizeof(path));
+    if (err != ESP_OK) {
+        return err;
+    }
+
+    struct stat st;
+    if (stat(path, &st) != 0) {
+        return errno == ENOENT ? ESP_ERR_NOT_FOUND : ESP_FAIL;
+    }
+    if (!S_ISREG(st.st_mode)) {
+        return ESP_ERR_INVALID_ARG;
+    }
+    if (unlink(path) != 0) {
+        ESP_LOGE(TAG, "delete file failed: %s", path);
+        return ESP_FAIL;
+    }
+    ESP_LOGI(TAG, "delete file: %s", path);
+    return ESP_OK;
+}
+
+esp_err_t SDCard_Save_Manifest(const char *course_id, const char *manifest_json)
+{
+    if (!course_id_is_safe(course_id) || !manifest_json) {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    char manifest_dir[SDCARD_PATH_MAX_LEN];
+    int written = snprintf(manifest_dir, sizeof(manifest_dir), "%s/manifest", MOUNT_POINT);
+    if (written <= 0 || (size_t)written >= sizeof(manifest_dir)) {
+        return ESP_ERR_INVALID_SIZE;
+    }
+    esp_err_t err = mkdir_recursive(manifest_dir);
+    if (err != ESP_OK) {
+        return err;
+    }
+
+    char path[SDCARD_PATH_MAX_LEN];
+    written = snprintf(path, sizeof(path), "%s/%s.json", manifest_dir, course_id);
+    if (written <= 0 || (size_t)written >= sizeof(path)) {
+        return ESP_ERR_INVALID_SIZE;
+    }
+
+    FILE *fp = fopen(path, "w");
+    if (!fp) {
+        ESP_LOGE(TAG, "open manifest for write failed: %s", path);
+        return ESP_FAIL;
+    }
+    size_t len = strlen(manifest_json);
+    bool ok = fwrite(manifest_json, 1, len, fp) == len;
+    fclose(fp);
+    if (!ok) {
+        ESP_LOGE(TAG, "write manifest failed: %s", path);
+        return ESP_FAIL;
+    }
+    ESP_LOGI(TAG, "save manifest: %s", path);
+    return ESP_OK;
+}
+
+void SDCard_Delete_MP3(char *mp3_name)
+{
+    char path[SDCARD_PATH_MAX_LEN];
+    if (!build_mp3_path(mp3_name, path, sizeof(path))) {
+        ESP_LOGW(TAG, "invalid MP3 name: %s", mp3_name ? mp3_name : "null");
+        return;
+    }
+
+    if (unlink(path) != 0) {
+        ESP_LOGE(TAG, "delete MP3 failed: %s", path);
+        return;
+    }
+
+    ESP_LOGI(TAG, "delete MP3: %s", path);
+}
+
+/* ======================= SD Card JPG API ====================== */
+
+const char *SDCard_Get_JPG_List(void)
+{
+    return build_media_list_json(jpg_list_buffer, sizeof(jpg_list_buffer), jpg_name_has_extension);
+}
+
+void SDCard_Play_JPG(esp_lcd_panel_handle_t panel, char *jpg_name)
+{
+    if (!panel) {
+        ESP_LOGW(TAG, "LCD panel is not ready");
+        return;
+    }
+
+    char path[SDCARD_PATH_MAX_LEN];
+    if (!build_jpg_path(jpg_name, path, sizeof(path))) {
+        ESP_LOGW(TAG, "invalid JPG name: %s", jpg_name ? jpg_name : "null");
+        return;
+    }
+
+    FILE *fp = fopen(path, "rb");
+    if (!fp) {
+        ESP_LOGW(TAG, "JPG not found: %s", path);
+        return;
+    }
+
+    if (fseek(fp, 0, SEEK_END) != 0) {
+        fclose(fp);
+        ESP_LOGE(TAG, "seek JPG failed: %s", path);
+        return;
+    }
+    long file_size = ftell(fp);
+    if (file_size <= 0 || fseek(fp, 0, SEEK_SET) != 0) {
+        fclose(fp);
+        ESP_LOGE(TAG, "invalid JPG size: %s", path);
+        return;
+    }
+
+    uint8_t *jpg_data = (uint8_t *)malloc((size_t)file_size);
+    if (!jpg_data) {
+        fclose(fp);
+        ESP_LOGE(TAG, "no memory for JPG: %s", path);
+        return;
+    }
+
+    size_t read_size = fread(jpg_data, 1, (size_t)file_size, fp);
+    fclose(fp);
+    if (read_size != (size_t)file_size) {
+        free(jpg_data);
+        ESP_LOGE(TAG, "read JPG failed: %s", path);
+        return;
+    }
+
+    uint8_t *image = NULL;
+    size_t image_len = 0;
+    size_t width = 0;
+    size_t height = 0;
+    size_t stride = 0;
+    esp_err_t err = jpeg_to_image(jpg_data, (size_t)file_size, &image, &image_len, &width, &height, &stride);
+    free(jpg_data);
+    if (err != ESP_OK || !image) {
+        ESP_LOGE(TAG, "decode JPG failed: %s, %s", path, esp_err_to_name(err));
+        return;
+    }
+
+    if (width != JPG_REQUIRED_WIDTH || height != JPG_REQUIRED_HEIGHT) {
+        heap_caps_free(image);
+        ESP_LOGW(TAG, "JPG size unsupported: %s, %ux%u", path, (unsigned)width, (unsigned)height);
+        return;
+    }
+
+    convert_rgb565_to_bgr565(image, width, height, stride);
+    for (size_t y = 0; y < height; ++y) {
+        esp_err_t draw_err = esp_lcd_panel_draw_bitmap(panel, 0, y, width, y + 1, image + y * stride);
+        if (draw_err != ESP_OK) {
+            heap_caps_free(image);
+            ESP_LOGE(TAG, "draw JPG failed: %s, line=%u, %s", path, (unsigned)y, esp_err_to_name(draw_err));
+            return;
+        }
+    }
+    heap_caps_free(image);
+    save_last_jpg_name(jpg_name);
+    ESP_LOGI(TAG, "show JPG: %s", path);
+}
+
+void SDCard_Restore_Last_JPG(esp_lcd_panel_handle_t panel)
+{
+    char jpg_name[SDCARD_PATH_MAX_LEN];
+    if (!load_last_jpg_name(jpg_name, sizeof(jpg_name))) {
+        return;
+    }
+
+    char path[SDCARD_PATH_MAX_LEN];
+    if (build_jpg_path(jpg_name, path, sizeof(path)) && access(path, F_OK) == 0) {
+        SDCard_Play_JPG(panel, jpg_name);
+    } else {
+        ESP_LOGW(TAG, "last JPG no longer exists: %s", jpg_name);
+    }
+}
+
+void SDCard_Delete_JPG(char *jpg_name)
+{
+    char path[SDCARD_PATH_MAX_LEN];
+    if (!build_jpg_path(jpg_name, path, sizeof(path))) {
+        ESP_LOGW(TAG, "invalid JPG name: %s", jpg_name ? jpg_name : "null");
+        return;
+    }
+
+    if (unlink(path) != 0) {
+        ESP_LOGE(TAG, "delete JPG failed: %s", path);
+        return;
+    }
+
+    ESP_LOGI(TAG, "delete JPG: %s", path);
+}
+
+/* ===================== Internal helpers ===================== */
+
+static bool course_id_is_safe(const char *course_id)
+{
+    if (!course_id || course_id[0] == '\0' || strlen(course_id) > 64) {
+        return false;
+    }
+    for (const char *p = course_id; *p; ++p) {
+        if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') ||
+              *p == '_' || *p == '-')) {
+            return false;
+        }
+    }
+    return true;
+}
+
+static esp_err_t mkdir_recursive(const char *path)
+{
+    if (!path || path[0] == '\0') {
+        return ESP_ERR_INVALID_ARG;
+    }
+
+    char tmp[SDCARD_PATH_MAX_LEN];
+    if (strlen(path) >= sizeof(tmp)) {
+        return ESP_ERR_INVALID_SIZE;
+    }
+    strcpy(tmp, path);
+
+    for (char *p = tmp + 1; *p; ++p) {
+        if (*p != '/') {
+            continue;
+        }
+        *p = '\0';
+        if (mkdir(tmp, 0775) != 0 && errno != EEXIST) {
+            return ESP_FAIL;
+        }
+        *p = '/';
+    }
+
+    if (mkdir(tmp, 0775) != 0 && errno != EEXIST) {
+        return ESP_FAIL;
+    }
+    return ESP_OK;
+}
+
+static bool media_name_is_safe(const char *name)
+{
+    if (!name || name[0] == '\0' || strlen(name) >= SDCARD_PATH_MAX_LEN - strlen(MOUNT_POINT) - 2) {
+        return false;
+    }
+
+    if (strchr(name, '\\') || strstr(name, "..") || strcmp(name, ".") == 0) {
+        return false;
+    }
+
+    if (name[0] == '/') {
+        return name[1] != '\0';
+    }
+
+    return strchr(name, '/') == NULL && strcmp(name, "..") != 0;
+}
+
+static bool mp3_name_has_extension(const char *mp3_name)
+{
+    if (!mp3_name) {
+        return false;
+    }
+
+    const char *dot = strrchr(mp3_name, '.');
+    return dot && strcasecmp(dot, ".mp3") == 0;
+}
+
+static bool jpg_name_has_extension(const char *jpg_name)
+{
+    if (!jpg_name) {
+        return false;
+    }
+
+    const char *dot = strrchr(jpg_name, '.');
+    return dot && strcasecmp(dot, ".jpg") == 0;
+}
+
+static bool build_media_path(const char *name, char *path, size_t path_size)
+{
+    if (!media_name_is_safe(name)) {
+        return false;
+    }
+
+    const char *relative_name = name[0] == '/' ? name + 1 : name;
+    int written = snprintf(path, path_size, "%s/%s", MOUNT_POINT, relative_name);
+    return written > 0 && (size_t)written < path_size;
+}
+
+static bool build_mp3_path(const char *mp3_name, char *path, size_t path_size)
+{
+    return mp3_name_has_extension(mp3_name) && build_media_path(mp3_name, path, path_size);
+}
+
+static bool build_jpg_path(const char *jpg_name, char *path, size_t path_size)
+{
+    return jpg_name_has_extension(jpg_name) && build_media_path(jpg_name, path, path_size);
+}
+
+static bool build_matching_jpg_name(const char *mp3_path, char *jpg_name, size_t jpg_name_size)
+{
+    if (!mp3_path || !jpg_name || jpg_name_size == 0) {
+        return false;
+    }
+    size_t mount_len = strlen(MOUNT_POINT);
+    if (strncmp(mp3_path, MOUNT_POINT, mount_len) != 0 || mp3_path[mount_len] != '/') {
+        return false;
+    }
+
+    const char *relative_path = mp3_path + mount_len;
+    size_t len = strlen(relative_path);
+    if (len < 4 || strcasecmp(relative_path + len - 4, ".mp3") != 0 || len >= jpg_name_size) {
+        return false;
+    }
+
+    memcpy(jpg_name, relative_path, len + 1);
+    memcpy(jpg_name + len - 4, ".jpg", 5);
+    return true;
+}
+
+static void append_media_list_dir_json(const char *dir_path, const char *relative_dir, char *buffer, size_t buffer_size,
+                                       bool (*extension_filter)(const char *), size_t *used, int *index, bool *truncated)
+{
+    DIR *dir = opendir(dir_path);
+    if (!dir) {
+        return;
+    }
+
+    struct dirent *entry;
+    while (!*truncated && (entry = readdir(dir)) != NULL) {
+        if (strcmp(entry->d_name, ".") == 0 || strcmp(entry->d_name, "..") == 0) {
+            continue;
+        }
+
+        char entry_path[SDCARD_PATH_MAX_LEN];
+        int written = snprintf(entry_path, sizeof(entry_path), "%s/%s", dir_path, entry->d_name);
+        if (written <= 0 || (size_t)written >= sizeof(entry_path)) {
+            *truncated = true;
+            break;
+        }
+
+        struct stat st;
+        if (stat(entry_path, &st) != 0) {
+            continue;
+        }
+
+        if (S_ISDIR(st.st_mode)) {
+            char child_relative_dir[SDCARD_PATH_MAX_LEN];
+            written = relative_dir[0] == '\0'
+                ? snprintf(child_relative_dir, sizeof(child_relative_dir), "%s", entry->d_name)
+                : snprintf(child_relative_dir, sizeof(child_relative_dir), "%s/%s", relative_dir, entry->d_name);
+            if (written <= 0 || (size_t)written >= sizeof(child_relative_dir)) {
+                *truncated = true;
+                break;
+            }
+            append_media_list_dir_json(entry_path, child_relative_dir, buffer, buffer_size, extension_filter, used, index, truncated);
+            continue;
+        }
+
+        if (!S_ISREG(st.st_mode) || !extension_filter(entry->d_name)) {
+            continue;
+        }
+
+        char display_name[SDCARD_PATH_MAX_LEN];
+        written = relative_dir[0] == '\0'
+            ? snprintf(display_name, sizeof(display_name), "%s", entry->d_name)
+            : snprintf(display_name, sizeof(display_name), "/%s/%s", relative_dir, entry->d_name);
+        if (written <= 0 || (size_t)written >= sizeof(display_name)) {
+            *truncated = true;
+            break;
+        }
+
+        written = snprintf(buffer + *used, buffer_size - *used,
+            "%s{\"index\":%d,\"name\":", *index == 0 ? "" : ",", *index);
+        if (written < 0 || *used + (size_t)written >= buffer_size) {
+            *truncated = true;
+            break;
+        }
+        *used += (size_t)written;
+        *used = append_json_escaped_string(buffer, buffer_size, *used, display_name);
+        if (*used >= buffer_size) {
+            *truncated = true;
+            break;
+        }
+        written = snprintf(buffer + *used, buffer_size - *used, "}");
+        if (written < 0 || *used + (size_t)written >= buffer_size) {
+            *truncated = true;
+            break;
+        }
+        *used += (size_t)written;
+        (*index)++;
+    }
+    closedir(dir);
+}
+
+static const char *build_media_list_json(char *buffer, size_t buffer_size, bool (*extension_filter)(const char *))
+{
+    DIR *dir = opendir(MOUNT_POINT);
+    if (!dir) {
+        snprintf(buffer, buffer_size, "{\"files\":[],\"count\":0,\"truncated\":false,\"error\":\"failed to open %s\"}", MOUNT_POINT);
+        return buffer;
+    }
+    closedir(dir);
+
+    size_t used = 0;
+    int index = 0;
+    bool truncated = false;
+    int written = snprintf(buffer, buffer_size, "{\"files\":[");
+    if (written < 0 || (size_t)written >= buffer_size) {
+        snprintf(buffer, buffer_size, "{\"files\":[],\"count\":0,\"truncated\":true}");
+        return buffer;
+    }
+    used = (size_t)written;
+
+    append_media_list_dir_json(MOUNT_POINT, "", buffer, buffer_size, extension_filter, &used, &index, &truncated);
+
+    if (used < buffer_size) {
+        snprintf(buffer + used, buffer_size - used, "],\"count\":%d,\"truncated\":%s}", index, truncated ? "true" : "false");
+    } else {
+        snprintf(buffer, buffer_size, "{\"files\":[],\"count\":0,\"truncated\":true}");
+    }
+    return buffer;
+}
+
+static bool resolve_mp3_path(const char *mp3_name, char *path, size_t path_size)
+{
+    if (build_mp3_path(mp3_name, path, path_size) && access(path, F_OK) == 0) {
+        return true;
+    }
+
+    if (!media_name_is_safe(mp3_name) || mp3_name_has_extension(mp3_name)) {
+        return false;
+    }
+
+    DIR *dir = opendir(MOUNT_POINT);
+    if (!dir) {
+        return false;
+    }
+
+    bool found = false;
+    struct dirent *entry;
+    while ((entry = readdir(dir)) != NULL) {
+        if (!mp3_name_has_extension(entry->d_name) || !strstr(entry->d_name, mp3_name)) {
+            continue;
+        }
+        int written = snprintf(path, path_size, "%s/%s", MOUNT_POINT, entry->d_name);
+        found = written > 0 && (size_t)written < path_size;
+        break;
+    }
+    closedir(dir);
+    return found;
+}
+
+static void convert_rgb565_to_bgr565(uint8_t *image, size_t width, size_t height, size_t stride)
+{
+    for (size_t y = 0; y < height; ++y) {
+        uint16_t *line = (uint16_t *)(image + y * stride);
+        for (size_t x = 0; x < width; ++x) {
+            uint16_t pixel = line[x];
+            uint16_t r = (pixel >> 11) & 0x1F;
+            uint16_t g = pixel & 0x07E0;
+            uint16_t b = pixel & 0x1F;
+            line[x] = (b << 11) | g | r;
+        }
+    }
+}
+
+static void save_last_jpg_name(const char *jpg_name)
+{
+    nvs_handle_t handle;
+    if (nvs_open(JPG_SETTINGS_NAMESPACE, NVS_READWRITE, &handle) != ESP_OK) {
+        return;
+    }
+    nvs_set_str(handle, JPG_SETTINGS_LAST_NAME, jpg_name);
+    nvs_commit(handle);
+    nvs_close(handle);
+}
+
+static bool load_last_jpg_name(char *jpg_name, size_t jpg_name_size)
+{
+    nvs_handle_t handle;
+    if (nvs_open(JPG_SETTINGS_NAMESPACE, NVS_READONLY, &handle) != ESP_OK) {
+        return false;
+    }
+
+    size_t length = jpg_name_size;
+    esp_err_t err = nvs_get_str(handle, JPG_SETTINGS_LAST_NAME, jpg_name, &length);
+    nvs_close(handle);
+    return err == ESP_OK && jpg_name[0] != '\0';
+}
+
+static esp_err_t initialize_battery_adc(void)
+{
+    if (battery_adc_initialized) {
+        return ESP_OK;
+    }
+
+    esp_err_t err = adc_oneshot_io_to_channel(ESP_BAT_ADC, &battery_adc_unit, &battery_adc_channel);
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "battery ADC pin mapping failed: %s", esp_err_to_name(err));
+        return err;
+    }
+
+    adc_oneshot_unit_init_cfg_t init_config = {
+        .unit_id = battery_adc_unit,
+        .ulp_mode = ADC_ULP_MODE_DISABLE,
+    };
+    err = adc_oneshot_new_unit(&init_config, &battery_adc_handle);
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "battery ADC unit init failed: %s", esp_err_to_name(err));
+        return err;
+    }
+
+    adc_oneshot_chan_cfg_t channel_config = {
+        .atten = ADC_ATTEN_DB_12,
+        .bitwidth = ADC_BITWIDTH_12,
+    };
+    err = adc_oneshot_config_channel(battery_adc_handle, battery_adc_channel, &channel_config);
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "battery ADC channel config failed: %s", esp_err_to_name(err));
+        return err;
+    }
+
+    battery_adc_initialized = true;
+    initialize_battery_adc_calibration();
+    return ESP_OK;
+}
+
+static bool initialize_battery_adc_calibration(void)
+{
+    if (battery_adc_cali_initialized) {
+        return battery_adc_cali_handle != NULL;
+    }
+
+#if ADC_CALI_SCHEME_CURVE_FITTING_SUPPORTED
+    adc_cali_curve_fitting_config_t cali_config = {
+        .unit_id = battery_adc_unit,
+        .chan = battery_adc_channel,
+        .atten = ADC_ATTEN_DB_12,
+        .bitwidth = ADC_BITWIDTH_12,
+    };
+    esp_err_t err = adc_cali_create_scheme_curve_fitting(&cali_config, &battery_adc_cali_handle);
+#elif ADC_CALI_SCHEME_LINE_FITTING_SUPPORTED
+    adc_cali_line_fitting_config_t cali_config = {
+        .unit_id = battery_adc_unit,
+        .atten = ADC_ATTEN_DB_12,
+        .bitwidth = ADC_BITWIDTH_12,
+    };
+    esp_err_t err = adc_cali_create_scheme_line_fitting(&cali_config, &battery_adc_cali_handle);
+#else
+    esp_err_t err = ESP_ERR_NOT_SUPPORTED;
+#endif
+
+    battery_adc_cali_initialized = true;
+    if (err != ESP_OK) {
+        ESP_LOGW(TAG, "battery ADC calibration unavailable: %s", esp_err_to_name(err));
+        battery_adc_cali_handle = NULL;
+        return false;
+    }
+    return true;
+}
+
+static void initialize_battery_charging_gpio(void)
+{
+    if (battery_charging_gpio_initialized) {
+        return;
+    }
+
+    gpio_config_t gpio_cfg = {
+        .pin_bit_mask = 1ULL << ESP_BAT_CHRG,
+        .mode = GPIO_MODE_INPUT,
+        .pull_up_en = GPIO_PULLUP_ENABLE,
+        .pull_down_en = GPIO_PULLDOWN_DISABLE,
+        .intr_type = GPIO_INTR_DISABLE,
+    };
+    ESP_ERROR_CHECK_WITHOUT_ABORT(gpio_config(&gpio_cfg));
+    battery_charging_gpio_initialized = true;
+}
+
+static uint8_t battery_mv_to_percent(uint32_t mv)
+{
+    if (mv <= BATTERY_MIN_MV) {
+        return 0;
+    }
+    if (mv >= BATTERY_MAX_MV) {
+        return 100;
+    }
+    return (uint8_t)((mv - BATTERY_MIN_MV) * 100 / (BATTERY_MAX_MV - BATTERY_MIN_MV));
+}
+
+static size_t append_json_escaped_string(char *buffer, size_t buffer_size, size_t used, const char *value)
+{
+    if (used >= buffer_size) {
+        return used;
+    }
+
+    buffer[used++] = '"';
+    for (const char *p = value; *p && used + 2 < buffer_size; ++p) {
+        if (*p == '"' || *p == '\\') {
+            buffer[used++] = '\\';
+        }
+        buffer[used++] = *p;
+    }
+    if (used < buffer_size) {
+        buffer[used++] = '"';
+    }
+    if (used < buffer_size) {
+        buffer[used] = '\0';
+    } else {
+        buffer[buffer_size - 1] = '\0';
+    }
+    return used;
+}
+
+static void sntp_finish_callback(struct timeval *tv)
+{
+    setenv("TZ", "CST-8", 1);
+    tzset();
+
+    time_t now = 0;
+    struct tm timeinfo = {0};
+    time(&now);
+    localtime_r(&now, &timeinfo);
+    ESP_LOGI(TAG, "鏈湴鏃堕棿: %d-%02d-%02d %02d:%02d:%02d",
+        timeinfo.tm_year + 1900, timeinfo.tm_mon + 1,
+        timeinfo.tm_mday, timeinfo.tm_hour,
+        timeinfo.tm_min, timeinfo.tm_sec);
+
+    if (!ds2321_dev) {
+        ESP_LOGW(TAG, "DS3231 device is not ready");
+        return;
+    }
+
+    struct tm rtc_time = timeinfo;
+    ESP_ERROR_CHECK_WITHOUT_ABORT(ds3231_set_time(ds2321_dev, &rtc_time));
+    ESP_ERROR_CHECK_WITHOUT_ABORT(ds3231_get_time(ds2321_dev, &rtc_time));
+
+    ESP_LOGI(TAG, "DS3231 褰撳墠鏃堕棿: %04d-%02d-%02d %02d:%02d:%02d",
+        rtc_time.tm_year + 1900, rtc_time.tm_mon + 1,
+        rtc_time.tm_mday, rtc_time.tm_hour,
+        rtc_time.tm_min, rtc_time.tm_sec);
+
+    DS2312_Update_Alarm(ds2321_dev, &rtc_time);
+    ESP_LOGI(TAG, "SNTP 鍚屾瀹屾垚");
+}
+
+static uint32_t alarm_store_checksum(const alarm_store_t *store)
+{
+    alarm_store_t copy = *store;
+    copy.checksum = 0;
+
+    uint32_t hash = 2166136261U;
+    const uint8_t *data = (const uint8_t *)&copy;
+    for (size_t i = 0; i < sizeof(copy); ++i) {
+        hash ^= data[i];
+        hash *= 16777619U;
+    }
+    return hash;
+}
+
+static void alarm_store_init_empty(alarm_store_t *store)
+{
+    memset(store, 0, sizeof(*store));
+    store->magic = ALARM_STORE_MAGIC;
+    store->version = ALARM_STORE_VERSION;
+    store->checksum = alarm_store_checksum(store);
+}
+
+static bool alarm_entry_is_valid(const alarm_entry_t *entry)
+{
+    return alarm_id_is_safe(entry->id) && entry->hour <= 23 && entry->min <= 59 && entry->sec <= 59 &&
+        (entry->repeat_mask & ALARM_REPEAT_ALL) != 0;
+}
+
+static bool alarm_id_is_safe(const char *id)
+{
+    if (!id || id[0] == '\0' || strlen(id) >= ALARM_ID_MAX_LEN) {
+        return false;
+    }
+    for (const char *p = id; *p; ++p) {
+        if (!((*p >= 'a' && *p <= 'z') || (*p >= 'A' && *p <= 'Z') || (*p >= '0' && *p <= '9') ||
+              *p == '_' || *p == '-')) {
+            return false;
+        }
+    }
+    return true;
+}
+
+static int find_alarm_by_id(const alarm_store_t *store, const char *id)
+{
+    for (uint8_t i = 0; i < store->count; ++i) {
+        if (strcmp(store->entries[i].id, id) == 0) {
+            return i;
+        }
+    }
+    return -1;
+}
+
+static int alarm_entry_seconds(const alarm_entry_t *entry)
+{
+    return entry->hour * 3600 + entry->min * 60 + entry->sec;
+}
+
+static int tm_seconds(const struct tm *time)
+{
+    return time->tm_hour * 3600 + time->tm_min * 60 + time->tm_sec;
+}
+
+static void alarm_store_sort(alarm_store_t *store)
+{
+    for (uint8_t i = 0; i < store->count; ++i) {
+        for (uint8_t j = i + 1; j < store->count; ++j) {
+            if (alarm_entry_seconds(&store->entries[j]) < alarm_entry_seconds(&store->entries[i])) {
+                alarm_entry_t tmp = store->entries[i];
+                store->entries[i] = store->entries[j];
+                store->entries[j] = tmp;
+            }
+        }
+    }
+}
+
+static const esp_partition_t *get_config_partition(void)
+{
+    const esp_partition_t *partition = esp_partition_find_first(
+        ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_ANY, "config");
+    if (!partition) {
+        ESP_LOGE(TAG, "config partition not found");
+    }
+    return partition;
+}
+
+static esp_err_t load_alarm_store(alarm_store_t *store)
+{
+    const esp_partition_t *partition = get_config_partition();
+    if (!partition) {
+        alarm_store_init_empty(store);
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    esp_err_t err = esp_partition_read(partition, 0, store, sizeof(*store));
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "read alarm store failed: %s", esp_err_to_name(err));
+        alarm_store_init_empty(store);
+        return err;
+    }
+
+    if (store->magic != ALARM_STORE_MAGIC ||
+        store->version != ALARM_STORE_VERSION ||
+        store->count > ALARM_MAX_NUM ||
+        store->checksum != alarm_store_checksum(store)) {
+        alarm_store_init_empty(store);
+        return ESP_OK;
+    }
+
+    for (uint8_t i = 0; i < store->count; ++i) {
+        if (!alarm_entry_is_valid(&store->entries[i])) {
+            ESP_LOGW(TAG, "invalid alarm entry in flash, reset alarm store");
+            alarm_store_init_empty(store);
+            return ESP_OK;
+        }
+    }
+
+    alarm_store_sort(store);
+    return ESP_OK;
+}
+
+static esp_err_t save_alarm_store(alarm_store_t *store)
+{
+    const esp_partition_t *partition = get_config_partition();
+    if (!partition) {
+        return ESP_ERR_NOT_FOUND;
+    }
+
+    store->magic = ALARM_STORE_MAGIC;
+    store->version = ALARM_STORE_VERSION;
+    alarm_store_sort(store);
+    store->checksum = alarm_store_checksum(store);
+
+    esp_err_t err = esp_partition_erase_range(partition, 0, partition->size);
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "erase alarm store failed: %s", esp_err_to_name(err));
+        return err;
+    }
+
+    err = esp_partition_write(partition, 0, store, sizeof(*store));
+    if (err != ESP_OK) {
+        ESP_LOGE(TAG, "write alarm store failed: %s", esp_err_to_name(err));
+    }
+    return err;
+}
+
+static bool get_current_time(i2c_dev_t *i2c_dev, struct tm *timeinfo)
+{
+    memset(timeinfo, 0, sizeof(*timeinfo));
+
+    if (i2c_dev && ds3231_get_time(i2c_dev, timeinfo) == ESP_OK) {
+        return true;
+    }
+
+    time_t now = 0;
+    time(&now);
+    localtime_r(&now, timeinfo);
+    return now > 0;
+}
+
+static int select_next_alarm_index(const alarm_store_t *store, const struct tm *now)
+{
+    if (store->count == 0) {
+        return -1;
+    }
+
+    int best_index = -1;
+    int best_delta = SECONDS_PER_DAY * 8 + 1;
+
+    for (uint8_t i = 0; i < store->count; ++i) {
+        int delta = alarm_delta_seconds(&store->entries[i], now);
+        if (delta < best_delta) {
+            best_delta = delta;
+            best_index = i;
+        }
+    }
+
+    return best_index;
+}
+
+static int alarm_delta_seconds(const alarm_entry_t *entry, const struct tm *now)
+{
+    int now_sec = tm_seconds(now);
+    int alarm_sec = alarm_entry_seconds(entry);
+    int best_delta = SECONDS_PER_DAY * 8;
+
+    for (int day_offset = 0; day_offset <= 7; ++day_offset) {
+        int tm_wday = (now->tm_wday + day_offset) % 7;
+        if ((entry->repeat_mask & tm_wday_to_repeat_bit(tm_wday)) == 0) {
+            continue;
+        }
+        int delta = day_offset * SECONDS_PER_DAY + alarm_sec - now_sec;
+        if (delta <= 0) {
+            continue;
+        }
+        if (delta < best_delta) {
+            best_delta = delta;
+        }
+    }
+
+    return best_delta;
+}
+
+static int mqtt_weekday_to_tm_wday(int day)
+{
+    return day == 7 ? 0 : day;
+}
+
+static uint8_t tm_wday_to_repeat_bit(int tm_wday)
+{
+    switch (tm_wday) {
+    case 1: return ALARM_REPEAT_MON;
+    case 2: return ALARM_REPEAT_TUE;
+    case 3: return ALARM_REPEAT_WED;
+    case 4: return ALARM_REPEAT_THU;
+    case 5: return ALARM_REPEAT_FRI;
+    case 6: return ALARM_REPEAT_SAT;
+    default: return ALARM_REPEAT_SUN;
+    }
+}
+
+static bool alarm_entry_equals(const alarm_entry_t *left, const alarm_entry_t *right)
+{
+    return strcmp(left->id, right->id) == 0 && left->hour == right->hour && left->min == right->min &&
+        left->sec == right->sec && left->repeat_mask == right->repeat_mask;
+}
+
+static void alarm_power_off_restore_task(void *arg)
+{
+    int timeout_ms = (int)(intptr_t)arg;
+    esp_err_t err = Board_Restore_From_Power_Off(timeout_ms);
+    ESP_LOGI(TAG, "power-off restore from alarm finished: %s", esp_err_to_name(err));
+    alarm_power_off_restore_task_handle = NULL;
+    vTaskDelete(NULL);
+}
+
+static void schedule_power_off_restore_from_alarm(void)
+{
+    if (!Board_Is_Power_Off()) {
+        return;
+    }
+    if (alarm_power_off_restore_task_handle) {
+        ESP_LOGI(TAG, "power-off restore already pending");
+        return;
+    }
+
+    BaseType_t ret = xTaskCreate(alarm_power_off_restore_task, "alarm_pwr_restore", 4096,
+                                 (void *)(intptr_t)POWER_OFF_WAKE_RECONNECT_TIMEOUT_MS, 5,
+                                 &alarm_power_off_restore_task_handle);
+    if (ret != pdPASS) {
+        alarm_power_off_restore_task_handle = NULL;
+        ESP_LOGE(TAG, "create alarm power-off restore task failed");
+    }
+}
+
+static void alarm_monitor_task(void *arg)
+{
+    i2c_dev_t *i2c_dev = (i2c_dev_t *)arg;
+
+    while (true) {
+        vTaskDelay(pdMS_TO_TICKS(1000));
+
+        alarm_store_t store;
+        if (load_alarm_store(&store) != ESP_OK || store.count == 0) {
+            if (current_alarm1_valid) {
+                disable_alarm1(i2c_dev);
+            }
+            continue;
+        }
+
+        struct tm now;
+        if (!get_current_time(i2c_dev, &now)) {
+            continue;
+        }
+
+        ds3231_alarm_t alarm_flags = DS3231_ALARM_NONE;
+        if (ds3231_get_alarm_flags(i2c_dev, &alarm_flags) == ESP_OK && (alarm_flags & DS3231_ALARM_1)) {
+            ESP_LOGI(TAG, "DS3231 alarm1 triggered");
+            ds3231_clear_alarm_flags(i2c_dev, DS3231_ALARM_1);
+            current_alarm1_valid = false;
+            schedule_power_off_restore_from_alarm();
+        }
+
+        int next_index = select_next_alarm_index(&store, &now);
+        if (next_index < 0) {
+            if (current_alarm1_valid && !Board_Is_Power_Off()) {
+                disable_alarm1(i2c_dev);
+            }
+            continue;
+        }
+
+        alarm_entry_t *next_alarm = &store.entries[next_index];
+        if (!current_alarm1_valid) {
+            program_alarm1(i2c_dev, next_alarm);
+            continue;
+        }
+
+        int next_delta = alarm_delta_seconds(next_alarm, &now);
+        int current_delta = alarm_delta_seconds(&current_alarm1_entry, &now);
+        if (next_delta < current_delta || !alarm_entry_equals(next_alarm, &current_alarm1_entry)) {
+            program_alarm1(i2c_dev, next_alarm);
+        }
+    }
+}
+
+static void disable_alarm1(i2c_dev_t *i2c_dev)
+{
+    if (!i2c_dev) {
+        return;
+    }
+
+    ESP_ERROR_CHECK_WITHOUT_ABORT(ds3231_disable_alarm_ints(i2c_dev, DS3231_ALARM_1));
+    ESP_ERROR_CHECK_WITHOUT_ABORT(ds3231_clear_alarm_flags(i2c_dev, DS3231_ALARM_1));
+    current_alarm1_valid = false;
+}
+
+static void program_alarm1(i2c_dev_t *i2c_dev, const alarm_entry_t *entry)
+{
+    if (!i2c_dev || !entry) {
+        return;
+    }
+
+    struct tm now;
+    if (!get_current_time(i2c_dev, &now)) {
+        return;
+    }
+
+    int now_sec = tm_seconds(&now);
+    int alarm_sec = alarm_entry_seconds(entry);
+    int best_delta = SECONDS_PER_DAY * 8;
+    struct tm best_time = now;
+    for (int day_offset = 0; day_offset <= 7; ++day_offset) {
+        int tm_wday = (now.tm_wday + day_offset) % 7;
+        if ((entry->repeat_mask & tm_wday_to_repeat_bit(tm_wday)) == 0) {
+            continue;
+        }
+        int delta = day_offset * SECONDS_PER_DAY + alarm_sec - now_sec;
+        if (delta <= 0) {
+            continue;
+        }
+        if (delta < best_delta) {
+            best_delta = delta;
+            time_t target = mktime(&now) + delta;
+            localtime_r(&target, &best_time);
+        }
+    }
+
+    if (best_delta >= SECONDS_PER_DAY * 8) {
+        disable_alarm1(i2c_dev);
+        return;
+    }
+
+    struct tm alarm_time = {0};
+    alarm_time.tm_hour = entry->hour;
+    alarm_time.tm_min = entry->min;
+    alarm_time.tm_sec = entry->sec;
+    alarm_time.tm_mday = best_time.tm_mday;
+
+    ESP_ERROR_CHECK_WITHOUT_ABORT(ds3231_clear_alarm_flags(i2c_dev, DS3231_ALARM_1));
+    ESP_ERROR_CHECK_WITHOUT_ABORT(ds3231_set_alarm(i2c_dev, DS3231_ALARM_1, &alarm_time,
+        DS3231_ALARM1_MATCH_SECMINHOURDATE, NULL, 0));
+    ESP_ERROR_CHECK_WITHOUT_ABORT(ds3231_enable_alarm_ints(i2c_dev, DS3231_ALARM_1));
+
+    current_alarm1_entry = *entry;
+    current_alarm1_valid = true;
+    ESP_LOGI(TAG, "program DS3231 alarm1: id=%s %02d:%02d:%02d mday=%d", entry->id,
+        entry->hour, entry->min, entry->sec, best_time.tm_mday);
+}
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h
new file mode 100644
index 0000000..930de40
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/system.h
@@ -0,0 +1,402 @@
+#ifndef __SYSTEM_H__
+#define __SYSTEM_H__
+
+#define APP_FIRMWARE_VERSION "1.0.0"
+
+#include "esp_log.h"
+#include "freertos/FreeRTOS.h"
+#include "freertos/task.h"
+#include <stddef.h>
+#include <stdint.h>
+#include <time.h>
+#include <sys/time.h>
+#include "esp_sntp.h"
+#include "driver/gpio.h"
+#include "driver/i2c_master.h"
+#include <ds3231.h>
+#include <stdbool.h>
+#include <esp_lcd_panel_io.h>
+#include <esp_lcd_panel_ops.h>
+#include <esp_lcd_gc9d01.h>
+
+#ifdef __cplusplus
+extern "C" {
+#endif
+
+#define ALARM_MAX_NUM   10
+#define ALARM_ID_MAX_LEN 32
+#define ALARM_REPEAT_MON 0x01
+#define ALARM_REPEAT_TUE 0x02
+#define ALARM_REPEAT_WED 0x04
+#define ALARM_REPEAT_THU 0x08
+#define ALARM_REPEAT_FRI 0x10
+#define ALARM_REPEAT_SAT 0x20
+#define ALARM_REPEAT_SUN 0x40
+#define ALARM_REPEAT_ALL 0x7F
+
+#define MOUNT_POINT "/sdcard"
+#define SDCARD_PATH_MAX_LEN 256
+
+#define SCREEN_MIRROR_X false
+#define SCREEN_MIRROR_Y false
+#define SCREEN_SWAP_XY  false
+
+#define WIFI_HISTORY_MAX_NUM 5
+
+#define BOARD_MQTT_BROKER_HOST "124.222.109.197"
+#define BOARD_MQTT_BROKER_PORT 2100
+#define BOARD_MQTT_USERNAME "AI_XIAOZHI"
+#define BOARD_MQTT_PASSWORD "V9qL7!mX2@rT8$kN5^wP1&c"
+#define BOARD_MQTT_CMD_CTL_TOPIC_FORMAT "snlp/v1/%s/cmd/ctl"
+#define BOARD_MQTT_CMD_TASK_TOPIC_FORMAT "snlp/v1/%s/cmd/task"
+#define BOARD_MQTT_RPT_LOG_TOPIC_FORMAT "snlp/v1/%s/rpt/log"
+#define BOARD_MQTT_RPT_ACK_TOPIC_FORMAT "snlp/v1/%s/rpt/ack"
+#define BOARD_MQTT_RPT_PROGRESS_TOPIC_FORMAT "snlp/v1/%s/rpt/progress"
+#define BOARD_MQTT_RPT_STATE_TOPIC_FORMAT "snlp/v1/%s/rpt/state"
+#define BOARD_MQTT_RPT_EVENT_TOPIC_FORMAT "snlp/v1/%s/rpt/event"
+#define BOARD_MQTT_EXECUTE_COMMANDS 1
+
+/**
+ * @brief Wi-Fi 寰呮満浣庡姛鑰楄秴鏃舵椂闂达紝鍗曚綅涓虹銆�
+ *
+ * 褰撹澶囨寔缁浜庣┖闂插緟鍛界姸鎬佽秴杩囪鏃堕棿鍚庯紝鏉跨骇閫昏緫浼氭柇寮� Wi-Fi station 浠ラ檷浣庡姛鑰楋紝
+ * 浣嗕笉浼氬叧闂湰鍦扮绾垮敜閱掕瘝妫�娴嬨�傝缃负 0 鎴栬礋鏁板彲绂佺敤璇ュ緟鏈烘ā寮忋��
+ */
+#define WIFI_STANDBY_TIMEOUT_SEC 300
+#define POWER_OFF_WAKE_RECONNECT_TIMEOUT_MS 30000
+
+/**
+ * @brief 鏉跨骇 Custom/MultiNet 鍞ら啋璇嶉厤缃��
+ *
+ * 濡傛灉闇�瑕佷慨鏀规櫤鑳戒綋鐨勫悕锛岃鍦ㄦ帶鍒跺彴涓慨鏀瑰皬鏅虹殑璁板繂銆�
+ */
+#define BOARD_CUSTOM_WAKE_WORD_PINYIN "ni hao xiao zhi"
+#define BOARD_CUSTOM_WAKE_WORD_DISPLAY "浣犲ソ灏忔櫤"
+#define BOARD_CUSTOM_WAKE_WORD_THRESHOLD_PERCENT 20
+
+/**
+ * @brief 鏉跨骇 MultiNet 绂荤嚎鍛戒护璇嶉厤缃��
+ *
+ */
+#define OFFLINE_COMMAND_WORD "鍒囨崲妯″紡"
+#define OFFLINE_COMMAND_WORD_PINYIN "qie huan mo shi"
+
+void Board_Offline_Command_Callback(void);
+bool Board_Handle_Offline_Command(const char *command);
+
+/**
+ * @brief 浣跨敤 SNTP 缃戠粶鏃堕棿鍚屾 DS3231 RTC銆�
+ *
+ * 璇ュ嚱鏁颁細鍚姩 SNTP 瀹㈡埛绔苟鍦ㄦ椂闂村悓姝ュ畬鎴愬悗鍐欏叆 DS3231锛岄�傜敤浜庤澶囪繛鎺� Wi-Fi
+ * 涔嬪悗鏍″噯 RTC 鏃堕棿銆傚嚱鏁颁細淇濆瓨浼犲叆鐨� DS3231 I2C 璁惧鎻忚堪绗︼紝渚� SNTP 鍚屾瀹屾垚
+ * 鍥炶皟缁х画浣跨敤銆�
+ *
+ * @param i2c_dev 宸插垵濮嬪寲鐨� DS3231 I2C 璁惧鎻忚堪绗︼紝涓嶈兘涓虹┖銆�
+ */
+void SNTP_To_DS2312(i2c_dev_t *i2c_dev);
+
+/**
+ * @brief 鏍规嵁宸蹭繚瀛樼殑姣忔棩闂归挓鍒楄〃鏇存柊 DS3231 Alarm1銆�
+ *
+ * 璇ュ嚱鏁颁細璇诲彇鎸佷箙鍖栭椆閽熷垪琛紝閫夋嫨鐩稿浜庡綋鍓嶆椂闂存渶杩戠殑鏈潵闂归挓锛屽苟鍐欏叆
+ * DS3231 Alarm1銆傝嫢鍒楄〃涓虹┖鎴栬鍙栧け璐ワ紝鍒欎細鍏抽棴 Alarm1銆備紶鍏� time 鏃朵娇鐢ㄨ
+ * 鏃堕棿浣滀负褰撳墠鏃堕棿鍩哄噯锛涗紶鍏� NULL 鏃朵細浼樺厛浠� DS3231 璇诲彇褰撳墠鏃堕棿锛屽け璐ュ悗鍥為��
+ * 鍒扮郴缁熸椂闂淬��
+ *
+ * @param i2c_dev 宸插垵濮嬪寲鐨� DS3231 I2C 璁惧鎻忚堪绗︼紝涓嶈兘涓虹┖銆�
+ * @param time 鍙�夌殑褰撳墠鏃堕棿鍩哄噯锛涗负 NULL 鏃剁敱鍑芥暟鑷鑾峰彇褰撳墠鏃堕棿銆�
+ */
+void DS2312_Update_Alarm(i2c_dev_t *i2c_dev, struct tm *time);
+
+/**
+ * @brief 鍚姩 DS3231 闂归挓鍚庡彴鐩戞帶浠诲姟銆�
+ *
+ * 璇ュ嚱鏁颁細鍒涘缓涓�涓悗鍙颁换鍔★紝姣忕妫�鏌ラ椆閽熷垪琛ㄣ�佸綋鍓嶆椂闂村拰 DS3231 Alarm1 瑙﹀彂鏍囧織銆�
+ * 濡傛灉妫�娴嬪埌褰撳墠鍐欏叆鐨� Alarm1 宸蹭笉鏄渶杩戠殑鏈潵闂归挓锛屾垨 Alarm1 宸茶Е鍙戜絾娌℃湁澶栭儴
+ * IO 閫氱煡锛屼换鍔′細涓诲姩娓呴櫎鏍囧織骞堕噸鏂拌皟搴﹀悗缁椆閽熴�傞噸澶嶈皟鐢ㄤ笉浼氬垱寤哄涓洃鎺т换鍔°��
+ *
+ * @param i2c_dev 宸插垵濮嬪寲鐨� DS3231 I2C 璁惧鎻忚堪绗︼紝涓嶈兘涓虹┖銆�
+ */
+void DS2312_Start_Alarm_Monitor(i2c_dev_t *i2c_dev);
+
+/**
+ * @brief 娣诲姞涓�涓瘡鏃ラ噸澶嶇殑 RTC 闂归挓銆�
+ *
+ * 璇ュ嚱鏁颁細灏嗘寚瀹氭椂鍒嗙鍐欏叆鎸佷箙鍖栭椆閽熷垪琛紝骞跺湪淇濆瓨鎴愬姛鍚庣珛鍗抽噸鏂拌皟搴�
+ * DS3231 Alarm1銆傝嫢鏃堕棿鍙傛暟闈炴硶銆侀椆閽熷凡瀛樺湪鎴栧垪琛ㄥ凡婊★紝鍑芥暟浼氬拷鐣ユ湰娆℃坊鍔犮��
+ *
+ * @param i2c_dev 宸插垵濮嬪寲鐨� DS3231 I2C 璁惧鎻忚堪绗︼紝涓嶈兘涓虹┖銆�
+ * @param hour 灏忔椂锛屽彇鍊艰寖鍥� 0-23銆�
+ * @param min 鍒嗛挓锛屽彇鍊艰寖鍥� 0-59銆�
+ * @param sec 绉掞紝鍙栧�艰寖鍥� 0-59銆�
+ */
+void DS2312_Add_Alarm_Time(i2c_dev_t *i2c_dev, int hour, int min, int sec);
+
+/**
+ * @brief 鑾峰彇褰撳墠淇濆瓨鐨� RTC 闂归挓鍒楄〃銆�
+ *
+ * 杩斿洖鍊间负 JSON 瀛楃涓诧紝渚� MCP 鎴栧叾浠栦笂灞傞�昏緫鐩存帴瑙f瀽銆傚瓧绗︿覆鐢辨ā鍧楀唴閮ㄩ潤鎬佺紦鍐插尯
+ * 淇濆瓨锛岃皟鐢ㄨ�呬笉搴旈噴鏀炬垨闀挎湡淇濆瓨璇ユ寚閽堬紱鍚庣画鍐嶆璋冪敤鏈嚱鏁板彲鑳借鐩栧師鍐呭銆�
+ *
+ * @param i2c_dev 淇濈暀鍙傛暟锛岀敤浜庝繚鎸佹帴鍙d笌鍏朵粬 DS2312 鎿嶄綔涓�鑷达紝褰撳墠瀹炵幇涓嶄緷璧栬鍙傛暟銆�
+ * @return 鎸囧悜鍐呴儴闈欐�佺紦鍐插尯鐨� JSON 瀛楃涓层��
+ */
+const char *DS2312_Get_Alarm_List(i2c_dev_t *i2c_dev);
+
+/**
+ * @brief 鎸夌储寮曞垹闄や竴涓凡淇濆瓨鐨� RTC 闂归挓銆�
+ *
+ * index 瀵瑰簲 DS2312_Get_Alarm_List() 杩斿洖鍒楄〃涓殑 index 瀛楁銆傚垹闄ゆ垚鍔熷悗浼氱珛鍗抽噸鏂�
+ * 璋冨害 DS3231 Alarm1锛涜嫢绱㈠紩闈炴硶鎴栧垪琛ㄨ鍙栧け璐ワ紝鍑芥暟涓嶄細淇敼鐜版湁闂归挓閰嶇疆銆�
+ *
+ * @param i2c_dev 宸插垵濮嬪寲鐨� DS3231 I2C 璁惧鎻忚堪绗︼紝涓嶈兘涓虹┖銆�
+ * @param index 寰呭垹闄ら椆閽熺殑鍒楄〃绱㈠紩銆�
+ */
+void DS2312_Delete_Alarm_Index(i2c_dev_t *i2c_dev, uint8_t index);
+
+/**
+ * @brief 娓呯┖鎵�鏈夊凡淇濆瓨鐨� RTC 闂归挓銆�
+ *
+ * 璇ュ嚱鏁颁細娓呯┖鎸佷箙鍖栭椆閽熷垪琛紝骞跺叧闂� DS3231 Alarm1 涓柇閰嶇疆锛岄槻姝㈡棫闂归挓缁х画瑙﹀彂銆�
+ *
+ * @param i2c_dev 宸插垵濮嬪寲鐨� DS3231 I2C 璁惧鎻忚堪绗︼紝涓嶈兘涓虹┖銆�
+ */
+void DS2312_Delete_Alarm_All(i2c_dev_t *i2c_dev);
+
+esp_err_t DS2312_Set_Unix_Time(i2c_dev_t *i2c_dev, time_t timestamp);
+esp_err_t DS2312_Set_Unix_Time_Saved(time_t timestamp);
+esp_err_t DS2312_Set_Alarm_Id(i2c_dev_t *i2c_dev, const char *id, int hour, int min, int sec, uint8_t repeat_mask);
+esp_err_t DS2312_Delete_Alarm_Id(i2c_dev_t *i2c_dev, const char *id);
+esp_err_t DS2312_Set_Alarm_Id_Saved(const char *id, int hour, int min, int sec, uint8_t repeat_mask);
+esp_err_t DS2312_Delete_Alarm_Id_Saved(const char *id);
+
+/**
+ * @brief 鑾峰彇 SD 鍗′腑鐨� MP3 鏂囦欢鍒楄〃銆�
+ *
+ * 鍑芥暟浼氶�掑綊閬嶅巻 SD 鍗℃寕杞界洰褰曪紝浠呰繑鍥炲悗缂�涓� .mp3 鎴� .MP3 鐨勬枃浠躲�傚瓙鐩綍鏂囦欢
+ * 浠� / 寮�澶寸殑 SD 鍗$浉瀵硅矾寰勮繑鍥炪�傝繑鍥炲�间负 JSON 瀛楃涓诧紝瀛楃涓插瓨鏀惧湪妯″潡鍐呴儴闈欐�佺紦鍐插尯涓紝璋冪敤鑰呬笉搴旈噴鏀捐鎸囬拡銆�
+ *
+ * @return 鎸囧悜鍐呴儴闈欐�佺紦鍐插尯鐨� MP3 鏂囦欢鍒楄〃 JSON 瀛楃涓层��
+ */
+const char *SDCard_Get_MP3_List(void);
+
+/**
+ * @brief 鎸夋枃浠跺悕鍚姩 SD 鍗� MP3 鎾斁銆�
+ *
+ * mp3_name 鍙互鏄畬鏁存枃浠跺悕锛屼篃鍙互鏄兘澶熷敮涓�鍖归厤 MP3 鏂囦欢鍚嶇殑鍏抽敭瀛椼�傝嫢褰撳墠姝e湪鎾斁
+ * 鍏朵粬 MP3锛屼細鍏堝仠姝㈡棫浠诲姟鍐嶅惎鍔ㄦ柊鎾斁锛涜嫢褰撳墠澶勪簬灏忔櫤鎾姤鏆傚仠闊充箰鐨勭姸鎬侊紝鏂伴煶涔愪細
+ * 淇濇寔鏆傚仠锛岀瓑寰呬笂灞傛仮澶嶃��
+ *
+ * @param mp3_name MP3 鏂囦欢鍚嶆垨鍖归厤鍏抽敭瀛楋紝涓嶈兘涓虹┖銆�
+ * @return ESP_OK 琛ㄧず鎾斁浠诲姟宸叉垚鍔熷惎鍔紱鍏朵粬鍊艰〃绀烘枃浠朵笉瀛樺湪銆佸弬鏁伴潪娉曟垨鎾斁鍚姩澶辫触銆�
+ */
+esp_err_t SDCard_Play_MP3(char *mp3_name);
+
+/**
+ * @brief 鎸夋枃浠跺悕鍜岄�熷害鍚姩 SD 鍗� MP3 鎾斁銆�
+ *
+ * speed 浠呮敮鎸� 0.5銆�0.75銆�1.0銆�1.25銆�1.5銆�2.0銆傞潪 1.0 閫熷害閫氳繃閲嶉噰鏍峰疄鐜帮紝浼氭敼鍙橀煶璋冦��
+ *
+ * @param mp3_name MP3 鏂囦欢鍚嶆垨鍖归厤鍏抽敭瀛楋紝涓嶈兘涓虹┖銆�
+ * @param speed 鎾斁閫熷害鍊嶇巼銆�
+ * @return ESP_OK 琛ㄧず鎾斁浠诲姟宸叉垚鍔熷惎鍔紱ESP_ERR_INVALID_ARG 琛ㄧず閫熷害鎴栨枃浠跺悕闈炴硶銆�
+ */
+esp_err_t SDCard_Play_MP3_With_Speed(char *mp3_name, float speed);
+void SDCard_Show_Matching_JPG_For_MP3_Path(const char *mp3_path);
+
+/**
+ * @brief 鍋滄褰撳墠 SD 鍗� MP3 鎾斁銆�
+ *
+ * 璇ュ嚱鏁颁細鍙栨秷褰撳墠鎾斁浠诲姟锛屽苟娓呴櫎鏆傚仠鐘舵�侊紝纭繚鍚庣画涓嶄細鍥犱负灏忔櫤鎾姤缁撴潫鑰岃嚜鍔ㄦ仮澶�
+ * 宸茶鐢ㄦ埛鍋滄鐨勯煶涔愩��
+ */
+void SDCard_Stop_Play(void);
+
+/**
+ * @brief 鏆傚仠褰撳墠鎴栧嵆灏嗗惎鍔ㄧ殑 SD 鍗� MP3 鎾斁銆�
+ *
+ * 鑻� MP3 姝e湪鎾斁锛屾挱鏀句换鍔′細鍦ㄥ畨鍏ㄦ鏌ョ偣闃诲杈撳嚭锛涜嫢褰撳墠娌℃湁鎾斁浠诲姟锛屼篃浼氳褰曟殏鍋�
+ * 鐘舵�侊紝浣块殢鍚庡惎鍔ㄧ殑 MP3 鍦ㄤ笂灞傝皟鐢ㄦ仮澶嶆帴鍙e墠淇濇寔闈欓煶鏆傚仠銆�
+ */
+void SDCard_Pause_Play(void);
+
+/**
+ * @brief 鎭㈠琚殏鍋滅殑 SD 鍗� MP3 鎾斁銆�
+ *
+ * 璇ュ嚱鏁颁細娓呴櫎鏆傚仠鏍囧織锛屼娇姝e湪绛夊緟鐨� MP3 鎾斁浠诲姟缁х画璇诲彇銆佽В鐮佸苟杈撳嚭闊抽銆�
+ */
+void SDCard_Resume_Play(void);
+
+/**
+ * @brief 鏌ヨ SD 鍗� MP3 鎾斁浠诲姟鏄惁瀛樺湪銆�
+ *
+ * @return true 琛ㄧず褰撳墠瀛樺湪 MP3 鎾斁浠诲姟锛沠alse 琛ㄧず娌℃湁姝e湪杩愯鐨勬挱鏀句换鍔°��
+ */
+bool SDCard_Is_Playing(void);
+
+/**
+ * @brief 鏌ヨ SD 鍗� MP3 鎾斁鏄惁澶勪簬鏆傚仠鐘舵�併��
+ *
+ * @return true 琛ㄧず鎾斁琚殏鍋滄垨鍚庣画鏂版挱鏀惧簲浠庢殏鍋滅姸鎬佸紑濮嬶紱false 琛ㄧず鏈殏鍋溿��
+ */
+bool SDCard_Is_Paused(void);
+
+/**
+ * @brief 鍒犻櫎 SD 鍗′腑鐨� MP3 鏂囦欢銆�
+ *
+ * 鍑芥暟浼氭牴鎹枃浠跺悕鎴栧尮閰嶅叧閿瓧瑙f瀽鐩爣 MP3 鏂囦欢骞舵墽琛屽垹闄ゃ�傝嫢鏂囦欢涓嶅瓨鍦ㄦ垨鍚嶇О闈炴硶锛�
+ * 鍑芥暟涓嶄細淇敼 SD 鍗″唴瀹广��
+ *
+ * @param mp3_name MP3 鏂囦欢鍚嶆垨鍖归厤鍏抽敭瀛楋紝涓嶈兘涓虹┖銆�
+ */
+void SDCard_Delete_MP3(char *mp3_name);
+
+/**
+ * @brief 鎸夊畬鏁磋矾寰勫惎鍔� MP3 鏂囦欢鎾斁銆�
+ *
+ * 璇ユ帴鍙i潰鍚戞ā鍧楀唴閮ㄦ垨楂樼骇璋冪敤鑰咃紝瑕佹眰浼犲叆宸茬粡瑙f瀽瀹屾垚鐨� SD 鍗℃枃浠惰矾寰勩�傛櫘閫� MCP
+ * 璋冪敤搴斾紭鍏堜娇鐢� SDCard_Play_MP3()锛岀敱鍏惰礋璐f枃浠跺悕瀹夊叏妫�鏌ュ拰璺緞瑙f瀽銆�
+ *
+ * @param path MP3 鏂囦欢瀹屾暣璺緞锛屼笉鑳戒负绌恒��
+ * @return ESP_OK 琛ㄧず鎾斁浠诲姟宸叉垚鍔熷惎鍔紱鍏朵粬鍊艰〃绀鸿矾寰勯潪娉曘�佹挱鏀惧櫒蹇欐垨浠诲姟鍒涘缓澶辫触銆�
+ */
+esp_err_t SDCard_Play_MP3_File(const char *path);
+
+/**
+ * @brief 鎸夊畬鏁磋矾寰勫拰閫熷害鍚姩 MP3 鏂囦欢鎾斁銆�
+ *
+ * speed 浠呮敮鎸� 0.5銆�0.75銆�1.0銆�1.25銆�1.5銆�2.0銆傞潪 1.0 閫熷害閫氳繃閲嶉噰鏍峰疄鐜帮紝浼氭敼鍙橀煶璋冦��
+ *
+ * @param path MP3 鏂囦欢瀹屾暣璺緞锛屼笉鑳戒负绌恒��
+ * @param speed 鎾斁閫熷害鍊嶇巼銆�
+ * @return ESP_OK 琛ㄧず鎾斁浠诲姟宸叉垚鍔熷惎鍔紱ESP_ERR_INVALID_ARG 琛ㄧず璺緞鎴栭�熷害闈炴硶銆�
+ */
+esp_err_t SDCard_Play_MP3_File_With_Speed(const char *path, float speed);
+esp_err_t SDCard_Map_Business_Path(const char *business_path, char *sd_path, size_t sd_path_size);
+esp_err_t SDCard_Prepare_Business_File(const char *business_path,
+                                       char *final_path,
+                                       size_t final_path_size,
+                                       char *part_path,
+                                       size_t part_path_size);
+esp_err_t SDCard_Commit_Part_File(const char *part_path, const char *final_path);
+void SDCard_Remove_File_If_Exists(const char *path);
+esp_err_t SDCard_Delete_File_By_Business_Path(const char *business_path);
+esp_err_t SDCard_Save_Manifest(const char *course_id, const char *manifest_json);
+
+typedef bool (*board_download_progress_cb_t)(int progress, const char *info, void *ctx);
+esp_err_t Board_Download_File_To_Path(const char *url,
+                                      const char *part_path,
+                                      const char *expected_md5,
+                                      board_download_progress_cb_t progress_cb,
+                                      void *ctx,
+                                      char *error_info,
+                                      size_t error_info_size);
+
+typedef void (*board_ota_progress_cb_t)(int progress, size_t speed, void *ctx);
+typedef void (*board_ota_complete_cb_t)(bool success, void *ctx);
+esp_err_t Board_Start_Firmware_Upgrade(const char *url,
+                                       const char *version,
+                                       board_ota_progress_cb_t progress_cb,
+                                       board_ota_complete_cb_t complete_cb,
+                                       void *ctx);
+
+/**
+ * @brief 鑾峰彇 SD 鍗′腑鐨� JPG 鍥剧墖鍒楄〃銆�
+ *
+ * 鍑芥暟浼氶亶鍘� SD 鍗℃寕杞界洰褰曪紝浠呰繑鍥炲悗缂�涓� .jpg 鎴� .JPG 鐨勫浘鐗囨枃浠跺悕銆傚昂瀵告牎楠屽湪鏄剧ず鍥剧墖鏃舵墽琛岋紝
+ * 閬垮厤鍒楄〃鏌ヨ闃舵閫愪釜瑙g爜鍥剧墖瀵艰嚧 MCP 鍝嶅簲鍙樻參銆傝繑鍥炲�间负 JSON 瀛楃涓诧紝瀛樻斁鍦ㄦā鍧楀唴閮ㄩ潤鎬佺紦鍐插尯涓紝璋冪敤鑰呬笉搴旈噴鏀捐鎸囬拡銆�
+ *
+ * @return 鎸囧悜鍐呴儴闈欐�佺紦鍐插尯鐨� JPG 鍥剧墖鍒楄〃 JSON 瀛楃涓层��
+ */
+const char *SDCard_Get_JPG_List(void);
+
+/**
+ * @brief 鍦� LCD 闈㈡澘涓婃樉绀� SD 鍗′腑鐨� JPG 鍥剧墖銆�
+ *
+ * 鍑芥暟浼氭牴鎹枃浠跺悕瑙f瀽鍥剧墖璺緞锛岃鍙栧苟瑙g爜 160x160 JPG锛屽畬鎴愬繀瑕佺殑 RGB565/BGR565
+ * 鍍忕礌鏍煎紡杞崲鍚庣粯鍒跺埌鎸囧畾 LCD 闈㈡澘銆傛樉绀烘垚鍔熷悗浼氫繚瀛樻渶鍚庝竴娆℃樉绀虹殑鍥剧墖鍚嶇О锛岀敤浜�
+ * 涓嬫寮�鏈烘仮澶嶆樉绀恒��
+ *
+ * @param panel 宸插垵濮嬪寲鐨� LCD panel 鍙ユ焺锛屼笉鑳戒负绌恒��
+ * @param jpg_name JPG 鏂囦欢鍚嶏紝涓嶈兘涓虹┖銆�
+ */
+void SDCard_Play_JPG(esp_lcd_panel_handle_t panel, char *jpg_name);
+
+/**
+ * @brief 鍒犻櫎 SD 鍗′腑鐨� JPG 鍥剧墖鏂囦欢銆�
+ *
+ * 鍑芥暟浼氭牴鎹枃浠跺悕瑙f瀽鐩爣 JPG 鏂囦欢骞舵墽琛屽垹闄ゃ�傝嫢鏂囦欢涓嶅瓨鍦ㄣ�佸悕绉伴潪娉曟垨鍒犻櫎澶辫触锛�
+ * 鍑芥暟涓嶄細褰卞搷鍏朵粬鍥剧墖鏂囦欢銆�
+ *
+ * @param jpg_name JPG 鏂囦欢鍚嶏紝涓嶈兘涓虹┖銆�
+ */
+void SDCard_Delete_JPG(char *jpg_name);
+
+/**
+ * @brief 鎭㈠鏄剧ず涓婁竴娆℃垚鍔熸樉绀虹殑 JPG 鍥剧墖銆�
+ *
+ * 鍑芥暟浼氫粠 NVS 涓鍙栨渶鍚庝竴娆℃樉绀虹殑 JPG 鏂囦欢鍚嶏紝骞跺湪纭鏂囦欢浠嶇劧瀛樺湪鍚庨噸鏂版樉绀恒��
+ * 閫傚悎鍦� LCD 鍒濆鍖栧拰 SD 鍗℃寕杞藉畬鎴愬悗璋冪敤銆�
+ *
+ * @param panel 宸插垵濮嬪寲鐨� LCD panel 鍙ユ焺锛屼笉鑳戒负绌恒��
+ */
+void SDCard_Restore_Last_JPG(esp_lcd_panel_handle_t panel);
+
+/**
+ * @brief 鑾峰彇褰撳墠鐢垫睜鐢甸噺鐧惧垎姣斻��
+ *
+ * 鍑芥暟浼氳鍙栫數姹� ADC 寮曡剼鐢靛帇锛岀粨鍚堟澘绾у垎鍘嬪弬鏁版崲绠椾负鍗曡妭閿傜數姹犵數鍘嬶紝骞舵槧灏勫埌
+ * 0-100 鐨勭櫨鍒嗘瘮鑼冨洿銆傝繑鍥炲�肩敤浜� MCP 鐢垫睜鐘舵�佸拰灏忔櫤璁惧鐘舵�佷笂鎶ャ��
+ *
+ * @return 鐢垫睜鐢甸噺鐧惧垎姣旓紝鑼冨洿 0-100銆�
+ */
+uint8_t Battery_Get_Value(void);
+
+/**
+ * @brief 鑾峰彇褰撳墠鍏呯數鐘舵�併��
+ *
+ * 鍑芥暟浼氳鍙栧厖鐢垫娴� GPIO锛屽苟鎸夌収鏉跨骇纭欢瀹氫箟鍒ゆ柇鏄惁姝e湪鍏呯數銆�
+ *
+ * @return true 琛ㄧず姝e湪鍏呯數锛沠alse 琛ㄧず鏈浜庡厖鐢电姸鎬併��
+ */
+bool Battery_Is_Charging(void);
+
+/**
+ * @brief 鑾峰彇宸蹭繚瀛樼殑 Wi-Fi 鍘嗗彶鍒楄〃銆�
+ *
+ * 杩斿洖鍊间负 JSON 瀛楃涓诧紝浠呭寘鍚� SSID 鍜岀储寮曪紝涓嶅寘鍚瘑鐮佺瓑鏁忔劅淇℃伅銆傚瓧绗︿覆鐢辨ā鍧楀唴閮�
+ * 闈欐�佸璞′繚瀛橈紝璋冪敤鑰呬笉搴旈噴鏀捐鎸囬拡锛涘悗缁啀娆¤皟鐢ㄦ湰鍑芥暟鍙兘瑕嗙洊鍘熷唴瀹广��
+ *
+ * @return 鎸囧悜鍐呴儴闈欐�佺紦鍐插尯鐨� Wi-Fi 鍘嗗彶鍒楄〃 JSON 瀛楃涓层��
+ */
+const char *WIFI_Get_History_List(void);
+
+/**
+ * @brief 娓呯┖宸蹭繚瀛樼殑 Wi-Fi 鍘嗗彶鍒楄〃銆�
+ *
+ * 璇ュ嚱鏁颁細鍒犻櫎鎸佷箙鍖栦繚瀛樼殑 Wi-Fi SSID 鍜屽瘑鐮侊紝骞舵竻绌哄唴瀛樹腑鐨勫巻鍙插垪琛ㄣ�傚嚱鏁颁笉浼氫富鍔�
+ * 鏂紑褰撳墠 Wi-Fi 杩炴帴锛涙竻绌哄悗閫氬父闇�瑕侀噸鍚澶囷紝璁惧浼氬洜鏃犲巻鍙� Wi-Fi 鑰岄噸鏂拌繘鍏ラ厤缃戞祦绋嬨��
+ */
+void WIFI_Delete_All(void);
+
+uint8_t Board_Get_CPU_Usage(void);
+esp_err_t Board_Set_Output_Volume(int level);
+int Board_Get_Output_Volume(void);
+esp_err_t Board_Enter_Standby(void);
+esp_err_t Board_Request_Wake(int timeout_ms);
+esp_err_t Board_Enter_Power_Off(void);
+esp_err_t Board_Restore_From_Power_Off(int timeout_ms);
+bool Board_Is_Power_Off(void);
+esp_err_t Board_Reboot_Delayed(int delay_seconds);
+void Board_Acquire_Sleep_Inhibit(void);
+void Board_Release_Sleep_Inhibit(void);
+bool Board_Is_Voice_Session_Active(void);
+void Board_Show_JPG(const char *jpg_name);
+void Board_Show_Black_Screen(void);
+void Board_Show_Alert(const char *status, const char *message, const char *emotion);
+
+#ifdef __cplusplus
+}
+#endif
+
+#endif
\ No newline at end of file
diff --git a/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/wifi_history_api.cc b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/wifi_history_api.cc
new file mode 100644
index 0000000..64af2e2
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/main/boards/sening-xiaozhi-s3/wifi_history_api.cc
@@ -0,0 +1,44 @@
+#include "system.h"
+#include "ssid_manager.h"
+
+#include <string>
+
+static std::string wifi_history_json;
+
+static void AppendJsonEscapedString(std::string& output, const std::string& value) {
+    output.push_back('"');
+    for (char ch : value) {
+        if (ch == '"' || ch == '\\') {
+            output.push_back('\\');
+        }
+        output.push_back(ch);
+    }
+    output.push_back('"');
+}
+
+extern "C" const char *WIFI_Get_History_List(void) {
+    auto ssid_list = SsidManager::GetInstance().GetSsidList();
+
+    wifi_history_json.clear();
+    wifi_history_json += "{\"count\":";
+    wifi_history_json += std::to_string(ssid_list.size());
+    wifi_history_json += ",\"wifi\":[";
+
+    for (size_t i = 0; i < ssid_list.size(); ++i) {
+        if (i > 0) {
+            wifi_history_json.push_back(',');
+        }
+        wifi_history_json += "{\"index\":";
+        wifi_history_json += std::to_string(i);
+        wifi_history_json += ",\"ssid\":";
+        AppendJsonEscapedString(wifi_history_json, ssid_list[i].ssid);
+        wifi_history_json.push_back('}');
+    }
+
+    wifi_history_json += "]}";
+    return wifi_history_json.c_str();
+}
+
+extern "C" void WIFI_Delete_All(void) {
+    SsidManager::GetInstance().Clear();
+}
diff --git a/xiaozhi-esp32-2.2.6/main/idf_component.yml b/xiaozhi-esp32-2.2.6/main/idf_component.yml
index 9fbbb35..9de3804 100644
--- a/xiaozhi-esp32-2.2.6/main/idf_component.yml
+++ b/xiaozhi-esp32-2.2.6/main/idf_component.yml
@@ -42,7 +42,7 @@
     version: ==1.3.1   # for compatibility. update version may need to modify this project code.
     rules:
     - if: target in [esp32p4, esp32s3]
-  
+
   espressif/esp_image_effects:
     version: ^1.0.1
     rules:
@@ -126,7 +126,7 @@
     - if: target in [esp32s3]
 
   espressif/esp_lcd_touch_st7123: ^1.0.0
-  espressif/iot_usbh_rndis: 
+  espressif/iot_usbh_rndis:
     version: ^0.3.1
     rules:
     - if: target in [esp32s3, esp32p4]
@@ -134,3 +134,5 @@
   ## Required IDF version
   idf:
     version: '>=5.5.2'
+  esp-idf-lib/ds3231: ^1.1.7
+  espressif/esp_lcd_gc9d01: ^2.0.0
diff --git a/xiaozhi-esp32-2.2.6/main/mcp_server.cc b/xiaozhi-esp32-2.2.6/main/mcp_server.cc
index 7cd7ba0..ec930df 100644
--- a/xiaozhi-esp32-2.2.6/main/mcp_server.cc
+++ b/xiaozhi-esp32-2.2.6/main/mcp_server.cc
@@ -532,6 +532,9 @@
                 } else if (argument.type() == kPropertyTypeInteger && cJSON_IsNumber(value)) {
                     argument.set_value<int>(value->valueint);
                     found = true;
+                } else if (argument.type() == kPropertyTypeNumber && cJSON_IsNumber(value)) {
+                    argument.set_value<double>(value->valuedouble);
+                    found = true;
                 } else if (argument.type() == kPropertyTypeString && cJSON_IsString(value)) {
                     argument.set_value<std::string>(value->valuestring);
                     found = true;
diff --git a/xiaozhi-esp32-2.2.6/main/mcp_server.h b/xiaozhi-esp32-2.2.6/main/mcp_server.h
index dacdd55..fe1cb29 100644
--- a/xiaozhi-esp32-2.2.6/main/mcp_server.h
+++ b/xiaozhi-esp32-2.2.6/main/mcp_server.h
@@ -52,6 +52,7 @@
 enum PropertyType {
     kPropertyTypeBoolean,
     kPropertyTypeInteger,
+    kPropertyTypeNumber,
     kPropertyTypeString
 };
 
@@ -59,7 +60,7 @@
 private:
     std::string name_;
     PropertyType type_;
-    std::variant<bool, int, std::string> value_;
+    std::variant<bool, int, double, std::string> value_;
     bool has_default_value_;
     std::optional<int> min_value_;  // 鏂板锛氭暣鏁版渶灏忓��
     std::optional<int> max_value_;  // 鏂板锛氭暣鏁版渶澶у��
@@ -139,6 +140,11 @@
             if (max_value_.has_value()) {
                 cJSON_AddNumberToObject(json, "maximum", max_value_.value());
             }
+        } else if (type_ == kPropertyTypeNumber) {
+            cJSON_AddStringToObject(json, "type", "number");
+            if (has_default_value_) {
+                cJSON_AddNumberToObject(json, "default", value<double>());
+            }
         } else if (type_ == kPropertyTypeString) {
             cJSON_AddStringToObject(json, "type", "string");
             if (has_default_value_) {
diff --git a/xiaozhi-esp32-2.2.6/main/protocols/mqtt_protocol.cc b/xiaozhi-esp32-2.2.6/main/protocols/mqtt_protocol.cc
index 94d9d12..5e9341b 100644
--- a/xiaozhi-esp32-2.2.6/main/protocols/mqtt_protocol.cc
+++ b/xiaozhi-esp32-2.2.6/main/protocols/mqtt_protocol.cc
@@ -18,11 +18,11 @@
         .callback = [](void* arg) {
             MqttProtocol* protocol = (MqttProtocol*)arg;
             auto& app = Application::GetInstance();
-            if (app.GetDeviceState() == kDeviceStateIdle) {
+            if (app.GetDeviceState() == kDeviceStateIdle && Board::GetInstance().IsNetworkReady()) {
                 ESP_LOGI(TAG, "Reconnecting to MQTT server");
                 auto alive = protocol->alive_;  // Capture alive flag
                 app.Schedule([protocol, alive]() {
-                    if (*alive) {
+                    if (*alive && Board::GetInstance().IsNetworkReady()) {
                         protocol->StartMqttClient(false);
                     }
                 });
@@ -56,9 +56,25 @@
     return StartMqttClient(false);
 }
 
+void MqttProtocol::PrepareForNetworkStandby() {
+    network_standby_preparing_ = true;
+    if (reconnect_timer_ != nullptr) {
+        esp_timer_stop(reconnect_timer_);
+    }
+    audio_channel_opened_ = false;
+    udp_.reset();
+    if (mqtt_ != nullptr) {
+        mqtt_->Disconnect();
+        mqtt_.reset();
+    }
+    network_standby_preparing_ = false;
+}
+
 bool MqttProtocol::StartMqttClient(bool report_error) {
     if (mqtt_ != nullptr) {
         ESP_LOGW(TAG, "Mqtt client already started");
+        audio_channel_opened_ = false;
+        udp_.reset();
         mqtt_.reset();
     }
 
@@ -83,6 +99,9 @@
     mqtt_->SetKeepAlive(keepalive_interval);
 
     mqtt_->OnDisconnected([this]() {
+        if (network_standby_preparing_ || !Board::GetInstance().IsNetworkReady()) {
+            return;
+        }
         if (on_disconnected_ != nullptr) {
             on_disconnected_();
         }
@@ -143,7 +162,9 @@
     }
     if (!mqtt_->Connect(broker_address, broker_port, client_id, username, password)) {
         ESP_LOGE(TAG, "Failed to connect to endpoint, code=%d", mqtt_->GetLastError());
-        SetError(Lang::Strings::SERVER_NOT_CONNECTED);
+        if (report_error) {
+            SetError(Lang::Strings::SERVER_NOT_CONNECTED);
+        }
         return false;
     }
 
@@ -164,32 +185,60 @@
 }
 
 bool MqttProtocol::SendAudio(std::unique_ptr<AudioStreamPacket> packet) {
-    std::lock_guard<std::mutex> lock(channel_mutex_);
-    if (udp_ == nullptr) {
-        return false;
+    bool notify_channel_closed = false;
+
+    {
+        std::lock_guard<std::mutex> lock(channel_mutex_);
+        if (udp_ == nullptr || !audio_channel_opened_) {
+            return false;
+        }
+        if (!Board::GetInstance().IsNetworkReady()) {
+            ESP_LOGW(TAG, "Network is not ready, drop UDP audio and close channel");
+            audio_channel_opened_ = false;
+            udp_.reset();
+            notify_channel_closed = true;
+        } else {
+            std::string nonce(aes_nonce_);
+            *(uint16_t*)&nonce[2] = htons(packet->payload.size());
+            *(uint32_t*)&nonce[8] = htonl(packet->timestamp);
+            *(uint32_t*)&nonce[12] = htonl(++local_sequence_);
+
+            std::string encrypted;
+            encrypted.resize(aes_nonce_.size() + packet->payload.size());
+            memcpy(encrypted.data(), nonce.data(), nonce.size());
+
+            size_t nc_off = 0;
+            uint8_t stream_block[16] = {0};
+            if (mbedtls_aes_crypt_ctr(&aes_ctx_, packet->payload.size(), &nc_off, (uint8_t*)nonce.c_str(), stream_block,
+                (uint8_t*)packet->payload.data(), (uint8_t*)&encrypted[nonce.size()]) != 0) {
+                ESP_LOGE(TAG, "Failed to encrypt audio data");
+                return false;
+            }
+
+            int ret = udp_->Send(encrypted);
+            if (ret <= 0) {
+                ESP_LOGW(TAG, "UDP audio send failed, close audio channel");
+                audio_channel_opened_ = false;
+                udp_.reset();
+                notify_channel_closed = true;
+            } else {
+                return true;
+            }
+        }
     }
 
-    std::string nonce(aes_nonce_);
-    *(uint16_t*)&nonce[2] = htons(packet->payload.size());
-    *(uint32_t*)&nonce[8] = htonl(packet->timestamp);
-    *(uint32_t*)&nonce[12] = htonl(++local_sequence_);
-
-    std::string encrypted;
-    encrypted.resize(aes_nonce_.size() + packet->payload.size());
-    memcpy(encrypted.data(), nonce.data(), nonce.size());
-
-    size_t nc_off = 0;
-    uint8_t stream_block[16] = {0};
-    if (mbedtls_aes_crypt_ctr(&aes_ctx_, packet->payload.size(), &nc_off, (uint8_t*)nonce.c_str(), stream_block,
-        (uint8_t*)packet->payload.data(), (uint8_t*)&encrypted[nonce.size()]) != 0) {
-        ESP_LOGE(TAG, "Failed to encrypt audio data");
-        return false;
+    if (notify_channel_closed && on_audio_channel_closed_ != nullptr) {
+        on_audio_channel_closed_();
     }
-
-    return udp_->Send(encrypted) > 0;
+    return false;
 }
 
 void MqttProtocol::CloseAudioChannel(bool send_goodbye) {
+    if (!audio_channel_opened_) {
+        return;
+    }
+    audio_channel_opened_ = false;
+
     {
         std::lock_guard<std::mutex> lock(channel_mutex_);
         udp_.reset();
@@ -287,6 +336,7 @@
     });
 
     udp_->Connect(udp_server_, udp_port_);
+    audio_channel_opened_ = true;
 
     if (on_audio_channel_opened_ != nullptr) {
         on_audio_channel_opened_();
@@ -385,5 +435,5 @@
 }
 
 bool MqttProtocol::IsAudioChannelOpened() const {
-    return udp_ != nullptr && !error_occurred_ && !IsTimeout();
+    return audio_channel_opened_ && udp_ != nullptr && !error_occurred_ && !IsTimeout();
 }
diff --git a/xiaozhi-esp32-2.2.6/main/protocols/mqtt_protocol.h b/xiaozhi-esp32-2.2.6/main/protocols/mqtt_protocol.h
index 90963db..7de7643 100644
--- a/xiaozhi-esp32-2.2.6/main/protocols/mqtt_protocol.h
+++ b/xiaozhi-esp32-2.2.6/main/protocols/mqtt_protocol.h
@@ -33,6 +33,7 @@
     bool OpenAudioChannel() override;
     void CloseAudioChannel(bool send_goodbye = true) override;
     bool IsAudioChannelOpened() const override;
+    void PrepareForNetworkStandby() override;
 
 private:
     // Alive flag for safe scheduled callbacks - set to false in destructor
@@ -52,6 +53,8 @@
     uint32_t local_sequence_;
     uint32_t remote_sequence_;
     esp_timer_handle_t reconnect_timer_;
+    bool network_standby_preparing_ = false;
+    bool audio_channel_opened_ = false;
 
     bool StartMqttClient(bool report_error=false);
     void ParseServerHello(const cJSON* root);
diff --git a/xiaozhi-esp32-2.2.6/main/protocols/protocol.h b/xiaozhi-esp32-2.2.6/main/protocols/protocol.h
index 28ef604..0f927f6 100644
--- a/xiaozhi-esp32-2.2.6/main/protocols/protocol.h
+++ b/xiaozhi-esp32-2.2.6/main/protocols/protocol.h
@@ -73,6 +73,7 @@
     virtual void SendStopListening();
     virtual void SendAbortSpeaking(AbortReason reason);
     virtual void SendMcpMessage(const std::string& message);
+    virtual void PrepareForNetworkStandby() {}
 
 protected:
     std::function<void(const cJSON* root)> on_incoming_json_;
diff --git a/xiaozhi-esp32-2.2.6/partitions/v2/16m.csv b/xiaozhi-esp32-2.2.6/partitions/v2/16m.csv
index 8af93a3..bce7232 100644
--- a/xiaozhi-esp32-2.2.6/partitions/v2/16m.csv
+++ b/xiaozhi-esp32-2.2.6/partitions/v2/16m.csv
@@ -5,4 +5,5 @@
 phy_init, data, phy,     0xf000,    0x1000,
 ota_0,    app,  ota_0,   0x20000,   0x3f0000,
 ota_1,    app,  ota_1,   ,          0x3f0000,
-assets,   data, spiffs,  0x800000,  8M
+assets,   data, spiffs,  0x800000,  0x7FF000
+config,   data, 0x05,    ,          4K,
diff --git a/xiaozhi-esp32-2.2.6/scripts/build_default_assets.py b/xiaozhi-esp32-2.2.6/scripts/build_default_assets.py
index 90cee7d..3e1503e 100755
--- a/xiaozhi-esp32-2.2.6/scripts/build_default_assets.py
+++ b/xiaozhi-esp32-2.2.6/scripts/build_default_assets.py
@@ -861,11 +861,11 @@
         print("Please select appropriate CONFIG_SR_MN_* options in menuconfig, or disable USE_CUSTOM_WAKE_WORD")
         sys.exit(1)
     
-    # 3. Only package multinet models if USE_CUSTOM_WAKE_WORD=y
-    if wake_word_config['use_custom_wake_word']:
+    # 3. Package multinet models for custom wake word or AFE offline commands
+    if wake_word_config['use_custom_wake_word'] or wake_word_config['use_afe_wake_word']:
         multinet_model_paths = get_multinet_model_paths(multinet_model_names, args.esp_sr_model_path)
     elif multinet_model_names:
-        print(f"  Note: Found multinet models {multinet_model_names} but USE_CUSTOM_WAKE_WORD is disabled, skipping")
+        print(f"  Note: Found multinet models {multinet_model_names} but wake word type does not use MultiNet, skipping")
     
     # Print model information (only for models that will actually be packaged)
     if wakenet_model_paths:
diff --git a/xiaozhi-esp32-2.2.6/sdkconfig.defaults b/xiaozhi-esp32-2.2.6/sdkconfig.defaults
index ae6b193..c4dfd6f 100644
--- a/xiaozhi-esp32-2.2.6/sdkconfig.defaults
+++ b/xiaozhi-esp32-2.2.6/sdkconfig.defaults
@@ -34,6 +34,11 @@
 
 CONFIG_CODEC_I2C_BACKWARD_COMPATIBLE=n
 
+CONFIG_FATFS_LFN_HEAP=y
+CONFIG_FATFS_MAX_LFN=255
+CONFIG_FATFS_API_ENCODING_UTF_8=y
+CONFIG_FATFS_CODEPAGE_936=y
+
 # Fix ML307 FIFO Overflow
 CONFIG_UART_ISR_IN_IRAM=y
 
diff --git a/xiaozhi-esp32-2.2.6/sdkconfig.defaults.esp32s3 b/xiaozhi-esp32-2.2.6/sdkconfig.defaults.esp32s3
index 0ac4880..0e76080 100644
--- a/xiaozhi-esp32-2.2.6/sdkconfig.defaults.esp32s3
+++ b/xiaozhi-esp32-2.2.6/sdkconfig.defaults.esp32s3
@@ -22,6 +22,7 @@
 CONFIG_ESP32S3_DATA_CACHE_LINE_64B=y
 
 CONFIG_SR_WN_WN9_NIHAOXIAOZHI_TTS=y
+CONFIG_SR_MN_CN_MULTINET7_QUANT=y
 
 CONFIG_ESP_SYSTEM_EVENT_TASK_STACK_SIZE=4096
 
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_alarm_tool_schema.py b/xiaozhi-esp32-2.2.6/tools/validate_alarm_tool_schema.py
new file mode 100644
index 0000000..0550d87
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_alarm_tool_schema.py
@@ -0,0 +1,29 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    board = read_text("main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc")
+    alarm_block = board.split('mcp_server.AddTool("self.alarm.add"', 1)[1].split(
+        'mcp_server.AddTool("self.alarm.get_list"', 1
+    )[0]
+
+    require('Property("hour", kPropertyTypeInteger, 0, 23)' in alarm_block, "alarm.add must require hour")
+    require('Property("minute", kPropertyTypeInteger, 0, 59)' in alarm_block, "alarm.add must require minute")
+    require('Property("second", kPropertyTypeInteger, 0, 0, 59)' in alarm_block, "alarm.add must default second")
+    require('Property("trigger_time"' not in alarm_block, "alarm.add must not expose stale trigger_time schema")
+    require('Property("label"' not in alarm_block, "alarm.add must not expose stale label schema")
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_config_mode_offline_resume.py b/xiaozhi-esp32-2.2.6/tools/validate_config_mode_offline_resume.py
new file mode 100644
index 0000000..c2b7ceb
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_config_mode_offline_resume.py
@@ -0,0 +1,56 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    app = read_text("main/application.cc")
+    board = read_text("main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc")
+
+    require(
+        "void Application::LoadLocalAssetsForWakeWord()" in app,
+        "Application must load local assets/srmodels before starting Wi-Fi",
+    )
+    require(
+        "bool local_assets_applied_ = false;" in read_text("main/application.h"),
+        "Application must track whether local assets were applied before network startup",
+    )
+    initialize_body = app.split("void Application::Initialize()", 1)[1].split("void Application::Run()", 1)[0]
+    require(
+        initialize_body.find("LoadLocalAssetsForWakeWord();") != -1 and initialize_body.find("LoadLocalAssetsForWakeWord();") < initialize_body.find("board.StartNetwork();"),
+        "Application must load local srmodels before board.StartNetwork() can enter Wi-Fi config mode",
+    )
+    require(
+        "case kDeviceStateWifiConfiguring:\n            audio_service_.EnableVoiceProcessing(false);\n            audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord());" in app,
+        "Wi-Fi config mode must keep AFE wake/offline command detection running",
+    )
+    connection_failed_branch = board.split("event == NetworkEvent::ConnectionFailed", 1)[1].split("event == NetworkEvent::WifiConfigModeEnter", 1)[0]
+    require(
+        "BoardMqtt_ResumePlaylistIfUnfinished();" not in connection_failed_branch,
+        "ConnectionFailed callback must not resume playlist before config-mode prompt",
+    )
+    require(
+        "event == NetworkEvent::WifiConfigModeEnter" in board,
+        "board network callback must handle Wi-Fi config mode entry",
+    )
+    require(
+        "xTaskCreate([](void *arg)" in board and "mqtt_resume_after_config" in board,
+        "playlist resume after config mode must be deferred in a task",
+    )
+    require(
+        "vTaskDelay(pdMS_TO_TICKS(" in board and "BoardMqtt_ResumePlaylistIfUnfinished();" in board,
+        "deferred config-mode resume task must wait before resuming playlist",
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_internal_tool_speech_filter.py b/xiaozhi-esp32-2.2.6/tools/validate_internal_tool_speech_filter.py
new file mode 100644
index 0000000..017841b
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_internal_tool_speech_filter.py
@@ -0,0 +1,44 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    app = read_text("main/application.cc")
+    filter_body = app.split("static bool IsInternalToolSpeech(const char* text)", 1)[1].split(
+        "static bool IsStandbyRequestText", 1
+    )[0]
+
+    require(
+        "strncmp(text, \"% self.\", 7) == 0" in filter_body,
+        "internal tool speech filter must continue to ignore percent-prefixed self tool calls",
+    )
+    require(
+        'strstr(text, "\\\"arguments\\\"")' in filter_body,
+        "internal tool speech filter must ignore leaked tool-call arguments JSON fragments",
+    )
+    require(
+        'strstr(text, "\\\"trigger_time\\\"")' in filter_body,
+        "internal tool speech filter must ignore leaked alarm trigger_time JSON fragments",
+    )
+    require(
+        'strstr(text, "\\\"label\\\"")' in filter_body,
+        "internal tool speech filter must ignore leaked alarm label JSON fragments",
+    )
+    require(
+        'Ignore internal tool speech' in app,
+        "TTS sentence_start must use the internal tool speech filter before updating display text",
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_mqtt_alarm_compat.py b/xiaozhi-esp32-2.2.6/tools/validate_mqtt_alarm_compat.py
new file mode 100644
index 0000000..bcc8b2c
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_mqtt_alarm_compat.py
@@ -0,0 +1,36 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    mqtt = read_text("main/boards/sening-xiaozhi-s3/mqtt.c")
+    require(
+        'sscanf(value, "%2d:%2d%c", hour, minute, &tail)' in mqtt,
+        "MQTT SET_ALARM time must accept HH:MM and default seconds to 00",
+    )
+    require(
+        'strcmp(command->valuestring, "delete") == 0' in mqtt,
+        "MQTT SET_ALARM payload.action=delete must delete alarm by id",
+    )
+    require(
+        'BoardMqtt_PublishJson(s_rpt_ack_topic, root, BOARD_MQTT_QOS);' in mqtt,
+        "MQTT ACK must use the board default QoS",
+    )
+    require(
+        'BoardMqtt_PublishProgress(req_id, 0, "media playlist accepted");' in mqtt,
+        "MQTT PLAY_MEDIA must publish progress when playlist is accepted",
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_no_boot_jpg_restore.py b/xiaozhi-esp32-2.2.6/tools/validate_no_boot_jpg_restore.py
new file mode 100644
index 0000000..7a77600
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_no_boot_jpg_restore.py
@@ -0,0 +1,24 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    board = read_text("main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc")
+    require(
+        "SDCard_Restore_Last_JPG" not in board,
+        "boot should not restore the last displayed JPG",
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_offline_command_wiring.py b/xiaozhi-esp32-2.2.6/tools/validate_offline_command_wiring.py
new file mode 100644
index 0000000..f2885d0
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_offline_command_wiring.py
@@ -0,0 +1,37 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    audio_service = read_text("main/audio/audio_service.cc")
+    afe_pos = audio_service.find("wake_word_ = std::make_unique<AfeWakeWord>();")
+    custom_pos = audio_service.find("wake_word_ = std::make_unique<CustomWakeWord>();")
+    require(afe_pos != -1, "AfeWakeWord selection is missing")
+    require(custom_pos != -1, "CustomWakeWord fallback is missing")
+    require(afe_pos < custom_pos, "AfeWakeWord must be preferred before CustomWakeWord when WakeNet exists")
+
+    sdkconfig = read_text("sdkconfig")
+    defaults = read_text("sdkconfig.defaults.esp32s3")
+    assets_script = read_text("scripts/build_default_assets.py")
+    multinet_enabled = "CONFIG_SR_MN_CN_MULTINET" in sdkconfig and "CONFIG_SR_MN_CN_NONE=y" not in sdkconfig
+    defaults_enabled = "CONFIG_SR_MN_CN_MULTINET" in defaults
+    require(multinet_enabled, "sdkconfig must enable a Chinese MultiNet model")
+    require(defaults_enabled, "sdkconfig.defaults.esp32s3 must enable a Chinese MultiNet model")
+    require(
+        "wake_word_config['use_custom_wake_word'] or wake_word_config['use_afe_wake_word']" in assets_script,
+        "assets build must package MultiNet when AFE wake word is enabled",
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_playlist_jpg_display.py b/xiaozhi-esp32-2.2.6/tools/validate_playlist_jpg_display.py
new file mode 100644
index 0000000..937282c
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_playlist_jpg_display.py
@@ -0,0 +1,78 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    app = read_text("main/application.cc")
+    board = read_text("main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc")
+    system = read_text("main/boards/sening-xiaozhi-s3/system.c")
+    header = read_text("main/boards/sening-xiaozhi-s3/system.h")
+    mqtt = read_text("main/boards/sening-xiaozhi-s3/mqtt.c")
+    mqtt_header = read_text("main/boards/sening-xiaozhi-s3/mqtt.h")
+
+    require(
+        "void SDCard_Show_Matching_JPG_For_MP3_Path(const char *mp3_path);" in header,
+        "system.h must expose a helper for showing the matching JPG for an MP3 path",
+    )
+    require(
+        "void SDCard_Show_Matching_JPG_For_MP3_Path(const char *mp3_path)" in system,
+        "system.c must implement matching JPG display for direct MP3 paths",
+    )
+    require(
+        "SDCard_Show_Matching_JPG_For_MP3_Path(path);" in system,
+        "manual MP3 playback should share the matching JPG display helper",
+    )
+    require(
+        mqtt.find("SDCard_Show_Matching_JPG_For_MP3_Path(item.sd_path);") < mqtt.find("SDCard_Play_MP3_File_With_Speed(item.sd_path, item.speed);") ,
+        "MQTT playlist must show the matching JPG before starting MP3 playback",
+    )
+    require(
+        "void BoardMqtt_StopPlaylistPlayback(void);" in mqtt_header,
+        "mqtt.h must expose a public playlist stop API",
+    )
+    require(
+        "Board_Show_Black_Screen();" in mqtt,
+        "playlist stop should clear the screen to black",
+    )
+    require(
+        "void Board_Show_Black_Screen(void)" in board,
+        "board must implement a black-screen helper",
+    )
+    require(
+        "uint16_t black_line[SCREEN_WIDTH] = {0};" in board,
+        "black-screen helper must use a low-memory line buffer",
+    )
+    require(
+        "Board_Show_Black_Screen();" in board,
+        "board init should use the black-screen helper",
+    )
+    require(
+        "for (size_t y = 0; y < height; ++y)" in system,
+        "JPG display must draw line by line to reduce DMA pressure",
+    )
+    require(
+        "esp_lcd_panel_draw_bitmap(panel, 0, 0, JPG_REQUIRED_WIDTH, JPG_REQUIRED_HEIGHT, image);" not in system,
+        "JPG display must not use a full-frame draw path",
+    )
+    require(
+        "esp_lcd_panel_draw_bitmap(panel, 0, y, width, y + 1, image + y * stride);" in system,
+        "JPG display must use the line-by-line draw path",
+    )
+    require(
+        "sdcard_mp3_stop_requested_ = true;\n                        BoardMqtt_StopPlaylistPlayback();" in app,
+        "self.mp3.stop must terminate the MQTT playlist as well as the local MP3",
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_power_off_restore_playlist_resume.py b/xiaozhi-esp32-2.2.6/tools/validate_power_off_restore_playlist_resume.py
new file mode 100644
index 0000000..df048ac
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_power_off_restore_playlist_resume.py
@@ -0,0 +1,47 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    app = read_text("main/application.cc")
+    mqtt_header = read_text("main/boards/sening-xiaozhi-s3/mqtt.h")
+    board = read_text("main/boards/sening-xiaozhi-s3/sening_xiaozhi_s3.cc")
+
+    require(
+        "void BoardMqtt_ResumePlaylistIfUnfinished(void);" in mqtt_header,
+        "MQTT header must expose unfinished playlist resume API",
+    )
+
+    restore_body = app.split("void Application::RestoreFromPowerOffStandby(bool network_ready)", 1)[1].split(
+        "void Application::AcquireSleepInhibit", 1
+    )[0]
+    require(
+        "BoardMqtt_ResumePlaylistIfUnfinished();" in restore_body,
+        "power-off standby restore must check for unfinished MQTT playlist",
+    )
+    require(
+        restore_body.find("audio_service_.EnableWakeWordDetection(true);") < restore_body.find("BoardMqtt_ResumePlaylistIfUnfinished();"),
+        "playlist resume check should run after restore re-enables normal application state",
+    )
+
+    board_restore_body = board.split("esp_err_t RestoreFromPowerOffStandby(int timeout_ms)", 1)[1].split(
+        "bool IsPowerOffStandby", 1
+    )[0]
+    require(
+        "Application::GetInstance().RestoreFromPowerOffStandby(network_ready);" in board_restore_body,
+        "board power-off restore must delegate application-level restore logic",
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_power_off_stops_playlist.py b/xiaozhi-esp32-2.2.6/tools/validate_power_off_stops_playlist.py
new file mode 100644
index 0000000..b569f90
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_power_off_stops_playlist.py
@@ -0,0 +1,38 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    app = read_text("main/application.cc")
+    mqtt_header = read_text("main/boards/sening-xiaozhi-s3/mqtt.h")
+
+    require(
+        "void BoardMqtt_StopPlaylistPlayback(void);" in mqtt_header,
+        "MQTT header must expose a public playlist stop API",
+    )
+
+    power_off_body = app.split("void Application::EnterPowerOffStandby()", 1)[1].split(
+        "void Application::RestoreFromPowerOffStandby", 1
+    )[0]
+    require(
+        "BoardMqtt_StopPlaylistPlayback();" in power_off_body,
+        "power-off standby must stop the whole playlist task, not only the current MP3",
+    )
+    require(
+        "StopSdCardMp3();" not in power_off_body,
+        "power-off standby must not bypass the playlist stop API with direct MP3 stop",
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_sdcard_voice_coordination.py b/xiaozhi-esp32-2.2.6/tools/validate_sdcard_voice_coordination.py
new file mode 100644
index 0000000..14bf094
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_sdcard_voice_coordination.py
@@ -0,0 +1,76 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    app = read_text("main/application.cc")
+    header = read_text("main/application.h")
+    mqtt = read_text("main/boards/sening-xiaozhi-s3/mqtt.h")
+
+    require(
+        "void PauseSdCardMp3ForVoice();" in header,
+        "Application must declare a helper for pausing SD card MP3 before voice interaction",
+    )
+    require(
+        "void ResumeSdCardMp3AfterVoice();" in header,
+        "Application must declare a helper for resuming SD card MP3 after voice interaction",
+    )
+    require(
+        "bool resume_sdcard_mp3_after_voice_ = false;" in header,
+        "Application must track SD card MP3 paused specifically for voice interaction",
+    )
+    require(
+        "void Application::PauseSdCardMp3ForVoice()" in app,
+        "Application must implement SD card MP3 pause before voice interaction",
+    )
+    require(
+        "PauseSdCardMp3();\n        resume_sdcard_mp3_after_voice_ = true;" in app,
+        "voice pause helper must remember that playback should resume later",
+    )
+    require(
+        "void Application::ResumeSdCardMp3AfterVoice()" in app,
+        "Application must implement SD card MP3 resume after voice interaction",
+    )
+    require(
+        "ResumeSdCardMp3AfterVoice();\n            display->SetStatus(Lang::Strings::STANDBY);" in app,
+        "idle state must resume SD card MP3 before returning to standby UI",
+    )
+    require(
+        "PauseSdCardMp3ForVoice();\n            SetDeviceState(kDeviceStateConnecting);" in app,
+        "manual listening must pause SD card MP3 before opening the voice channel",
+    )
+    require(
+        "PauseSdCardMp3ForVoice();\n        audio_service_.EncodeWakeWord();" in app,
+        "wake-word voice invoke must pause SD card MP3 before encoding/sending voice",
+    )
+    finish_tts = app.split("void Application::FinishTtsResponse()", 1)[1].split("void Application::ScheduleTtsAudio", 1)[0]
+    require(
+        "ResumeSdCardMp3AfterVoice();" in finish_tts,
+        "TTS playback completion must resume SD card MP3 paused for voice before waiting for idle",
+    )
+    require(
+        "sdcard_mp3_stop_requested_ = true;" in app and "BoardMqtt_StopPlaylistPlayback();" in app,
+        "self.mp3.stop must terminate the MQTT playlist as well as the local MP3",
+    )
+    require(
+        "sdcard_mp3_stop_requested_ = true;\n                        BoardMqtt_StopPlaylistPlayback();" in app,
+        "self.mp3.stop must call the playlist stop API from the MCP handler",
+    )
+    require(
+        "void BoardMqtt_StopPlaylistPlayback(void);" in mqtt,
+        "MQTT header must expose a public playlist stop API",
+    )
+
+
+if __name__ == "__main__":
+    main()
diff --git a/xiaozhi-esp32-2.2.6/tools/validate_stt_event_report.py b/xiaozhi-esp32-2.2.6/tools/validate_stt_event_report.py
new file mode 100644
index 0000000..d1993c7
--- /dev/null
+++ b/xiaozhi-esp32-2.2.6/tools/validate_stt_event_report.py
@@ -0,0 +1,61 @@
+from pathlib import Path
+
+ROOT = Path(__file__).resolve().parents[1]
+
+
+def read_text(path: str) -> str:
+    return (ROOT / path).read_text(encoding="utf-8")
+
+
+def require(condition: bool, message: str) -> None:
+    if not condition:
+        raise SystemExit(message)
+
+
+def main() -> None:
+    system_h = read_text("main/boards/sening-xiaozhi-s3/system.h")
+    mqtt_h = read_text("main/boards/sening-xiaozhi-s3/mqtt.h")
+    mqtt_c = read_text("main/boards/sening-xiaozhi-s3/mqtt.c")
+    app = read_text("main/application.cc")
+
+    require(
+        'BOARD_MQTT_RPT_EVENT_TOPIC_FORMAT "snlp/v1/%s/rpt/event"' in system_h,
+        "system config must define the MQTT rpt/event topic format",
+    )
+    require(
+        "void BoardMqtt_PublishSttEvent(const char *text);" in mqtt_h,
+        "MQTT header must expose STT event reporting API",
+    )
+    require(
+        "static char s_rpt_event_topic[BOARD_MQTT_TOPIC_MAX_LEN];" in mqtt_c,
+        "MQTT client must keep a rpt/event topic buffer",
+    )
+    require(
+        "BoardMqtt_FormatTopic(s_rpt_event_topic" in mqtt_c,
+        "MQTT startup must format the rpt/event topic",
+    )
+    require(
+        "void BoardMqtt_PublishSttEvent(const char *text)" in mqtt_c,
+        "MQTT client must implement STT event reporting",
+    )
+
+    publish_body = mqtt_c.split("void BoardMqtt_PublishSttEvent(const char *text)", 1)[1].split(
+        "static void", 1
+    )[0]
+    require('cJSON_AddStringToObject(payload, "event", "asr_result");' in publish_body, "STT event must use asr_result event type")
+    require('cJSON_AddStringToObject(payload, "text", text);' in publish_body, "STT event must include recognized text")
+    require("s_rpt_event_topic" in publish_body, "STT event must publish to rpt/event topic")
+    require('cJSON_AddStringToObject(payload, "ts"' not in publish_body, "STT event payload must not add its own ts field")
+    require('cJSON_AddNumberToObject(payload, "timestamp"' not in publish_body, "STT event payload must not add its own timestamp field")
+
+    stt_body = app.split('} else if (strcmp(type->valuestring, "stt") == 0)', 1)[1].split(
+        '} else if (strcmp(type->valuestring, "llm") == 0)', 1
+    )[0]
+    require(
+        "BoardMqtt_PublishSttEvent(text->valuestring);" in stt_body,
+        "application STT handler must report recognized text to business MQTT",
+    )
+
+
+if __name__ == "__main__":
+    main()

--
Gitblit v1.9.1