commit 7b5da8189268edd16db2372589048f1e8044d035 Author: 0Xiao0 <511201264@qq.com> Date: Mon Jun 15 16:47:28 2026 +0800 first commit diff --git a/.clang-format b/.clang-format new file mode 100644 index 0000000..9b71362 --- /dev/null +++ b/.clang-format @@ -0,0 +1,126 @@ +--- +Language: Cpp +BasedOnStyle: Google +AccessModifierOffset: -4 +AlignAfterOpenBracket: Align +AlignConsecutiveAssignments: false +AlignConsecutiveDeclarations: false +AlignEscapedNewlines: Left +AlignOperands: true +AlignTrailingComments: true +AllowAllParametersOfDeclarationOnNextLine: true +AllowShortBlocksOnASingleLine: false +AllowShortCaseLabelsOnASingleLine: false +AllowShortFunctionsOnASingleLine: All +AllowShortIfStatementsOnASingleLine: false +AllowShortLoopsOnASingleLine: false +AlwaysBreakAfterReturnType: ExceptShortType +AlwaysBreakBeforeMultilineStrings: true +AlwaysBreakTemplateDeclarations: Yes +BinPackArguments: true +BinPackParameters: true +BraceWrapping: + AfterClass: false + AfterControlStatement: false + AfterEnum: false + AfterFunction: false + AfterNamespace: false + AfterObjCDeclaration: false + AfterStruct: false + AfterUnion: false + AfterExternBlock: false + BeforeCatch: false + BeforeElse: false + IndentBraces: false + SplitEmptyFunction: false + SplitEmptyRecord: false + SplitEmptyNamespace: false +BreakBeforeBinaryOperators: None +BreakBeforeBraces: Attach +BreakBeforeInheritanceComma: false +BreakInheritanceList: BeforeColon +BreakBeforeTernaryOperators: true +BreakConstructorInitializersBeforeComma: false +BreakConstructorInitializers: BeforeColon +BreakAfterJavaFieldAnnotations: false +BreakStringLiterals: true +ColumnLimit: 100 +CommentPragmas: '^ IWYU pragma:' +CompactNamespaces: false +ConstructorInitializerAllOnOneLineOrOnePerLine: true +ConstructorInitializerIndentWidth: 4 +ContinuationIndentWidth: 4 +Cpp11BracedListStyle: true +DerivePointerAlignment: false +DisableFormat: false +ExperimentalAutoDetectBinPacking: false +FixNamespaceComments: true +ForEachMacros: + - cJSON_ArrayForEach +IncludeBlocks: Preserve +IncludeCategories: + - Regex: '^' + Priority: 1 + - Regex: '^' + Priority: 1 + - Regex: '^<.*\.h>' + Priority: 2 + - Regex: '^<.*' + Priority: 3 + - Regex: '.*' + Priority: 4 +IncludeIsMainRegex: '([-_](test|unittest))?$' +IndentCaseLabels: true +IndentPPDirectives: None +IndentWidth: 4 +IndentWrappedFunctionNames: false +KeepEmptyLinesAtTheStartOfBlocks: false +MacroBlockBegin: '' +MacroBlockEnd: '' +MaxEmptyLinesToKeep: 1 +NamespaceIndentation: None +PenaltyBreakAssignment: 2 +PenaltyBreakBeforeFirstCallParameter: 1 +PenaltyBreakComment: 300 +PenaltyBreakFirstLessLess: 120 +PenaltyBreakString: 1000 +PenaltyBreakTemplateDeclaration: 10 +PenaltyExcessCharacter: 1000000 +PenaltyReturnTypeOnItsOwnLine: 200 +PointerAlignment: Left +RawStringFormats: + - Language: Cpp + Delimiters: + - cc + - CC + - cpp + - Cpp + - CPP + - 'c++' + - 'C++' + CanonicalDelimiter: '' + BasedOnStyle: google +ReflowComments: true +SortIncludes: true +SortUsingDeclarations: true +SpaceAfterCStyleCast: false +SpaceAfterTemplateKeyword: true +SpaceBeforeAssignmentOperators: true +SpaceBeforeCpp11BracedList: false +SpaceBeforeCtorInitializerColon: true +SpaceBeforeInheritanceColon: true +SpaceBeforeParens: ControlStatements +SpaceBeforeRangeBasedForLoopColon: true +SpaceInEmptyParentheses: false +SpacesBeforeTrailingComments: 2 +SpacesInAngles: false +SpacesInContainerLiterals: true +SpacesInCStyleCastParentheses: false +SpacesInParentheses: false +SpacesInSquareBrackets: false +Standard: Latest +StatementMacros: + - Q_UNUSED + - QT_REQUIRE_VERSION +TabWidth: 4 +UseTab: Never \ No newline at end of file diff --git a/.github/ISSUE_TEMPLATE/01_build_install_bug.yml b/.github/ISSUE_TEMPLATE/01_build_install_bug.yml new file mode 100644 index 0000000..d3489e2 --- /dev/null +++ b/.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/.github/ISSUE_TEMPLATE/02_runtime_bug.yml b/.github/ISSUE_TEMPLATE/02_runtime_bug.yml new file mode 100644 index 0000000..60a62f8 --- /dev/null +++ b/.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/.github/ISSUE_TEMPLATE/03_feature_request.yml b/.github/ISSUE_TEMPLATE/03_feature_request.yml new file mode 100644 index 0000000..79cb921 --- /dev/null +++ b/.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’s 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/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..d663ce7 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: true +contact_links: + - name: 小智 AI 官方网站 + url: https://xiaozhi.me/ + about: 激活设备、配置 AI、声纹识别、声音克隆等应有尽有,DIY 属于你自己的小智 + - name: 小智 AI 聊天机器人百科全书 + url: https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb + about: 开发文档、硬件制作、烧录教程、FAQ尽在小智百科 \ No newline at end of file diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml new file mode 100644 index 0000000..685c2d1 --- /dev/null +++ b/.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@v4 + 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/.gitignore b/.gitignore new file mode 100644 index 0000000..0f47aa0 --- /dev/null +++ b/.gitignore @@ -0,0 +1,21 @@ +tmp/ +components/ +managed_components/ +build/ +dist/ +.vscode/ +.devcontainer/ +sdkconfig.old +sdkconfig +dependencies.lock +.env +releases/ +main/assets/lang_config.h +main/mmap_generate_emoji.h +.DS_Store +.cache +*.pyc +*.bin +mmap_generate_*.h +.clangd +/main/assets.bin \ No newline at end of file diff --git a/CMakeLists.txt b/CMakeLists.txt new file mode 100644 index 0000000..652c682 --- /dev/null +++ b/CMakeLists.txt @@ -0,0 +1,13 @@ +# The following lines of boilerplate have to be in your project's +# CMakeLists in this exact order for cmake to work correctly +cmake_minimum_required(VERSION 3.16) + +# Add this line to disable the specific warning +add_compile_options(-Wno-missing-field-initializers) + +include($ENV{IDF_PATH}/tools/cmake/project.cmake) +# "Trim" the build. Include the minimal set of components, main, and anything it depends on. +idf_build_set_property(MINIMAL_BUILD ON) + +set(PROJECT_VER "2.2.4") +project(xiaozhi) diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..e90b554 --- /dev/null +++ b/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) 2025 Shenzhen Xinzhi Future Technology Co., Ltd. +Copyright (c) 2025 Project Contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..958958c --- /dev/null +++ b/README.md @@ -0,0 +1,173 @@ +# An MCP-based Chatbot + +(English | [中文](README_zh.md) | [日本語](README_ja.md)) + +## Introduction + +👉 [Human: Give AI a camera vs AI: Instantly finds out the owner hasn't washed hair for three days【bilibili】](https://www.bilibili.com/video/BV1bpjgzKEhd/) + +👉 [Handcraft your AI girlfriend, beginner's guide【bilibili】](https://www.bilibili.com/video/BV1XnmFYLEJN/) + +As a voice interaction entry, the XiaoZhi AI chatbot leverages the AI capabilities of large models like Qwen / DeepSeek, and achieves multi-terminal control via the MCP protocol. + +Control everything via MCP + +## Version Notes + +The current v2 version is incompatible with the v1 partition table, so it is not possible to upgrade from v1 to v2 via OTA. For partition table details, see [partitions/v2/README.md](partitions/v2/README.md). + +All hardware running v1 can be upgraded to v2 by manually flashing the firmware. + +The stable version of v1 is 1.9.2. You can switch to v1 by running `git checkout v1`. The v1 branch will be maintained until February 2026. + +### Features Implemented + +- Wi-Fi / ML307 Cat.1 4G +- Offline voice wake-up [ESP-SR](https://github.com/espressif/esp-sr) +- Supports two communication protocols ([Websocket](docs/websocket.md) or MQTT+UDP) +- Uses OPUS audio codec +- Voice interaction based on streaming ASR + LLM + TTS architecture +- Speaker recognition, identifies the current speaker [3D Speaker](https://github.com/modelscope/3D-Speaker) +- OLED / LCD display, supports emoji display +- Battery display and power management +- Multi-language support (Chinese, English, Japanese) +- Supports ESP32-C3, ESP32-S3, ESP32-P4 chip platforms +- Device-side MCP for device control (Speaker, LED, Servo, GPIO, etc.) +- Cloud-side MCP to extend large model capabilities (smart home control, PC desktop operation, knowledge search, email, etc.) +- Customizable wake words, fonts, emojis, and chat backgrounds with online web-based editing ([Custom Assets Generator](https://github.com/78/xiaozhi-assets-generator)) + +## Hardware + +### Breadboard DIY Practice + +See the Feishu document tutorial: + +👉 ["XiaoZhi AI Chatbot Encyclopedia"](https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb?from=from_copylink) + +Breadboard demo: + +![Breadboard Demo](docs/v1/wiring2.jpg) + +### Supports 70+ Open Source Hardware (Partial List) + +- LiChuang ESP32-S3 Development Board +- Espressif ESP32-S3-BOX3 +- M5Stack CoreS3 +- M5Stack AtomS3R + Echo Base +- Magic Button 2.4 +- Waveshare ESP32-S3-Touch-AMOLED-1.8 +- LILYGO T-Circle-S3 +- XiaGe Mini C3 +- CuiCan AI Pendant +- WMnologo-Xingzhi-1.54TFT +- SenseCAP Watcher +- ESP-HI Low Cost Robot Dog + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +## Software + +### Firmware Flashing + +For beginners, it is recommended to use the firmware that can be flashed without setting up a development environment. + +The firmware connects to the official [xiaozhi.me](https://xiaozhi.me) server by default. Personal users can register an account to use the Qwen real-time model for free. + +👉 [Beginner's Firmware Flashing Guide](https://ccnphfhqs21z.feishu.cn/wiki/Zpz4wXBtdimBrLk25WdcXzxcnNS) + +### Development Environment + +- Cursor or VSCode +- Install ESP-IDF plugin, select SDK version 5.4 or above +- Linux is better than Windows for faster compilation and fewer driver issues +- This project uses Google C++ code style, please ensure compliance when submitting code + +### Developer Documentation + +- [Custom Board Guide](docs/custom-board.md) - Learn how to create custom boards for XiaoZhi AI +- [MCP Protocol IoT Control Usage](docs/mcp-usage.md) - Learn how to control IoT devices via MCP protocol +- [MCP Protocol Interaction Flow](docs/mcp-protocol.md) - Device-side MCP protocol implementation +- [MQTT + UDP Hybrid Communication Protocol Document](docs/mqtt-udp.md) +- [A detailed WebSocket communication protocol document](docs/websocket.md) + +## Large Model Configuration + +If you already have a XiaoZhi AI chatbot device and have connected to the official server, you can log in to the [xiaozhi.me](https://xiaozhi.me) console for configuration. + +👉 [Backend Operation Video Tutorial (Old Interface)](https://www.bilibili.com/video/BV1jUCUY2EKM/) + +## Related Open Source Projects + +For server deployment on personal computers, refer to the following open-source projects: + +- [xinnan-tech/xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) Python server +- [joey-zhou/xiaozhi-esp32-server-java](https://github.com/joey-zhou/xiaozhi-esp32-server-java) Java server +- [AnimeAIChat/xiaozhi-server-go](https://github.com/AnimeAIChat/xiaozhi-server-go) Golang server +- [hackers365/xiaozhi-esp32-server-golang](https://github.com/hackers365/xiaozhi-esp32-server-golang) Golang server + +Other client projects using the XiaoZhi communication protocol: + +- [huangjunsen0406/py-xiaozhi](https://github.com/huangjunsen0406/py-xiaozhi) Python client +- [TOM88812/xiaozhi-android-client](https://github.com/TOM88812/xiaozhi-android-client) Android client +- [100askTeam/xiaozhi-linux](http://github.com/100askTeam/xiaozhi-linux) Linux client by 100ask +- [78/xiaozhi-sf32](https://github.com/78/xiaozhi-sf32) Bluetooth chip firmware by Sichuan +- [QuecPython/solution-xiaozhiAI](https://github.com/QuecPython/solution-xiaozhiAI) QuecPython firmware by Quectel + +Custom Assets Tools: + +- [78/xiaozhi-assets-generator](https://github.com/78/xiaozhi-assets-generator) Custom Assets Generator (Wake words, fonts, emojis, backgrounds) + +## About the Project + +This is an open-source ESP32 project, released under the MIT license, allowing anyone to use it for free, including for commercial purposes. + +We hope this project helps everyone understand AI hardware development and apply rapidly evolving large language models to real hardware devices. + +If you have any ideas or suggestions, please feel free to raise Issues or join our [Discord](https://discord.gg/bXqgAfRm) or QQ group: 994694848 + +## Star History + + + + + + Star History Chart + + diff --git a/README_ja.md b/README_ja.md new file mode 100644 index 0000000..224a074 --- /dev/null +++ b/README_ja.md @@ -0,0 +1,169 @@ +# MCP ベースのチャットボット + +(日本語 | [中文](README_zh.md) | [English](README.md)) + +## はじめに + +👉 [人間:AIにカメラを装着 vs AI:その場で飼い主が3日間髪を洗っていないことを発見【bilibili】](https://www.bilibili.com/video/BV1bpjgzKEhd/) + +👉 [手作りでAIガールフレンドを作る、初心者入門チュートリアル【bilibili】](https://www.bilibili.com/video/BV1XnmFYLEJN/) + +シャオジーAIチャットボットは音声インタラクションの入口として、Qwen / DeepSeekなどの大規模モデルのAI能力を活用し、MCPプロトコルを通じてマルチエンド制御を実現します。 + +MCPであらゆるものを制御 + +## バージョンノート + +現在のv2バージョンはv1パーティションテーブルと互換性がないため、v1からv2へOTAでアップグレードすることはできません。パーティションテーブルの詳細については、[partitions/v2/README.md](partitions/v2/README.md)をご参照ください。 + +v1を実行しているすべてのハードウェアは、ファームウェアを手動で書き込むことでv2にアップグレードできます。 + +v1の安定版は1.9.2です。`git checkout v1`でv1に切り替えることができます。v1ブランチは2026年2月まで継続的にメンテナンスされます。 + +### 実装済み機能 + +- Wi-Fi / ML307 Cat.1 4G +- オフライン音声ウェイクアップ [ESP-SR](https://github.com/espressif/esp-sr) +- 2種類の通信プロトコルに対応([Websocket](docs/websocket.md) または MQTT+UDP) +- OPUSオーディオコーデックを採用 +- ストリーミングASR + LLM + TTSアーキテクチャに基づく音声インタラクション +- 話者認識、現在話している人を識別 [3D Speaker](https://github.com/modelscope/3D-Speaker) +- OLED / LCDディスプレイ、表情表示対応 +- バッテリー表示と電源管理 +- 多言語対応(中国語、英語、日本語) +- ESP32-C3、ESP32-S3、ESP32-P4チッププラットフォーム対応 +- デバイス側MCPによるデバイス制御(音量・明るさ調整、アクション制御など) +- クラウド側MCPで大規模モデル能力を拡張(スマートホーム制御、PCデスクトップ操作、知識検索、メール送受信など) +- カスタマイズ可能なウェイクワード、フォント、絵文字、チャット背景、オンラインWeb編集に対応 ([カスタムアセットジェネレーター](https://github.com/78/xiaozhi-assets-generator)) + +## ハードウェア + +### ブレッドボード手作り実践 + +Feishuドキュメントチュートリアルをご覧ください: + +👉 [「シャオジーAIチャットボット百科事典」](https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb?from=from_copylink) + +ブレッドボードのデモ: + +![ブレッドボードデモ](docs/v1/wiring2.jpg) + +### 70種類以上のオープンソースハードウェアに対応(一部のみ表示) + +- 立創・実戦派 ESP32-S3 開発ボード +- 楽鑫 ESP32-S3-BOX3 +- M5Stack CoreS3 +- M5Stack AtomS3R + Echo Base +- マジックボタン2.4 +- 微雪電子 ESP32-S3-Touch-AMOLED-1.8 +- LILYGO T-Circle-S3 +- エビ兄さん Mini C3 +- CuiCan AIペンダント +- 無名科技Nologo-星智-1.54TFT +- SenseCAP Watcher +- ESP-HI 超低コストロボット犬 + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +## ソフトウェア + +### ファームウェア書き込み + +初心者の方は、まず開発環境を構築せずに書き込み可能なファームウェアを使用することをおすすめします。 + +ファームウェアはデフォルトで公式 [xiaozhi.me](https://xiaozhi.me) サーバーに接続します。個人ユーザーはアカウント登録でQwenリアルタイムモデルを無料で利用できます。 + +👉 [初心者向けファームウェア書き込みガイド](https://ccnphfhqs21z.feishu.cn/wiki/Zpz4wXBtdimBrLk25WdcXzxcnNS) + +### 開発環境 + +- Cursor または VSCode +- ESP-IDFプラグインをインストールし、SDKバージョン5.4以上を選択 +- LinuxはWindowsよりも優れており、コンパイルが速く、ドライバの問題も少ない +- 本プロジェクトはGoogle C++コードスタイルを採用、コード提出時は準拠を確認してください + +### 開発者ドキュメント + +- [カスタム開発ボードガイド](docs/custom-board.md) - シャオジーAI用のカスタム開発ボード作成方法 +- [MCPプロトコルIoT制御使用法](docs/mcp-usage.md) - MCPプロトコルでIoTデバイスを制御する方法 +- [MCPプロトコルインタラクションフロー](docs/mcp-protocol.md) - デバイス側MCPプロトコルの実装方法 +- [MQTT + UDP ハイブリッド通信プロトコルドキュメント](docs/mqtt-udp.md) +- [詳細なWebSocket通信プロトコルドキュメント](docs/websocket.md) + +## 大規模モデル設定 + +すでにシャオジーAIチャットボットデバイスをお持ちで、公式サーバーに接続済みの場合は、[xiaozhi.me](https://xiaozhi.me) コンソールで設定できます。 + +👉 [バックエンド操作ビデオチュートリアル(旧インターフェース)](https://www.bilibili.com/video/BV1jUCUY2EKM/) + +## 関連オープンソースプロジェクト + +個人PCでサーバーをデプロイする場合は、以下のオープンソースプロジェクトを参照してください: + +- [xinnan-tech/xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) Pythonサーバー +- [joey-zhou/xiaozhi-esp32-server-java](https://github.com/joey-zhou/xiaozhi-esp32-server-java) Javaサーバー +- [AnimeAIChat/xiaozhi-server-go](https://github.com/AnimeAIChat/xiaozhi-server-go) Golangサーバー +- [hackers365/xiaozhi-esp32-server-golang](https://github.com/hackers365/xiaozhi-esp32-server-golang) Golangサーバー + +シャオジー通信プロトコルを利用した他のクライアントプロジェクト: + +- [huangjunsen0406/py-xiaozhi](https://github.com/huangjunsen0406/py-xiaozhi) Pythonクライアント +- [TOM88812/xiaozhi-android-client](https://github.com/TOM88812/xiaozhi-android-client) Androidクライアント +- [100askTeam/xiaozhi-linux](http://github.com/100askTeam/xiaozhi-linux) 百問科技提供のLinuxクライアント +- [78/xiaozhi-sf32](https://github.com/78/xiaozhi-sf32) 思澈科技のBluetoothチップファームウェア +- [QuecPython/solution-xiaozhiAI](https://github.com/QuecPython/solution-xiaozhiAI) 移遠提供のQuecPythonファームウェア + +## プロジェクトについて + +これはエビ兄さんがオープンソースで公開しているESP32プロジェクトで、MITライセンスのもと、誰でも無料で、商用利用も可能です。 + +このプロジェクトを通じて、AIハードウェア開発を理解し、急速に進化する大規模言語モデルを実際のハードウェアデバイスに応用できるようになることを目指しています。 + +ご意見やご提案があれば、いつでもIssueを提出するか、[Discord](https://discord.gg/bXqgAfRm) または QQグループ:1011329060 にご参加ください。 + +## スター履歴 + + + + + + Star History Chart + + diff --git a/README_zh.md b/README_zh.md new file mode 100644 index 0000000..e74bdb4 --- /dev/null +++ b/README_zh.md @@ -0,0 +1,169 @@ +# An MCP-based Chatbot + +(中文 | [English](README.md) | [日本語](README_ja.md)) + +## 介绍 + +👉 [人类:给 AI 装摄像头 vs AI:当场发现主人三天没洗头【bilibili】](https://www.bilibili.com/video/BV1bpjgzKEhd/) + +👉 [手工打造你的 AI 女友,新手入门教程【bilibili】](https://www.bilibili.com/video/BV1XnmFYLEJN/) + +小智 AI 聊天机器人作为一个语音交互入口,利用 Qwen / DeepSeek 等大模型的 AI 能力,通过 MCP 协议实现多端控制。 + +通过MCP控制万物 + +### 版本说明 + +当前 v2 版本与 v1 版本分区表不兼容,所以无法从 v1 版本通过 OTA 升级到 v2 版本。分区表说明参见 [partitions/v2/README.md](partitions/v2/README.md)。 + +使用 v1 版本的所有硬件,可以通过手动烧录固件来升级到 v2 版本。 + +v1 的稳定版本为 1.9.2,可以通过 `git checkout v1` 来切换到 v1 版本,该分支会持续维护到 2026 年 2 月。 + +### 已实现功能 + +- Wi-Fi / ML307 Cat.1 4G +- 离线语音唤醒 [ESP-SR](https://github.com/espressif/esp-sr) +- 支持两种通信协议([Websocket](docs/websocket.md) 或 MQTT+UDP) +- 采用 OPUS 音频编解码 +- 基于流式 ASR + LLM + TTS 架构的语音交互 +- 声纹识别,识别当前说话人的身份 [3D Speaker](https://github.com/modelscope/3D-Speaker) +- OLED / LCD 显示屏,支持表情显示 +- 电量显示与电源管理 +- 支持多语言(中文、英文、日文) +- 支持 ESP32-C3、ESP32-S3、ESP32-P4 芯片平台 +- 通过设备端 MCP 实现设备控制(音量、灯光、电机、GPIO 等) +- 通过云端 MCP 扩展大模型能力(智能家居控制、PC桌面操作、知识搜索、邮件收发等) +- 自定义唤醒词、字体、表情与聊天背景,支持网页端在线修改 ([自定义Assets生成器](https://github.com/78/xiaozhi-assets-generator)) + +## 硬件 + +### 面包板手工制作实践 + +详见飞书文档教程: + +👉 [《小智 AI 聊天机器人百科全书》](https://ccnphfhqs21z.feishu.cn/wiki/F5krwD16viZoF0kKkvDcrZNYnhb?from=from_copylink) + +面包板效果图如下: + +![面包板效果图](docs/v1/wiring2.jpg) + +### 支持 70 多个开源硬件(仅展示部分) + +- 立创·实战派 ESP32-S3 开发板 +- 乐鑫 ESP32-S3-BOX3 +- M5Stack CoreS3 +- M5Stack AtomS3R + Echo Base +- 神奇按钮 2.4 +- 微雪电子 ESP32-S3-Touch-AMOLED-1.8 +- LILYGO T-Circle-S3 +- 虾哥 Mini C3 +- 璀璨·AI 吊坠 +- 无名科技 Nologo-星智-1.54TFT +- SenseCAP Watcher +- ESP-HI 超低成本机器狗 + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +## 软件 + +### 固件烧录 + +新手第一次操作建议先不要搭建开发环境,直接使用免开发环境烧录的固件。 + +固件默认接入 [xiaozhi.me](https://xiaozhi.me) 官方服务器,个人用户注册账号可以免费使用 Qwen 实时模型。 + +👉 [新手烧录固件教程](https://ccnphfhqs21z.feishu.cn/wiki/Zpz4wXBtdimBrLk25WdcXzxcnNS) + +### 开发环境 + +- Cursor 或 VSCode +- 安装 ESP-IDF 插件,选择 SDK 版本 5.4 或以上 +- Linux 比 Windows 更好,编译速度快,也免去驱动问题的困扰 +- 本项目使用 Google C++ 代码风格,提交代码时请确保符合规范 + +### 开发者文档 + +- [自定义开发板指南](docs/custom-board.md) - 学习如何为小智 AI 创建自定义开发板 +- [MCP 协议物联网控制用法说明](docs/mcp-usage.md) - 了解如何通过 MCP 协议控制物联网设备 +- [MCP 协议交互流程](docs/mcp-protocol.md) - 设备端 MCP 协议的实现方式 +- [MQTT + UDP 混合通信协议文档](docs/mqtt-udp.md) +- [一份详细的 WebSocket 通信协议文档](docs/websocket.md) + +## 大模型配置 + +如果你已经拥有一个小智 AI 聊天机器人设备,并且已接入官方服务器,可以登录 [xiaozhi.me](https://xiaozhi.me) 控制台进行配置。 + +👉 [后台操作视频教程(旧版界面)](https://www.bilibili.com/video/BV1jUCUY2EKM/) + +## 相关开源项目 + +在个人电脑上部署服务器,可以参考以下第三方开源的项目: + +- [xinnan-tech/xiaozhi-esp32-server](https://github.com/xinnan-tech/xiaozhi-esp32-server) Python 服务器 +- [joey-zhou/xiaozhi-esp32-server-java](https://github.com/joey-zhou/xiaozhi-esp32-server-java) Java 服务器 +- [AnimeAIChat/xiaozhi-server-go](https://github.com/AnimeAIChat/xiaozhi-server-go) Golang 服务器 +- [hackers365/xiaozhi-esp32-server-golang](https://github.com/hackers365/xiaozhi-esp32-server-golang) Golang 服务器 + +使用小智通信协议的第三方客户端项目: + +- [huangjunsen0406/py-xiaozhi](https://github.com/huangjunsen0406/py-xiaozhi) Python 客户端 +- [TOM88812/xiaozhi-android-client](https://github.com/TOM88812/xiaozhi-android-client) Android 客户端 +- [100askTeam/xiaozhi-linux](http://github.com/100askTeam/xiaozhi-linux) 百问科技提供的 Linux 客户端 +- [78/xiaozhi-sf32](https://github.com/78/xiaozhi-sf32) 思澈科技的蓝牙芯片固件 +- [QuecPython/solution-xiaozhiAI](https://github.com/QuecPython/solution-xiaozhiAI) 移远提供的 QuecPython 固件 + +## 关于项目 + +这是一个由虾哥开源的 ESP32 项目,以 MIT 许可证发布,允许任何人免费使用,修改或用于商业用途。 + +我们希望通过这个项目,能够帮助大家了解 AI 硬件开发,将当下飞速发展的大语言模型应用到实际的硬件设备中。 + +如果你有任何想法或建议,请随时提出 Issues 或加入 [Discord](https://discord.gg/bXqgAfRm) 或 QQ 群:1011329060 + +## Star History + + + + + + Star History Chart + + diff --git a/docs/blufi.md b/docs/blufi.md new file mode 100644 index 0000000..992872d --- /dev/null +++ b/docs/blufi.md @@ -0,0 +1,37 @@ +# BluFi 配网(集成 esp-wifi-connect) + +本文档说明如何在小智固件中启用和使用 BluFi(BLE Wi‑Fi 配网),并结合项目内置的 `esp-wifi-connect` 组件完成 Wi‑Fi 连接与存储。官方 +BluFi +协议说明请参考 [Espressif 文档](https://docs.espressif.com/projects/esp-idf/zh_CN/stable/esp32/api-guides/ble/blufi.html)。 + +## 前置条件 + +- 需要支持 BLE 的芯片与固件配置。 +- 在 `idf.py menuconfig` 中启用 `WiFi Configuration Method -> Esp Blufi`(`CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING=y` + )。如果想使用 BluFi,必须关闭同一菜单下的 Hotspot 选项,否则默认使用 Hotspot 配网模式。 + +- 保持默认的 NVS 与事件循环初始化(项目的 `app_main` 已处理)。 +- CONFIG_BT_BLUEDROID_ENABLED、CONFIG_BT_NIMBLE_ENABLED这两个宏应二选一,不能同时启用。 +## 工作流程 + +1) 手机端通过 BluFi(如官方 EspBlufi App 或自研客户端)连接设备,发送 Wi‑Fi SSID/密码,手机端可以通过blufi协议获取设备端扫描到的WiFi列表。 +2) 设备侧在 `ESP_BLUFI_EVENT_REQ_CONNECT_TO_AP` 中将凭据写入 `SsidManager`(存储到 NVS,属于 `esp-wifi-connect` 组件)。 +3) 随后启动 `WifiStation` 扫描并连接;状态通过 BluFi 返回。 +4) 配网成功后设备会自动连接新 Wi‑Fi;失败则返回失败状态。 + +## 使用步骤 + +1. 配置:在 menuconfig 开启 `Esp Blufi`。编译并烧录固件。 +2. 触发配网:设备首次启动且没有已保存的 Wi‑Fi 时会自动进入配网。 +3. 手机端操作:打开 EspBlufi App(或其他 BluFi 客户端),搜索并连接设备,可以选择是否加密,按提示输入 Wi‑Fi SSID/密码并发送。 +4. 观察结果: + - 成功:BluFi 报告连接成功,设备自动连接 Wi‑Fi。 + - 失败:BluFi 返回失败状态,可重新发送或检查路由器。 + +## 注意事项 + +- BluFi 配网不支持与热点配网同时开启。如果热点配网已经启动,则默认使用热点配网。请在 menuconfig 中只保留一种配网方式。 +- 若多次测试,建议清除或覆盖存储的 SSID(`wifi` 命名空间),避免旧配置干扰。 +- 如果使用自定义 BluFi 客户端,需遵循官方协议帧格式,参考上文官方文档链接。 +- 官方文档中已提供EspBlufi APP下载地址 +- 由于IDF5.5.2的blufi接口发生变化,5.5.2版本编译后蓝牙名称为"Xiaozhi-Blufi",5.5.1版本中蓝牙名称为"BLUFI_DEVICE" diff --git a/docs/code_style.md b/docs/code_style.md new file mode 100644 index 0000000..e096762 --- /dev/null +++ b/docs/code_style.md @@ -0,0 +1,91 @@ +# 代码风格指南 + +## 代码格式化工具 + +本项目使用 clang-format 工具来统一代码风格。我们已经在项目根目录下提供了 `.clang-format` 配置文件,该配置基于 Google C++ 风格指南,并做了一些自定义调整。 + +### 安装 clang-format + +在使用之前,请确保你已经安装了 clang-format 工具: + +- **Windows**: + ```powershell + winget install LLVM + # 或者使用 Chocolatey + choco install llvm + ``` + +- **Linux**: + ```bash + sudo apt install clang-format # Ubuntu/Debian + sudo dnf install clang-tools-extra # Fedora + ``` + +- **macOS**: + ```bash + brew install clang-format + ``` + +### 使用方法 + +1. **格式化单个文件**: + ```bash + clang-format -i path/to/your/file.cpp + ``` + +2. **格式化整个项目**: + ```bash + # 在项目根目录下执行 + find main -iname *.h -o -iname *.cc | xargs clang-format -i + ``` + +3. **在提交代码前检查格式**: + ```bash + # 检查文件格式是否符合规范(不修改文件) + clang-format --dry-run -Werror path/to/your/file.cpp + ``` + +### IDE 集成 + +- **Visual Studio Code**: + 1. 安装 C/C++ 扩展 + 2. 在设置中启用 `C_Cpp.formatting` 为 `clang-format` + 3. 可以设置保存时自动格式化:`editor.formatOnSave: true` + +- **CLion**: + 1. 在设置中选择 `Editor > Code Style > C/C++` + 2. 将 `Formatter` 设置为 `clang-format` + 3. 选择使用项目中的 `.clang-format` 配置文件 + +### 主要格式规则 + +- 缩进使用 4 个空格 +- 行宽限制为 100 字符 +- 大括号采用 Attach 风格(与控制语句在同一行) +- 指针和引用符号靠左对齐 +- 自动排序头文件包含 +- 类访问修饰符缩进为 -4 空格 + +### 注意事项 + +1. 提交代码前请确保代码已经过格式化 +2. 不要手动调整已格式化的代码对齐 +3. 如果某段代码不希望被格式化,可以使用以下注释包围: + ```cpp + // clang-format off + // 你的代码 + // clang-format on + ``` + +### 常见问题 + +1. **格式化失败**: + - 检查 clang-format 版本是否过低 + - 确认文件编码为 UTF-8 + - 验证 .clang-format 文件语法是否正确 + +2. **与期望格式不符**: + - 检查是否使用了项目根目录下的 .clang-format 配置 + - 确认没有其他位置的 .clang-format 文件被优先使用 + +如有任何问题或建议,欢迎提出 issue 或 pull request。 \ No newline at end of file diff --git a/docs/custom-board.md b/docs/custom-board.md new file mode 100644 index 0000000..9e7bd8f --- /dev/null +++ b/docs/custom-board.md @@ -0,0 +1,453 @@ +# 自定义开发板指南 + +本指南介绍如何为小智AI语音聊天机器人项目定制一个新的开发板初始化程序。小智AI支持70多种ESP32系列开发板,每个开发板的初始化代码都放在对应的目录下。 + +## 重要提示 + +> **警告**: 对于自定义开发板,当IO配置与原有开发板不同时,切勿直接覆盖原有开发板的配置编译固件。必须创建新的开发板类型,或者通过config.json文件中的builds配置不同的name和sdkconfig宏定义来区分。使用 `python scripts/release.py [开发板目录名字]` 来编译打包固件。 +> +> 如果直接覆盖原有配置,将来OTA升级时,您的自定义固件可能会被原有开发板的标准固件覆盖,导致您的设备无法正常工作。每个开发板有唯一的标识和对应的固件升级通道,保持开发板标识的唯一性非常重要。 + +## 目录结构 + +每个开发板的目录结构通常包含以下文件: + +- `xxx_board.cc` - 主要的板级初始化代码,实现了板子相关的初始化和功能 +- `config.h` - 板级配置文件,定义了硬件管脚映射和其他配置项 +- `config.json` - 编译配置,指定目标芯片和特殊的编译选项 +- `README.md` - 开发板相关的说明文档 + +## 定制开发板步骤 + +### 1. 创建新的开发板目录 + +首先在`boards/`目录下创建一个新的目录,命名方式应使用 `[品牌名]-[开发板类型]` 的形式,例如 `m5stack-tab5`: + +```bash +mkdir main/boards/my-custom-board +``` + +### 2. 创建配置文件 + +#### config.h + +在`config.h`中定义所有的硬件配置,包括: + +- 音频采样率和I2S引脚配置 +- 音频编解码芯片地址和I2C引脚配置 +- 按钮和LED引脚配置 +- 显示屏参数和引脚配置 + +参考示例(来自lichuang-c3-dev): + +```c +#ifndef _BOARD_CONFIG_H_ +#define _BOARD_CONFIG_H_ + +#include + +// 音频配置 +#define AUDIO_INPUT_SAMPLE_RATE 24000 +#define AUDIO_OUTPUT_SAMPLE_RATE 24000 + +#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_10 +#define AUDIO_I2S_GPIO_WS GPIO_NUM_12 +#define AUDIO_I2S_GPIO_BCLK GPIO_NUM_8 +#define AUDIO_I2S_GPIO_DIN GPIO_NUM_7 +#define AUDIO_I2S_GPIO_DOUT GPIO_NUM_11 + +#define AUDIO_CODEC_PA_PIN GPIO_NUM_13 +#define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_0 +#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_1 +#define AUDIO_CODEC_ES8311_ADDR ES8311_CODEC_DEFAULT_ADDR + +// 按钮配置 +#define BOOT_BUTTON_GPIO GPIO_NUM_9 + +// 显示屏配置 +#define DISPLAY_SPI_SCK_PIN GPIO_NUM_3 +#define DISPLAY_SPI_MOSI_PIN GPIO_NUM_5 +#define DISPLAY_DC_PIN GPIO_NUM_6 +#define DISPLAY_SPI_CS_PIN GPIO_NUM_4 + +#define DISPLAY_WIDTH 320 +#define DISPLAY_HEIGHT 240 +#define DISPLAY_MIRROR_X true +#define DISPLAY_MIRROR_Y false +#define DISPLAY_SWAP_XY true + +#define DISPLAY_OFFSET_X 0 +#define DISPLAY_OFFSET_Y 0 + +#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_2 +#define DISPLAY_BACKLIGHT_OUTPUT_INVERT true + +#endif // _BOARD_CONFIG_H_ +``` + +#### config.json + +在`config.json`中定义编译配置,这个文件用于 `scripts/release.py` 脚本自动化编译: + +```json +{ + "target": "esp32s3", // 目标芯片型号: esp32, esp32s3, esp32c3, esp32c6, esp32p4等 + "builds": [ + { + "name": "my-custom-board", // 开发板名称,用于生成固件包 + "sdkconfig_append": [ + // 特别 Flash 大小配置 + "CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y", + // 特别分区表配置 + "CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\"" + ] + } + ] +} +``` + +**配置项说明:** +- `target`: 目标芯片型号,必须与硬件匹配 +- `name`: 编译输出的固件包名称,建议与目录名一致 +- `sdkconfig_append`: 额外的 sdkconfig 配置项数组,会追加到默认配置中 + +**常用的 sdkconfig_append 配置:** +```json +// Flash 大小 +"CONFIG_ESPTOOLPY_FLASHSIZE_4MB=y" // 4MB Flash +"CONFIG_ESPTOOLPY_FLASHSIZE_8MB=y" // 8MB Flash +"CONFIG_ESPTOOLPY_FLASHSIZE_16MB=y" // 16MB Flash + +// 分区表 +"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/4m.csv\"" // 4MB 分区表 +"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/8m.csv\"" // 8MB 分区表 +"CONFIG_PARTITION_TABLE_CUSTOM_FILENAME=\"partitions/v2/16m.csv\"" // 16MB 分区表 + +// 语言配置 +"CONFIG_LANGUAGE_EN_US=y" // 英语 +"CONFIG_LANGUAGE_ZH_CN=y" // 简体中文 + +// 唤醒词配置 +"CONFIG_USE_DEVICE_AEC=y" // 启用设备端 AEC +"CONFIG_WAKE_WORD_DISABLED=y" // 禁用唤醒词 +``` + +### 3. 编写板级初始化代码 + +创建一个`my_custom_board.cc`文件,实现开发板的所有初始化逻辑。 + +一个基本的开发板类定义包含以下几个部分: + +1. **类定义**:继承自`WifiBoard`或`Ml307Board` +2. **初始化函数**:包括I2C、显示屏、按钮、IoT等组件的初始化 +3. **虚函数重写**:如`GetAudioCodec()`、`GetDisplay()`、`GetBacklight()`等 +4. **注册开发板**:使用`DECLARE_BOARD`宏注册开发板 + +```cpp +#include "wifi_board.h" +#include "codecs/es8311_audio_codec.h" +#include "display/lcd_display.h" +#include "application.h" +#include "button.h" +#include "config.h" +#include "mcp_server.h" + +#include +#include +#include + +#define TAG "MyCustomBoard" + +class MyCustomBoard : public WifiBoard { +private: + i2c_master_bus_handle_t codec_i2c_bus_; + Button boot_button_; + LcdDisplay* display_; + + // I2C初始化 + void InitializeI2c() { + i2c_master_bus_config_t i2c_bus_cfg = { + .i2c_port = I2C_NUM_0, + .sda_io_num = AUDIO_CODEC_I2C_SDA_PIN, + .scl_io_num = AUDIO_CODEC_I2C_SCL_PIN, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + .intr_priority = 0, + .trans_queue_depth = 0, + .flags = { + .enable_internal_pullup = 1, + }, + }; + ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &codec_i2c_bus_)); + } + + // SPI初始化(用于显示屏) + void InitializeSpi() { + spi_bus_config_t buscfg = {}; + buscfg.mosi_io_num = DISPLAY_SPI_MOSI_PIN; + buscfg.miso_io_num = GPIO_NUM_NC; + buscfg.sclk_io_num = DISPLAY_SPI_SCK_PIN; + buscfg.quadwp_io_num = GPIO_NUM_NC; + buscfg.quadhd_io_num = GPIO_NUM_NC; + buscfg.max_transfer_sz = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t); + ESP_ERROR_CHECK(spi_bus_initialize(SPI2_HOST, &buscfg, SPI_DMA_CH_AUTO)); + } + + // 按钮初始化 + void InitializeButtons() { + boot_button_.OnClick([this]() { + auto& app = Application::GetInstance(); + if (app.GetDeviceState() == kDeviceStateStarting) { + EnterWifiConfigMode(); + return; + } + app.ToggleChatState(); + }); + } + + // 显示屏初始化(以ST7789为例) + void InitializeDisplay() { + esp_lcd_panel_io_handle_t panel_io = nullptr; + esp_lcd_panel_handle_t panel = nullptr; + + esp_lcd_panel_io_spi_config_t io_config = {}; + io_config.cs_gpio_num = DISPLAY_SPI_CS_PIN; + io_config.dc_gpio_num = DISPLAY_DC_PIN; + io_config.spi_mode = 2; + io_config.pclk_hz = 80 * 1000 * 1000; + io_config.trans_queue_depth = 10; + io_config.lcd_cmd_bits = 8; + io_config.lcd_param_bits = 8; + ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI2_HOST, &io_config, &panel_io)); + + esp_lcd_panel_dev_config_t panel_config = {}; + panel_config.reset_gpio_num = GPIO_NUM_NC; + panel_config.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB; + panel_config.bits_per_pixel = 16; + ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel)); + + esp_lcd_panel_reset(panel); + esp_lcd_panel_init(panel); + esp_lcd_panel_invert_color(panel, true); + esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY); + esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y); + + // 创建显示屏对象 + display_ = new SpiLcdDisplay(panel_io, panel, + DISPLAY_WIDTH, DISPLAY_HEIGHT, + DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y, + DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY); + } + + // MCP Tools 初始化 + void InitializeTools() { + // 参考 MCP 文档 + } + +public: + // 构造函数 + MyCustomBoard() : boot_button_(BOOT_BUTTON_GPIO) { + InitializeI2c(); + InitializeSpi(); + InitializeDisplay(); + InitializeButtons(); + InitializeTools(); + GetBacklight()->SetBrightness(100); + } + + // 获取音频编解码器 + virtual AudioCodec* GetAudioCodec() override { + static Es8311AudioCodec audio_codec( + codec_i2c_bus_, + I2C_NUM_0, + AUDIO_INPUT_SAMPLE_RATE, + AUDIO_OUTPUT_SAMPLE_RATE, + AUDIO_I2S_GPIO_MCLK, + AUDIO_I2S_GPIO_BCLK, + AUDIO_I2S_GPIO_WS, + AUDIO_I2S_GPIO_DOUT, + AUDIO_I2S_GPIO_DIN, + AUDIO_CODEC_PA_PIN, + AUDIO_CODEC_ES8311_ADDR); + return &audio_codec; + } + + // 获取显示屏 + virtual Display* GetDisplay() override { + return display_; + } + + // 获取背光控制 + virtual Backlight* GetBacklight() override { + static PwmBacklight backlight(DISPLAY_BACKLIGHT_PIN, DISPLAY_BACKLIGHT_OUTPUT_INVERT); + return &backlight; + } +}; + +// 注册开发板 +DECLARE_BOARD(MyCustomBoard); +``` + +### 4. 添加构建系统配置 + +#### 在 Kconfig.projbuild 中添加开发板选项 + +打开 `main/Kconfig.projbuild` 文件,在 `choice BOARD_TYPE` 部分添加新的开发板配置项: + +```kconfig +choice BOARD_TYPE + prompt "Board Type" + default BOARD_TYPE_BREAD_COMPACT_WIFI + help + Board type. 开发板类型 + + # ... 其他开发板选项 ... + + config BOARD_TYPE_MY_CUSTOM_BOARD + bool "My Custom Board (我的自定义开发板)" + depends on IDF_TARGET_ESP32S3 # 根据你的目标芯片修改 +endchoice +``` + +**注意事项:** +- `BOARD_TYPE_MY_CUSTOM_BOARD` 是配置项名称,需要全大写,使用下划线分隔 +- `depends on` 指定了目标芯片类型(如 `IDF_TARGET_ESP32S3`、`IDF_TARGET_ESP32C3` 等) +- 描述文字可以使用中英文 + +#### 在 CMakeLists.txt 中添加开发板配置 + +打开 `main/CMakeLists.txt` 文件,在开发板类型判断部分添加新的配置: + +```cmake +# 在 elseif 链中添加你的开发板配置 +elseif(CONFIG_BOARD_TYPE_MY_CUSTOM_BOARD) + set(BOARD_TYPE "my-custom-board") # 与目录名一致 + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) # 根据屏幕大小选择合适的字体 + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) # 可选,如果需要表情显示 +endif() +``` + +**字体和表情配置说明:** + +根据屏幕分辨率选择合适的字体大小: +- 小屏幕(128x64 OLED):`font_puhui_basic_14_1` / `font_awesome_14_1` +- 中小屏幕(240x240):`font_puhui_basic_16_4` / `font_awesome_16_4` +- 中等屏幕(240x320):`font_puhui_basic_20_4` / `font_awesome_20_4` +- 大屏幕(480x320+):`font_puhui_basic_30_4` / `font_awesome_30_4` + +表情集合选项: +- `twemoji_32` - 32x32 像素表情(小屏幕) +- `twemoji_64` - 64x64 像素表情(大屏幕) + +### 5. 配置和编译 + +#### 方法一:使用 idf.py 手动配置 + +1. **设置目标芯片**(首次配置或更换芯片时): + ```bash + # 对于 ESP32-S3 + idf.py set-target esp32s3 + + # 对于 ESP32-C3 + idf.py set-target esp32c3 + + # 对于 ESP32 + idf.py set-target esp32 + ``` + +2. **清理旧配置**: + ```bash + idf.py fullclean + ``` + +3. **进入配置菜单**: + ```bash + idf.py menuconfig + ``` + + 在菜单中导航到:`Xiaozhi Assistant` -> `Board Type`,选择你的自定义开发板。 + +4. **编译和烧录**: + ```bash + idf.py build + idf.py flash monitor + ``` + +#### 方法二:使用 release.py 脚本(推荐) + +如果你的开发板目录下有 `config.json` 文件,可以使用此脚本自动完成配置和编译: + +```bash +python scripts/release.py my-custom-board +``` + +此脚本会自动: +- 读取 `config.json` 中的 `target` 配置并设置目标芯片 +- 应用 `sdkconfig_append` 中的编译选项 +- 完成编译并打包固件 + +### 6. 创建README.md + +在README.md中说明开发板的特性、硬件要求、编译和烧录步骤: + + +## 常见开发板组件 + +### 1. 显示屏 + +项目支持多种显示屏驱动,包括: +- ST7789 (SPI) +- ILI9341 (SPI) +- SH8601 (QSPI) +- 等... + +### 2. 音频编解码器 + +支持的编解码器包括: +- ES8311 (常用) +- ES7210 (麦克风阵列) +- AW88298 (功放) +- 等... + +### 3. 电源管理 + +一些开发板使用电源管理芯片: +- AXP2101 +- 其他可用的PMIC + +### 4. MCP设备控制 + +可以添加各种MCP工具,让AI能够使用: +- Speaker (扬声器控制) +- Screen (屏幕亮度调节) +- Battery (电池电量读取) +- Light (灯光控制) +- 等... + +## 开发板类继承关系 + +- `Board` - 基础板级类 + - `WifiBoard` - Wi-Fi连接的开发板 + - `Ml307Board` - 使用4G模块的开发板 + - `DualNetworkBoard` - 支持Wi-Fi与4G网络切换的开发板 + +## 开发技巧 + +1. **参考相似的开发板**:如果您的新开发板与现有开发板有相似之处,可以参考现有实现 +2. **分步调试**:先实现基础功能(如显示),再添加更复杂的功能(如音频) +3. **管脚映射**:确保在config.h中正确配置所有管脚映射 +4. **检查硬件兼容性**:确认所有芯片和驱动程序的兼容性 + +## 可能遇到的问题 + +1. **显示屏不正常**:检查SPI配置、镜像设置和颜色反转设置 +2. **音频无输出**:检查I2S配置、PA使能引脚和编解码器地址 +3. **无法连接网络**:检查Wi-Fi凭据和网络配置 +4. **无法与服务器通信**:检查MQTT或WebSocket配置 + +## 参考资料 + +- ESP-IDF 文档: https://docs.espressif.com/projects/esp-idf/ +- LVGL 文档: https://docs.lvgl.io/ +- ESP-SR 文档: https://github.com/espressif/esp-sr \ No newline at end of file diff --git a/docs/mcp-based-graph.jpg b/docs/mcp-based-graph.jpg new file mode 100644 index 0000000..af81cd2 Binary files /dev/null and b/docs/mcp-based-graph.jpg differ diff --git a/docs/mcp-protocol.md b/docs/mcp-protocol.md new file mode 100644 index 0000000..0c8ec90 --- /dev/null +++ b/docs/mcp-protocol.md @@ -0,0 +1,269 @@ +# MCP (Model Context Protocol) 交互流程 + +NOTICE: AI 辅助生成, 在实现后台服务时, 请参照代码确认细节!! + +本项目中的 MCP 协议用于后台 API(MCP 客户端)与 ESP32 设备(MCP 服务器)之间的通信,以便后台能够发现和调用设备提供的功能(工具)。 + +## 协议格式 + +根据代码 (`main/protocols/protocol.cc`, `main/mcp_server.cc`),MCP 消息是封装在基础通信协议(如 WebSocket 或 MQTT)的消息体中的。其内部结构遵循 [JSON-RPC 2.0](https://www.jsonrpc.org/specification) 规范。 + +整体消息结构示例: + +```json +{ + "session_id": "...", // 会话 ID + "type": "mcp", // 消息类型,固定为 "mcp" + "payload": { // JSON-RPC 2.0 负载 + "jsonrpc": "2.0", + "method": "...", // 方法名 (如 "initialize", "tools/list", "tools/call") + "params": { ... }, // 方法参数 (对于 request) + "id": ..., // 请求 ID (对于 request 和 response) + "result": { ... }, // 方法执行结果 (对于 success response) + "error": { ... } // 错误信息 (对于 error response) + } +} +``` + +其中,`payload` 部分是标准的 JSON-RPC 2.0 消息: + +- `jsonrpc`: 固定的字符串 "2.0"。 +- `method`: 要调用的方法名称 (对于 Request)。 +- `params`: 方法的参数,一个结构化值,通常为对象 (对于 Request)。 +- `id`: 请求的标识符,客户端发送请求时提供,服务器响应时原样返回。用于匹配请求和响应。 +- `result`: 方法成功执行时的结果 (对于 Success Response)。 +- `error`: 方法执行失败时的错误信息 (对于 Error Response)。 + +## 交互流程及发送时机 + +MCP 的交互主要围绕客户端(后台 API)发现和调用设备上的“工具”(Tool)进行。 + +1. **连接建立与能力通告** + + - **时机:** 设备启动并成功连接到后台 API 后。 + - **发送方:** 设备。 + - **消息:** 设备发送基础协议的 "hello" 消息给后台 API,消息中包含设备支持的能力列表,例如通过支持 MCP 协议 (`"mcp": true`)。 + - **示例 (非 MCP 负载,而是基础协议消息):** + ```json + { + "type": "hello", + "version": ..., + "features": { + "mcp": true, + ... + }, + "transport": "websocket", // 或 "mqtt" + "audio_params": { ... }, + "session_id": "..." // 设备收到服务器hello后可能设置 + } + ``` + +2. **初始化 MCP 会话** + + - **时机:** 后台 API 收到设备 "hello" 消息,确认设备支持 MCP 后,通常作为 MCP 会话的第一个请求发送。 + - **发送方:** 后台 API (客户端)。 + - **方法:** `initialize` + - **消息 (MCP payload):** + + ```json + { + "jsonrpc": "2.0", + "method": "initialize", + "params": { + "capabilities": { + // 客户端能力,可选 + + // 摄像头视觉相关 + "vision": { + "url": "...", //摄像头: 图片处理地址(必须是http地址, 不是websocket地址) + "token": "..." // url token + } + + // ... 其他客户端能力 + } + }, + "id": 1 // 请求 ID + } + ``` + + - **设备响应时机:** 设备收到 `initialize` 请求并处理后。 + - **设备响应消息 (MCP payload):** + ```json + { + "jsonrpc": "2.0", + "id": 1, // 匹配请求 ID + "result": { + "protocolVersion": "2024-11-05", + "capabilities": { + "tools": {} // 这里的 tools 似乎不列出详细信息,需要 tools/list + }, + "serverInfo": { + "name": "...", // 设备名称 (BOARD_NAME) + "version": "..." // 设备固件版本 + } + } + } + ``` + +3. **发现设备工具列表** + + - **时机:** 后台 API 需要获取设备当前支持的具体功能(工具)列表及其调用方式时。 + - **发送方:** 后台 API (客户端)。 + - **方法:** `tools/list` + - **消息 (MCP payload):** + ```json + { + "jsonrpc": "2.0", + "method": "tools/list", + "params": { + "cursor": "" // 用于分页,首次请求为空字符串 + }, + "id": 2 // 请求 ID + } + ``` + - **设备响应时机:** 设备收到 `tools/list` 请求并生成工具列表后。 + - **设备响应消息 (MCP payload):** + ```json + { + "jsonrpc": "2.0", + "id": 2, // 匹配请求 ID + "result": { + "tools": [ // 工具对象列表 + { + "name": "self.get_device_status", + "description": "...", + "inputSchema": { ... } // 参数 schema + }, + { + "name": "self.audio_speaker.set_volume", + "description": "...", + "inputSchema": { ... } // 参数 schema + } + // ... 更多工具 + ], + "nextCursor": "..." // 如果列表很大需要分页,这里会包含下一个请求的 cursor 值 + } + } + ``` + - **分页处理:** 如果 `nextCursor` 字段非空,客户端需要再次发送 `tools/list` 请求,并在 `params` 中带上这个 `cursor` 值以获取下一页工具。 + +4. **调用设备工具** + + - **时机:** 后台 API 需要执行设备上的某个具体功能时。 + - **发送方:** 后台 API (客户端)。 + - **方法:** `tools/call` + - **消息 (MCP payload):** + ```json + { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "self.audio_speaker.set_volume", // 要调用的工具名称 + "arguments": { + // 工具参数,对象格式 + "volume": 50 // 参数名及其值 + } + }, + "id": 3 // 请求 ID + } + ``` + - **设备响应时机:** 设备收到 `tools/call` 请求,执行相应的工具函数后。 + - **设备成功响应消息 (MCP payload):** + ```json + { + "jsonrpc": "2.0", + "id": 3, // 匹配请求 ID + "result": { + "content": [ + // 工具执行结果内容 + { "type": "text", "text": "true" } // 示例:set_volume 返回 bool + ], + "isError": false // 表示成功 + } + } + ``` + - **设备失败响应消息 (MCP payload):** + ```json + { + "jsonrpc": "2.0", + "id": 3, // 匹配请求 ID + "error": { + "code": -32601, // JSON-RPC 错误码,例如 Method not found (-32601) + "message": "Unknown tool: self.non_existent_tool" // 错误描述 + } + } + ``` + +5. **设备主动发送消息 (Notifications)** + - **时机:** 设备内部发生需要通知后台 API 的事件时(例如,状态变化,虽然代码示例中没有明确的工具发送此类消息,但 `Application::SendMcpMessage` 的存在暗示了设备可能主动发送 MCP 消息)。 + - **发送方:** 设备 (服务器)。 + - **方法:** 可能是以 `notifications/` 开头的方法名,或者其他自定义方法。 + - **消息 (MCP payload):** 遵循 JSON-RPC Notification 格式,没有 `id` 字段。 + ```json + { + "jsonrpc": "2.0", + "method": "notifications/state_changed", // 示例方法名 + "params": { + "newState": "idle", + "oldState": "connecting" + } + // 没有 id 字段 + } + ``` + - **后台 API 处理:** 接收到 Notification 后,后台 API 进行相应的处理,但不回复。 + +## 交互图 + +下面是一个简化的交互序列图,展示了主要的 MCP 消息流程: + +```mermaid +sequenceDiagram + participant Device as ESP32 Device + participant BackendAPI as 后台 API (Client) + + Note over Device, BackendAPI: 建立 WebSocket / MQTT 连接 + + Device->>BackendAPI: Hello Message (包含 "mcp": true) + + BackendAPI->>Device: MCP Initialize Request + Note over BackendAPI: method: initialize + Note over BackendAPI: params: { capabilities: ... } + + Device->>BackendAPI: MCP Initialize Response + Note over Device: result: { protocolVersion: ..., serverInfo: ... } + + BackendAPI->>Device: MCP Get Tools List Request + Note over BackendAPI: method: tools/list + Note over BackendAPI: params: { cursor: "" } + + Device->>BackendAPI: MCP Get Tools List Response + Note over Device: result: { tools: [...], nextCursor: ... } + + loop Optional Pagination + BackendAPI->>Device: MCP Get Tools List Request + Note over BackendAPI: method: tools/list + Note over BackendAPI: params: { cursor: "..." } + Device->>BackendAPI: MCP Get Tools List Response + Note over Device: result: { tools: [...], nextCursor: "" } + end + + BackendAPI->>Device: MCP Call Tool Request + Note over BackendAPI: method: tools/call + Note over BackendAPI: params: { name: "...", arguments: { ... } } + + alt Tool Call Successful + Device->>BackendAPI: MCP Tool Call Success Response + Note over Device: result: { content: [...], isError: false } + else Tool Call Failed + Device->>BackendAPI: MCP Tool Call Error Response + Note over Device: error: { code: ..., message: ... } + end + + opt Device Notification + Device->>BackendAPI: MCP Notification + Note over Device: method: notifications/... + Note over Device: params: { ... } + end +``` + +这份文档概述了该项目中 MCP 协议的主要交互流程。具体的参数细节和工具功能需要参考 `main/mcp_server.cc` 中 `McpServer::AddCommonTools` 以及各个工具的实现。 diff --git a/docs/mcp-usage.md b/docs/mcp-usage.md new file mode 100644 index 0000000..fa50a39 --- /dev/null +++ b/docs/mcp-usage.md @@ -0,0 +1,115 @@ +# MCP 协议物联网控制用法说明 + +> 本文档介绍如何基于 MCP 协议实现 ESP32 设备的物联网控制。详细协议流程请参考 [`mcp-protocol.md`](./mcp-protocol.md)。 + +## 简介 + +MCP(Model Context Protocol)是新一代推荐用于物联网控制的协议,通过标准 JSON-RPC 2.0 格式在后台与设备间发现和调用"工具"(Tool),实现灵活的设备控制。 + +## 典型使用流程 + +1. 设备启动后通过基础协议(如 WebSocket/MQTT)与后台建立连接。 +2. 后台通过 MCP 协议的 `initialize` 方法初始化会话。 +3. 后台通过 `tools/list` 获取设备支持的所有工具(功能)及参数说明。 +4. 后台通过 `tools/call` 调用具体工具,实现对设备的控制。 + +详细协议格式与交互请见 [`mcp-protocol.md`](./mcp-protocol.md)。 + +## 设备端工具注册方法说明 + +设备通过 `McpServer::AddTool` 方法注册可被后台调用的"工具"。其常用函数签名如下: + +```cpp +void AddTool( + const std::string& name, // 工具名称,建议唯一且有层次感,如 self.dog.forward + const std::string& description, // 工具描述,简明说明功能,便于大模型理解 + const PropertyList& properties, // 输入参数列表(可为空),支持类型:布尔、整数、字符串 + std::function callback // 工具被调用时的回调实现 +); +``` +- name:工具唯一标识,建议用"模块.功能"命名风格。 +- description:自然语言描述,便于 AI/用户理解。 +- properties:参数列表,支持类型有布尔、整数、字符串,可指定范围和默认值。 +- callback:收到调用请求时的实际执行逻辑,返回值可为 bool/int/string。 + +## 典型注册示例(以 ESP-Hi 为例) + +```cpp +void InitializeTools() { + auto& mcp_server = McpServer::GetInstance(); + // 例1:无参数,控制机器人前进 + mcp_server.AddTool("self.dog.forward", "机器人向前移动", PropertyList(), [this](const PropertyList&) -> ReturnValue { + servo_dog_ctrl_send(DOG_STATE_FORWARD, NULL); + return true; + }); + // 例2:带参数,设置灯光 RGB 颜色 + mcp_server.AddTool("self.light.set_rgb", "设置RGB颜色", PropertyList({ + Property("r", kPropertyTypeInteger, 0, 255), + Property("g", kPropertyTypeInteger, 0, 255), + Property("b", kPropertyTypeInteger, 0, 255) + }), [this](const PropertyList& properties) -> ReturnValue { + int r = properties["r"].value(); + int g = properties["g"].value(); + int b = properties["b"].value(); + led_on_ = true; + SetLedColor(r, g, b); + return true; + }); +} +``` + +## 常见工具调用 JSON-RPC 示例 + +### 1. 获取工具列表 +```json +{ + "jsonrpc": "2.0", + "method": "tools/list", + "params": { "cursor": "" }, + "id": 1 +} +``` + +### 2. 控制底盘前进 +```json +{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "self.chassis.go_forward", + "arguments": {} + }, + "id": 2 +} +``` + +### 3. 切换灯光模式 +```json +{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "self.chassis.switch_light_mode", + "arguments": { "light_mode": 3 } + }, + "id": 3 +} +``` + +### 4. 摄像头翻转 +```json +{ + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "self.camera.set_camera_flipped", + "arguments": {} + }, + "id": 4 +} +``` + +## 备注 +- 工具名称、参数及返回值请以设备端 `AddTool` 注册为准。 +- 推荐所有新项目统一采用 MCP 协议进行物联网控制。 +- 详细协议与进阶用法请查阅 [`mcp-protocol.md`](./mcp-protocol.md)。 \ No newline at end of file diff --git a/docs/mqtt-udp.md b/docs/mqtt-udp.md new file mode 100644 index 0000000..478e466 --- /dev/null +++ b/docs/mqtt-udp.md @@ -0,0 +1,393 @@ +# MQTT + UDP 混合通信协议文档 + +基于代码实现整理的 MQTT + UDP 混合通信协议文档,概述设备端与服务器之间如何通过 MQTT 进行控制消息传输,通过 UDP 进行音频数据传输的交互方式。 + +--- + +## 1. 协议概览 + +本协议采用混合传输方式: +- **MQTT**:用于控制消息、状态同步、JSON 数据交换 +- **UDP**:用于实时音频数据传输,支持加密 + +### 1.1 协议特点 + +- **双通道设计**:控制与数据分离,确保实时性 +- **加密传输**:UDP 音频数据使用 AES-CTR 加密 +- **序列号保护**:防止数据包重放和乱序 +- **自动重连**:MQTT 连接断开时自动重连 + +--- + +## 2. 总体流程概览 + +```mermaid +sequenceDiagram + participant Device as ESP32 设备 + participant MQTT as MQTT 服务器 + participant UDP as UDP 服务器 + + Note over Device, UDP: 1. 建立 MQTT 连接 + Device->>MQTT: MQTT Connect + MQTT->>Device: Connected + + Note over Device, UDP: 2. 请求音频通道 + Device->>MQTT: Hello Message (type: "hello", transport: "udp") + MQTT->>Device: Hello Response (UDP 连接信息 + 加密密钥) + + Note over Device, UDP: 3. 建立 UDP 连接 + Device->>UDP: UDP Connect + UDP->>Device: Connected + + Note over Device, UDP: 4. 音频数据传输 + loop 音频流传输 + Device->>UDP: 加密音频数据 (Opus) + UDP->>Device: 加密音频数据 (Opus) + end + + Note over Device, UDP: 5. 控制消息交换 + par 控制消息 + Device->>MQTT: Listen/TTS/MCP 消息 + MQTT->>Device: STT/TTS/MCP 响应 + end + + Note over Device, UDP: 6. 关闭连接 + Device->>MQTT: Goodbye Message + Device->>UDP: Disconnect +``` + +--- + +## 3. MQTT 控制通道 + +### 3.1 连接建立 + +设备通过 MQTT 连接到服务器,连接参数包括: +- **Endpoint**:MQTT 服务器地址和端口 +- **Client ID**:设备唯一标识符 +- **Username/Password**:认证凭据 +- **Keep Alive**:心跳间隔(默认240秒) + +### 3.2 Hello 消息交换 + +#### 3.2.1 设备端发送 Hello + +```json +{ + "type": "hello", + "version": 3, + "transport": "udp", + "features": { + "mcp": true + }, + "audio_params": { + "format": "opus", + "sample_rate": 16000, + "channels": 1, + "frame_duration": 60 + } +} +``` + +#### 3.2.2 服务器响应 Hello + +```json +{ + "type": "hello", + "transport": "udp", + "session_id": "xxx", + "audio_params": { + "format": "opus", + "sample_rate": 24000, + "channels": 1, + "frame_duration": 60 + }, + "udp": { + "server": "192.168.1.100", + "port": 8888, + "key": "0123456789ABCDEF0123456789ABCDEF", + "nonce": "0123456789ABCDEF0123456789ABCDEF" + } +} +``` + +**字段说明:** +- `udp.server`:UDP 服务器地址 +- `udp.port`:UDP 服务器端口 +- `udp.key`:AES 加密密钥(十六进制字符串) +- `udp.nonce`:AES 加密随机数(十六进制字符串) + +### 3.3 JSON 消息类型 + +#### 3.3.1 设备端→服务器 + +1. **Listen 消息** + ```json + { + "session_id": "xxx", + "type": "listen", + "state": "start", + "mode": "manual" + } + ``` + +2. **Abort 消息** + ```json + { + "session_id": "xxx", + "type": "abort", + "reason": "wake_word_detected" + } + ``` + +3. **MCP 消息** + ```json + { + "session_id": "xxx", + "type": "mcp", + "payload": { + "jsonrpc": "2.0", + "id": 1, + "result": {...} + } + } + ``` + +4. **Goodbye 消息** + ```json + { + "session_id": "xxx", + "type": "goodbye" + } + ``` + +#### 3.3.2 服务器→设备端 + +支持的消息类型与 WebSocket 协议一致,包括: +- **STT**:语音识别结果 +- **TTS**:语音合成控制 +- **LLM**:情感表达控制 +- **MCP**:物联网控制 +- **System**:系统控制 +- **Custom**:自定义消息(可选) + +--- + +## 4. UDP 音频通道 + +### 4.1 连接建立 + +设备收到 MQTT Hello 响应后,使用其中的 UDP 连接信息建立音频通道: +1. 解析 UDP 服务器地址和端口 +2. 解析加密密钥和随机数 +3. 初始化 AES-CTR 加密上下文 +4. 建立 UDP 连接 + +### 4.2 音频数据格式 + +#### 4.2.1 加密音频包结构 + +``` +|type 1byte|flags 1byte|payload_len 2bytes|ssrc 4bytes|timestamp 4bytes|sequence 4bytes| +|payload payload_len bytes| +``` + +**字段说明:** +- `type`:数据包类型,固定为 0x01 +- `flags`:标志位,当前未使用 +- `payload_len`:负载长度(网络字节序) +- `ssrc`:同步源标识符 +- `timestamp`:时间戳(网络字节序) +- `sequence`:序列号(网络字节序) +- `payload`:加密的 Opus 音频数据 + +#### 4.2.2 加密算法 + +使用 **AES-CTR** 模式加密: +- **密钥**:128位,由服务器提供 +- **随机数**:128位,由服务器提供 +- **计数器**:包含时间戳和序列号信息 + +### 4.3 序列号管理 + +- **发送端**:`local_sequence_` 单调递增 +- **接收端**:`remote_sequence_` 验证连续性 +- **防重放**:拒绝序列号小于期望值的数据包 +- **容错处理**:允许轻微的序列号跳跃,记录警告 + +### 4.4 错误处理 + +1. **解密失败**:记录错误,丢弃数据包 +2. **序列号异常**:记录警告,但仍处理数据包 +3. **数据包格式错误**:记录错误,丢弃数据包 + +--- + +## 5. 状态管理 + +### 5.1 连接状态 + +```mermaid +stateDiagram + direction TB + [*] --> Disconnected + Disconnected --> MqttConnecting: StartMqttClient() + MqttConnecting --> MqttConnected: MQTT Connected + MqttConnecting --> Disconnected: Connect Failed + MqttConnected --> RequestingChannel: OpenAudioChannel() + RequestingChannel --> ChannelOpened: Hello Exchange Success + RequestingChannel --> MqttConnected: Hello Timeout/Failed + ChannelOpened --> UdpConnected: UDP Connect Success + UdpConnected --> AudioStreaming: Start Audio Transfer + AudioStreaming --> UdpConnected: Stop Audio Transfer + UdpConnected --> ChannelOpened: UDP Disconnect + ChannelOpened --> MqttConnected: CloseAudioChannel() + MqttConnected --> Disconnected: MQTT Disconnect +``` + +### 5.2 状态检查 + +设备通过以下条件判断音频通道是否可用: +```cpp +bool IsAudioChannelOpened() const { + return udp_ != nullptr && !error_occurred_ && !IsTimeout(); +} +``` + +--- + +## 6. 配置参数 + +### 6.1 MQTT 配置 + +从设置中读取的配置项: +- `endpoint`:MQTT 服务器地址 +- `client_id`:客户端标识符 +- `username`:用户名 +- `password`:密码 +- `keepalive`:心跳间隔(默认240秒) +- `publish_topic`:发布主题 + +### 6.2 音频参数 + +- **格式**:Opus +- **采样率**:16000 Hz(设备端)/ 24000 Hz(服务器端) +- **声道数**:1(单声道) +- **帧时长**:60ms + +--- + +## 7. 错误处理与重连 + +### 7.1 MQTT 重连机制 + +- 连接失败时自动重试 +- 支持错误上报控制 +- 断线时触发清理流程 + +### 7.2 UDP 连接管理 + +- 连接失败时不自动重试 +- 依赖 MQTT 通道重新协商 +- 支持连接状态查询 + +### 7.3 超时处理 + +基类 `Protocol` 提供超时检测: +- 默认超时时间:120 秒 +- 基于最后接收时间计算 +- 超时时自动标记为不可用 + +--- + +## 8. 安全考虑 + +### 8.1 传输加密 + +- **MQTT**:支持 TLS/SSL 加密(端口8883) +- **UDP**:使用 AES-CTR 加密音频数据 + +### 8.2 认证机制 + +- **MQTT**:用户名/密码认证 +- **UDP**:通过 MQTT 通道分发密钥 + +### 8.3 防重放攻击 + +- 序列号单调递增 +- 拒绝过期数据包 +- 时间戳验证 + +--- + +## 9. 性能优化 + +### 9.1 并发控制 + +使用互斥锁保护 UDP 连接: +```cpp +std::lock_guard lock(channel_mutex_); +``` + +### 9.2 内存管理 + +- 动态创建/销毁网络对象 +- 智能指针管理音频数据包 +- 及时释放加密上下文 + +### 9.3 网络优化 + +- UDP 连接复用 +- 数据包大小优化 +- 序列号连续性检查 + +--- + +## 10. 与 WebSocket 协议的比较 + +| 特性 | MQTT + UDP | WebSocket | +|------|------------|-----------| +| 控制通道 | MQTT | WebSocket | +| 音频通道 | UDP (加密) | WebSocket (二进制) | +| 实时性 | 高 (UDP) | 中等 | +| 可靠性 | 中等 | 高 | +| 复杂度 | 高 | 低 | +| 加密 | AES-CTR | TLS | +| 防火墙友好度 | 低 | 高 | + +--- + +## 11. 部署建议 + +### 11.1 网络环境 + +- 确保 UDP 端口可达 +- 配置防火墙规则 +- 考虑 NAT 穿透 + +### 11.2 服务器配置 + +- MQTT Broker 配置 +- UDP 服务器部署 +- 密钥管理系统 + +### 11.3 监控指标 + +- 连接成功率 +- 音频传输延迟 +- 数据包丢失率 +- 解密失败率 + +--- + +## 12. 总结 + +MQTT + UDP 混合协议通过以下设计实现高效的音视频通信: + +- **分离式架构**:控制与数据通道分离,各司其职 +- **加密保护**:AES-CTR 确保音频数据安全传输 +- **序列化管理**:防止重放攻击和数据乱序 +- **自动恢复**:支持连接断开后的自动重连 +- **性能优化**:UDP 传输保证音频数据的实时性 + +该协议适用于对实时性要求较高的语音交互场景,但需要在网络复杂度和传输性能之间做出权衡。 \ No newline at end of file diff --git a/docs/v0/AtomMatrix-echo-base.jpg b/docs/v0/AtomMatrix-echo-base.jpg new file mode 100644 index 0000000..979cf81 Binary files /dev/null and b/docs/v0/AtomMatrix-echo-base.jpg differ diff --git a/docs/v0/ESP32-BreadBoard.jpg b/docs/v0/ESP32-BreadBoard.jpg new file mode 100644 index 0000000..f7a6fd4 Binary files /dev/null and b/docs/v0/ESP32-BreadBoard.jpg differ diff --git a/docs/v0/atoms3r-echo-base.jpg b/docs/v0/atoms3r-echo-base.jpg new file mode 100644 index 0000000..961e72b Binary files /dev/null and b/docs/v0/atoms3r-echo-base.jpg differ diff --git a/docs/v0/esp32s3-box3.jpg b/docs/v0/esp32s3-box3.jpg new file mode 100644 index 0000000..53c4b55 Binary files /dev/null and b/docs/v0/esp32s3-box3.jpg differ diff --git a/docs/v0/lichuang-s3.jpg b/docs/v0/lichuang-s3.jpg new file mode 100644 index 0000000..721e0a0 Binary files /dev/null and b/docs/v0/lichuang-s3.jpg differ diff --git a/docs/v0/m5stack-cores3.jpg b/docs/v0/m5stack-cores3.jpg new file mode 100644 index 0000000..b123f73 Binary files /dev/null and b/docs/v0/m5stack-cores3.jpg differ diff --git a/docs/v0/magiclick-2p4.jpg b/docs/v0/magiclick-2p4.jpg new file mode 100644 index 0000000..beffb3d Binary files /dev/null and b/docs/v0/magiclick-2p4.jpg differ diff --git a/docs/v0/waveshare-esp32-s3-touch-amoled-1.8.jpg b/docs/v0/waveshare-esp32-s3-touch-amoled-1.8.jpg new file mode 100644 index 0000000..90f2744 Binary files /dev/null and b/docs/v0/waveshare-esp32-s3-touch-amoled-1.8.jpg differ diff --git a/docs/v0/wiring.jpg b/docs/v0/wiring.jpg new file mode 100644 index 0000000..764c170 Binary files /dev/null and b/docs/v0/wiring.jpg differ diff --git a/docs/v1/atoms3r.jpg b/docs/v1/atoms3r.jpg new file mode 100644 index 0000000..45cbb45 Binary files /dev/null and b/docs/v1/atoms3r.jpg differ diff --git a/docs/v1/electron-bot.png b/docs/v1/electron-bot.png new file mode 100644 index 0000000..4d00d6d Binary files /dev/null and b/docs/v1/electron-bot.png differ diff --git a/docs/v1/esp-hi.jpg b/docs/v1/esp-hi.jpg new file mode 100644 index 0000000..d6fc714 Binary files /dev/null and b/docs/v1/esp-hi.jpg differ diff --git a/docs/v1/esp-sparkbot.jpg b/docs/v1/esp-sparkbot.jpg new file mode 100644 index 0000000..b738840 Binary files /dev/null and b/docs/v1/esp-sparkbot.jpg differ diff --git a/docs/v1/espbox3.jpg b/docs/v1/espbox3.jpg new file mode 100644 index 0000000..641d74b Binary files /dev/null and b/docs/v1/espbox3.jpg differ diff --git a/docs/v1/lichuang-s3.jpg b/docs/v1/lichuang-s3.jpg new file mode 100644 index 0000000..a559070 Binary files /dev/null and b/docs/v1/lichuang-s3.jpg differ diff --git a/docs/v1/lilygo-t-circle-s3.jpg b/docs/v1/lilygo-t-circle-s3.jpg new file mode 100644 index 0000000..45985d8 Binary files /dev/null and b/docs/v1/lilygo-t-circle-s3.jpg differ diff --git a/docs/v1/m5cores3.jpg b/docs/v1/m5cores3.jpg new file mode 100644 index 0000000..6a30cef Binary files /dev/null and b/docs/v1/m5cores3.jpg differ diff --git a/docs/v1/magiclick.jpg b/docs/v1/magiclick.jpg new file mode 100644 index 0000000..3c01463 Binary files /dev/null and b/docs/v1/magiclick.jpg differ diff --git a/docs/v1/movecall-cuican-esp32s3.jpg b/docs/v1/movecall-cuican-esp32s3.jpg new file mode 100644 index 0000000..ae70cfd Binary files /dev/null and b/docs/v1/movecall-cuican-esp32s3.jpg differ diff --git a/docs/v1/movecall-moji-esp32s3.jpg b/docs/v1/movecall-moji-esp32s3.jpg new file mode 100644 index 0000000..dec4526 Binary files /dev/null and b/docs/v1/movecall-moji-esp32s3.jpg differ diff --git a/docs/v1/otto-robot.png b/docs/v1/otto-robot.png new file mode 100644 index 0000000..d61cbdc Binary files /dev/null and b/docs/v1/otto-robot.png differ diff --git a/docs/v1/sensecap_watcher.jpg b/docs/v1/sensecap_watcher.jpg new file mode 100644 index 0000000..b1d7e4c Binary files /dev/null and b/docs/v1/sensecap_watcher.jpg differ diff --git a/docs/v1/waveshare.jpg b/docs/v1/waveshare.jpg new file mode 100644 index 0000000..7dacf2f Binary files /dev/null and b/docs/v1/waveshare.jpg differ diff --git a/docs/v1/wiring2.jpg b/docs/v1/wiring2.jpg new file mode 100644 index 0000000..f3a67ae Binary files /dev/null and b/docs/v1/wiring2.jpg differ diff --git a/docs/v1/wmnologo_xingzhi_0.96.jpg b/docs/v1/wmnologo_xingzhi_0.96.jpg new file mode 100644 index 0000000..24369cc Binary files /dev/null and b/docs/v1/wmnologo_xingzhi_0.96.jpg differ diff --git a/docs/v1/wmnologo_xingzhi_1.54.jpg b/docs/v1/wmnologo_xingzhi_1.54.jpg new file mode 100644 index 0000000..7456477 Binary files /dev/null and b/docs/v1/wmnologo_xingzhi_1.54.jpg differ diff --git a/docs/v1/xmini-c3.jpg b/docs/v1/xmini-c3.jpg new file mode 100644 index 0000000..f1ed8c2 Binary files /dev/null and b/docs/v1/xmini-c3.jpg differ diff --git a/docs/websocket.md b/docs/websocket.md new file mode 100644 index 0000000..f767114 --- /dev/null +++ b/docs/websocket.md @@ -0,0 +1,495 @@ +以下是一份基于代码实现整理的 WebSocket 通信协议文档,概述设备端与服务器之间如何通过 WebSocket 进行交互。 + +该文档仅基于所提供的代码推断,实际部署时可能需要结合服务器端实现进行进一步确认或补充。 + +--- + +## 1. 总体流程概览 + +1. **设备端初始化** + - 设备上电、初始化 `Application`: + - 初始化音频编解码器、显示屏、LED 等 + - 连接网络 + - 创建并初始化实现 `Protocol` 接口的 WebSocket 协议实例(`WebsocketProtocol`) + - 进入主循环等待事件(音频输入、音频输出、调度任务等)。 + +2. **建立 WebSocket 连接** + - 当设备需要开始语音会话时(例如用户唤醒、手动按键触发等),调用 `OpenAudioChannel()`: + - 根据配置获取 WebSocket URL + - 设置若干请求头(`Authorization`, `Protocol-Version`, `Device-Id`, `Client-Id`) + - 调用 `Connect()` 与服务器建立 WebSocket 连接 + +3. **设备端发送 "hello" 消息** + - 连接成功后,设备会发送一条 JSON 消息,示例结构如下: + ```json + { + "type": "hello", + "version": 1, + "features": { + "mcp": true + }, + "transport": "websocket", + "audio_params": { + "format": "opus", + "sample_rate": 16000, + "channels": 1, + "frame_duration": 60 + } + } + ``` + - 其中 `features` 字段为可选,内容根据设备编译配置自动生成。例如:`"mcp": true` 表示支持 MCP 协议。 + - `frame_duration` 的值对应 `OPUS_FRAME_DURATION_MS`(例如 60ms)。 + +4. **服务器回复 "hello"** + - 设备等待服务器返回一条包含 `"type": "hello"` 的 JSON 消息,并检查 `"transport": "websocket"` 是否匹配。 + - 服务器可选下发 `session_id` 字段,设备端收到后会自动记录。 + - 示例: + ```json + { + "type": "hello", + "transport": "websocket", + "session_id": "xxx", + "audio_params": { + "format": "opus", + "sample_rate": 24000, + "channels": 1, + "frame_duration": 60 + } + } + ``` + - 如果匹配,则认为服务器已就绪,标记音频通道打开成功。 + - 如果在超时时间(默认 10 秒)内未收到正确回复,认为连接失败并触发网络错误回调。 + +5. **后续消息交互** + - 设备端和服务器端之间可发送两种主要类型的数据: + 1. **二进制音频数据**(Opus 编码) + 2. **文本 JSON 消息**(用于传输聊天状态、TTS/STT 事件、MCP 协议消息等) + + - 在代码里,接收回调主要分为: + - `OnData(...)`: + - 当 `binary` 为 `true` 时,认为是音频帧;设备会将其当作 Opus 数据进行解码。 + - 当 `binary` 为 `false` 时,认为是 JSON 文本,需要在设备端用 cJSON 进行解析并做相应业务逻辑处理(如聊天、TTS、MCP 协议消息等)。 + + - 当服务器或网络出现断连,回调 `OnDisconnected()` 被触发: + - 设备会调用 `on_audio_channel_closed_()`,并最终回到空闲状态。 + +6. **关闭 WebSocket 连接** + - 设备在需要结束语音会话时,会调用 `CloseAudioChannel()` 主动断开连接,并回到空闲状态。 + - 或者如果服务器端主动断开,也会引发同样的回调流程。 + +--- + +## 2. 通用请求头 + +在建立 WebSocket 连接时,代码示例中设置了以下请求头: + +- `Authorization`: 用于存放访问令牌,形如 `"Bearer "` +- `Protocol-Version`: 协议版本号,与 hello 消息体内的 `version` 字段保持一致 +- `Device-Id`: 设备物理网卡 MAC 地址 +- `Client-Id`: 软件生成的 UUID(擦除 NVS 或重新烧录完整固件会重置) + +这些头会随着 WebSocket 握手一起发送到服务器,服务器可根据需求进行校验、认证等。 + +--- + +## 3. 二进制协议版本 + +设备支持多种二进制协议版本,通过配置中的 `version` 字段指定: + +### 3.1 版本1(默认) +直接发送 Opus 音频数据,无额外元数据。Websocket 协议会区分 text 与 binary。 + +### 3.2 版本2 +使用 `BinaryProtocol2` 结构: +```c +struct BinaryProtocol2 { + uint16_t version; // 协议版本 + uint16_t type; // 消息类型 (0: OPUS, 1: JSON) + uint32_t reserved; // 保留字段 + uint32_t timestamp; // 时间戳(毫秒,用于服务器端AEC) + uint32_t payload_size; // 负载大小(字节) + uint8_t payload[]; // 负载数据 +} __attribute__((packed)); +``` + +### 3.3 版本3 +使用 `BinaryProtocol3` 结构: +```c +struct BinaryProtocol3 { + uint8_t type; // 消息类型 + uint8_t reserved; // 保留字段 + uint16_t payload_size; // 负载大小 + uint8_t payload[]; // 负载数据 +} __attribute__((packed)); +``` + +--- + +## 4. JSON 消息结构 + +WebSocket 文本帧以 JSON 方式传输,以下为常见的 `"type"` 字段及其对应业务逻辑。若消息里包含未列出的字段,可能为可选或特定实现细节。 + +### 4.1 设备端→服务器 + +1. **Hello** + - 连接成功后,由设备端发送,告知服务器基本参数。 + - 例: + ```json + { + "type": "hello", + "version": 1, + "features": { + "mcp": true + }, + "transport": "websocket", + "audio_params": { + "format": "opus", + "sample_rate": 16000, + "channels": 1, + "frame_duration": 60 + } + } + ``` + +2. **Listen** + - 表示设备端开始或停止录音监听。 + - 常见字段: + - `"session_id"`:会话标识 + - `"type": "listen"` + - `"state"`:`"start"`, `"stop"`, `"detect"`(唤醒检测已触发) + - `"mode"`:`"auto"`, `"manual"` 或 `"realtime"`,表示识别模式。 + - 例:开始监听 + ```json + { + "session_id": "xxx", + "type": "listen", + "state": "start", + "mode": "manual" + } + ``` + +3. **Abort** + - 终止当前说话(TTS 播放)或语音通道。 + - 例: + ```json + { + "session_id": "xxx", + "type": "abort", + "reason": "wake_word_detected" + } + ``` + - `reason` 值可为 `"wake_word_detected"` 或其他。 + +4. **Wake Word Detected** + - 用于设备端向服务器告知检测到唤醒词。 + - 在发送该消息之前,可提前发送唤醒词的 Opus 音频数据,用于服务器进行声纹检测。 + - 例: + ```json + { + "session_id": "xxx", + "type": "listen", + "state": "detect", + "text": "你好小明" + } + ``` + +5. **MCP** + - 推荐用于物联网控制的新一代协议。所有设备能力发现、工具调用等均通过 type: "mcp" 的消息进行,payload 内部为标准 JSON-RPC 2.0(详见 [MCP 协议文档](./mcp-protocol.md))。 + + - **设备端到服务器发送 result 的例子:** + ```json + { + "session_id": "xxx", + "type": "mcp", + "payload": { + "jsonrpc": "2.0", + "id": 1, + "result": { + "content": [ + { "type": "text", "text": "true" } + ], + "isError": false + } + } + } + ``` + +--- + +### 4.2 服务器→设备端 + +1. **Hello** + - 服务器端返回的握手确认消息。 + - 必须包含 `"type": "hello"` 和 `"transport": "websocket"`。 + - 可能会带有 `audio_params`,表示服务器期望的音频参数,或与设备端对齐的配置。 + - 服务器可选下发 `session_id` 字段,设备端收到后会自动记录。 + - 成功接收后设备端会设置事件标志,表示 WebSocket 通道就绪。 + +2. **STT** + - `{"session_id": "xxx", "type": "stt", "text": "..."}` + - 表示服务器端识别到了用户语音。(例如语音转文本结果) + - 设备可能将此文本显示到屏幕上,后续再进入回答等流程。 + +3. **LLM** + - `{"session_id": "xxx", "type": "llm", "emotion": "happy", "text": "😀"}` + - 服务器指示设备调整表情动画 / UI 表达。 + +4. **TTS** + - `{"session_id": "xxx", "type": "tts", "state": "start"}`:服务器准备下发 TTS 音频,设备端进入 "speaking" 播放状态。 + - `{"session_id": "xxx", "type": "tts", "state": "stop"}`:表示本次 TTS 结束。 + - `{"session_id": "xxx", "type": "tts", "state": "sentence_start", "text": "..."}` + - 让设备在界面上显示当前要播放或朗读的文本片段(例如用于显示给用户)。 + +5. **MCP** + - 服务器通过 type: "mcp" 的消息下发物联网相关的控制指令或返回调用结果,payload 结构同上。 + + - **服务器到设备端发送 tools/call 的例子:** + ```json + { + "session_id": "xxx", + "type": "mcp", + "payload": { + "jsonrpc": "2.0", + "method": "tools/call", + "params": { + "name": "self.light.set_rgb", + "arguments": { "r": 255, "g": 0, "b": 0 } + }, + "id": 1 + } + } + ``` + +6. **System** + - 系统控制命令,常用于远程升级更新。 + - 例: + ```json + { + "session_id": "xxx", + "type": "system", + "command": "reboot" + } + ``` + - 支持的命令: + - `"reboot"`:重启设备 + +7. **Custom**(可选) + - 自定义消息,当 `CONFIG_RECEIVE_CUSTOM_MESSAGE` 启用时支持。 + - 例: + ```json + { + "session_id": "xxx", + "type": "custom", + "payload": { + "message": "自定义内容" + } + } + ``` + +8. **音频数据:二进制帧** + - 当服务器发送音频二进制帧(Opus 编码)时,设备端解码并播放。 + - 若设备端正在处于 "listening" (录音)状态,收到的音频帧会被忽略或清空以防冲突。 + +--- + +## 5. 音频编解码 + +1. **设备端发送录音数据** + - 音频输入经过可能的回声消除、降噪或音量增益后,通过 Opus 编码打包为二进制帧发送给服务器。 + - 根据协议版本,可能直接发送 Opus 数据(版本1)或使用带元数据的二进制协议(版本2/3)。 + +2. **设备端播放收到的音频** + - 收到服务器的二进制帧时,同样认定是 Opus 数据。 + - 设备端会进行解码,然后交由音频输出接口播放。 + - 如果服务器的音频采样率与设备不一致,会在解码后再进行重采样。 + +--- + +## 6. 常见状态流转 + +以下为常见设备端关键状态流转,与 WebSocket 消息对应: + +1. **Idle** → **Connecting** + - 用户触发或唤醒后,设备调用 `OpenAudioChannel()` → 建立 WebSocket 连接 → 发送 `"type":"hello"`。 + +2. **Connecting** → **Listening** + - 成功建立连接后,若继续执行 `SendStartListening(...)`,则进入录音状态。此时设备会持续编码麦克风数据并发送到服务器。 + +3. **Listening** → **Speaking** + - 收到服务器 TTS Start 消息 (`{"type":"tts","state":"start"}`) → 停止录音并播放接收到的音频。 + +4. **Speaking** → **Idle** + - 服务器 TTS Stop (`{"type":"tts","state":"stop"}`) → 音频播放结束。若未继续进入自动监听,则返回 Idle;如果配置了自动循环,则再度进入 Listening。 + +5. **Listening** / **Speaking** → **Idle**(遇到异常或主动中断) + - 调用 `SendAbortSpeaking(...)` 或 `CloseAudioChannel()` → 中断会话 → 关闭 WebSocket → 状态回到 Idle。 + +### 自动模式状态流转图 + +```mermaid +stateDiagram + direction TB + [*] --> kDeviceStateUnknown + kDeviceStateUnknown --> kDeviceStateStarting:初始化 + kDeviceStateStarting --> kDeviceStateWifiConfiguring:配置WiFi + kDeviceStateStarting --> kDeviceStateActivating:激活设备 + kDeviceStateActivating --> kDeviceStateUpgrading:检测到新版本 + kDeviceStateActivating --> kDeviceStateIdle:激活完成 + kDeviceStateIdle --> kDeviceStateConnecting:开始连接 + kDeviceStateConnecting --> kDeviceStateIdle:连接失败 + kDeviceStateConnecting --> kDeviceStateListening:连接成功 + kDeviceStateListening --> kDeviceStateSpeaking:开始说话 + kDeviceStateSpeaking --> kDeviceStateListening:结束说话 + kDeviceStateListening --> kDeviceStateIdle:手动终止 + kDeviceStateSpeaking --> kDeviceStateIdle:自动终止 +``` + +### 手动模式状态流转图 + +```mermaid +stateDiagram + direction TB + [*] --> kDeviceStateUnknown + kDeviceStateUnknown --> kDeviceStateStarting:初始化 + kDeviceStateStarting --> kDeviceStateWifiConfiguring:配置WiFi + kDeviceStateStarting --> kDeviceStateActivating:激活设备 + kDeviceStateActivating --> kDeviceStateUpgrading:检测到新版本 + kDeviceStateActivating --> kDeviceStateIdle:激活完成 + kDeviceStateIdle --> kDeviceStateConnecting:开始连接 + kDeviceStateConnecting --> kDeviceStateIdle:连接失败 + kDeviceStateConnecting --> kDeviceStateListening:连接成功 + kDeviceStateIdle --> kDeviceStateListening:开始监听 + kDeviceStateListening --> kDeviceStateIdle:停止监听 + kDeviceStateIdle --> kDeviceStateSpeaking:开始说话 + kDeviceStateSpeaking --> kDeviceStateIdle:结束说话 +``` + +--- + +## 7. 错误处理 + +1. **连接失败** + - 如果 `Connect(url)` 返回失败或在等待服务器 "hello" 消息时超时,触发 `on_network_error_()` 回调。设备会提示"无法连接到服务"或类似错误信息。 + +2. **服务器断开** + - 如果 WebSocket 异常断开,回调 `OnDisconnected()`: + - 设备回调 `on_audio_channel_closed_()` + - 切换到 Idle 或其他重试逻辑。 + +--- + +## 8. 其它注意事项 + +1. **鉴权** + - 设备通过设置 `Authorization: Bearer ` 提供鉴权,服务器端需验证是否有效。 + - 如果令牌过期或无效,服务器可拒绝握手或在后续断开。 + +2. **会话控制** + - 代码中部分消息包含 `session_id`,用于区分独立的对话或操作。服务端可根据需要对不同会话做分离处理。 + +3. **音频负载** + - 代码里默认使用 Opus 格式,并设置 `sample_rate = 16000`,单声道。帧时长由 `OPUS_FRAME_DURATION_MS` 控制,一般为 60ms。可根据带宽或性能做适当调整。为了获得更好的音乐播放效果,服务器下行音频可能使用 24000 采样率。 + +4. **协议版本配置** + - 通过设置中的 `version` 字段配置二进制协议版本(1、2 或 3) + - 版本1:直接发送 Opus 数据 + - 版本2:使用带时间戳的二进制协议,适用于服务器端 AEC + - 版本3:使用简化的二进制协议 + +5. **物联网控制推荐 MCP 协议** + - 设备与服务器之间的物联网能力发现、状态同步、控制指令等,建议全部通过 MCP 协议(type: "mcp")实现。原有的 type: "iot" 方案已废弃。 + - MCP 协议可在 WebSocket、MQTT 等多种底层协议上传输,具备更好的扩展性和标准化能力。 + - 详细用法请参考 [MCP 协议文档](./mcp-protocol.md) 及 [MCP 物联网控制用法](./mcp-usage.md)。 + +6. **错误或异常 JSON** + - 当 JSON 中缺少必要字段,例如 `{"type": ...}`,设备端会记录错误日志(`ESP_LOGE(TAG, "Missing message type, data: %s", data);`),不会执行任何业务。 + +--- + +## 9. 消息示例 + +下面给出一个典型的双向消息示例(流程简化示意): + +1. **设备端 → 服务器**(握手) + ```json + { + "type": "hello", + "version": 1, + "features": { + "mcp": true + }, + "transport": "websocket", + "audio_params": { + "format": "opus", + "sample_rate": 16000, + "channels": 1, + "frame_duration": 60 + } + } + ``` + +2. **服务器 → 设备端**(握手应答) + ```json + { + "type": "hello", + "transport": "websocket", + "session_id": "xxx", + "audio_params": { + "format": "opus", + "sample_rate": 16000 + } + } + ``` + +3. **设备端 → 服务器**(开始监听) + ```json + { + "session_id": "xxx", + "type": "listen", + "state": "start", + "mode": "auto" + } + ``` + 同时设备端开始发送二进制帧(Opus 数据)。 + +4. **服务器 → 设备端**(ASR 结果) + ```json + { + "session_id": "xxx", + "type": "stt", + "text": "用户说的话" + } + ``` + +5. **服务器 → 设备端**(TTS开始) + ```json + { + "session_id": "xxx", + "type": "tts", + "state": "start" + } + ``` + 接着服务器发送二进制音频帧给设备端播放。 + +6. **服务器 → 设备端**(TTS结束) + ```json + { + "session_id": "xxx", + "type": "tts", + "state": "stop" + } + ``` + 设备端停止播放音频,若无更多指令,则回到空闲状态。 + +--- + +## 10. 总结 + +本协议通过在 WebSocket 上层传输 JSON 文本与二进制音频帧,完成功能包括音频流上传、TTS 音频播放、语音识别与状态管理、MCP 指令下发等。其核心特征: + +- **握手阶段**:发送 `"type":"hello"`,等待服务器返回。 +- **音频通道**:采用 Opus 编码的二进制帧双向传输语音流,支持多种协议版本。 +- **JSON 消息**:使用 `"type"` 为核心字段标识不同业务逻辑,包括 TTS、STT、MCP、WakeWord、System、Custom 等。 +- **扩展性**:可根据实际需求在 JSON 消息中添加字段,或在 headers 里进行额外鉴权。 + +服务器与设备端需提前约定各类消息的字段含义、时序逻辑以及错误处理规则,方能保证通信顺畅。上述信息可作为基础文档,便于后续对接、开发或扩展。 diff --git a/main/CMakeLists.txt b/main/CMakeLists.txt new file mode 100644 index 0000000..03ae1d8 --- /dev/null +++ b/main/CMakeLists.txt @@ -0,0 +1,1129 @@ +# Define source files +set(SOURCES "audio/audio_codec.cc" + "audio/audio_service.cc" + "audio/demuxer/ogg_demuxer.cc" + "audio/codecs/no_audio_codec.cc" + "audio/codecs/box_audio_codec.cc" + "audio/codecs/es8311_audio_codec.cc" + "audio/codecs/es8374_audio_codec.cc" + "audio/codecs/es8388_audio_codec.cc" + "audio/codecs/es8389_audio_codec.cc" + "audio/codecs/dummy_audio_codec.cc" + "audio/processors/audio_debugger.cc" + "led/single_led.cc" + "led/circular_strip.cc" + "led/gpio_led.cc" + "display/display.cc" + "display/lcd_display.cc" + "display/oled_display.cc" + "display/lvgl_display/lvgl_display.cc" + "display/emote_display.cc" + "display/lvgl_display/emoji_collection.cc" + "display/lvgl_display/lvgl_theme.cc" + "display/lvgl_display/lvgl_font.cc" + "display/lvgl_display/lvgl_image.cc" + "display/lvgl_display/gif/lvgl_gif.cc" + "display/lvgl_display/gif/gifdec.c" + "display/lvgl_display/jpg/image_to_jpeg.cpp" + "display/lvgl_display/jpg/jpeg_to_image.c" + "protocols/protocol.cc" + "protocols/mqtt_protocol.cc" + "protocols/websocket_protocol.cc" + "mcp_server.cc" + "system_info.cc" + "application.cc" + "ota.cc" + "settings.cc" + "device_state_machine.cc" + "assets.cc" + "main.cc" + ) + +set(INCLUDE_DIRS "." "display" "display/lvgl_display" "display/lvgl_display/jpg" "audio" "audio/demuxer" "protocols") + +# Add board common files +list(APPEND SOURCES + "boards/common/board.cc" + "boards/common/wifi_board.cc" + "boards/common/ml307_board.cc" + "boards/common/nt26_board.cc" + "boards/common/dual_network_board.cc" + "boards/common/adc_battery_monitor.cc" + "boards/common/afsk_demod.cc" + "boards/common/axp2101.cc" + "boards/common/backlight.cc" + "boards/common/button.cc" + "boards/common/i2c_device.cc" + "boards/common/knob.cc" + "boards/common/power_save_timer.cc" + "boards/common/press_to_talk_mcp_tool.cc" + "boards/common/sleep_timer.cc" + "boards/common/sy6970.cc" + "boards/common/system_reset.cc" +) +list(APPEND INCLUDE_DIRS ${CMAKE_CURRENT_SOURCE_DIR}/boards/common) + + +idf_build_get_property(build_components BUILD_COMPONENTS) +# Function to find component dynamically by pattern +function(find_component_by_pattern PATTERN COMPONENT_VAR PATH_VAR) + foreach(COMPONENT ${build_components}) + if(COMPONENT MATCHES "${PATTERN}") + set(${COMPONENT_VAR} ${COMPONENT} PARENT_SCOPE) + idf_component_get_property(COMPONENT_PATH ${COMPONENT} COMPONENT_DIR) + set(${PATH_VAR} "${COMPONENT_PATH}" PARENT_SCOPE) + break() + endif() + endforeach() +endfunction() + +# Set default BUILTIN_TEXT_FONT and BUILTIN_ICON_FONT +set(BUILTIN_TEXT_FONT font_puhui_14_1) +set(BUILTIN_ICON_FONT font_awesome_14_1) + +set(EMOTE_RESOLUTION "320_240") + +# Add board files according to BOARD_TYPE +# Set default assets if the board uses partition table V2 +if(CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI) + set(BOARD_TYPE "bread-compact-wifi") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_ML307) + set(BOARD_TYPE "bread-compact-ml307") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_NT26) + set(BOARD_TYPE "bread-compact-nt26") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_ESP32) + set(BOARD_TYPE "bread-compact-esp32") +elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_ESP32_LCD) + set(BOARD_TYPE "bread-compact-esp32-lcd") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_DF_K10) + set(BOARD_TYPE "df-k10") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_DF_S3_AI_CAM) + set(BOARD_TYPE "df-s3-ai-cam") +elseif(CONFIG_BOARD_TYPE_ESP_BOX_3) + set(BOARD_TYPE "esp-box-3") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) + set(EMOTE_RESOLUTION "320_240") +elseif(CONFIG_BOARD_TYPE_ESP_BOX) + set(BOARD_TYPE "esp-box") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) + set(EMOTE_RESOLUTION "320_240") +elseif(CONFIG_BOARD_TYPE_ESP_BOX_LITE) + set(BOARD_TYPE "esp-box-lite") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_KEVIN_BOX_2) + set(BOARD_TYPE "kevin-box-2") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_KEVIN_C3) + set(BOARD_TYPE "kevin-c3") +elseif(CONFIG_BOARD_TYPE_KEVIN_SP_V3_DEV) + set(BOARD_TYPE "kevin-sp-v3-dev") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_KEVIN_SP_V4_DEV) + set(BOARD_TYPE "kevin-sp-v4-dev") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_KEVIN_YUYING_313LCD) + set(BOARD_TYPE "kevin-yuying-313lcd") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_LICHUANG_DEV_S3) + set(BOARD_TYPE "lichuang-dev") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_LICHUANG_DEV_C3) + set(BOARD_TYPE "lichuang-c3-dev") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_EDA_TV_PRO) + set(MANUFACTURER "lceda-course-examples") + set(BOARD_TYPE "eda-tv-pro") +elseif(CONFIG_BOARD_TYPE_EDA_ROBOT_PRO) + set(MANUFACTURER "lceda-course-examples") + set(BOARD_TYPE "eda-robot-pro") + set(BUILTIN_TEXT_FONT font_puhui_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) +elseif(CONFIG_BOARD_TYPE_EDA_SUPER_BEAR) + set(MANUFACTURER "lceda-course-examples") + set(BOARD_TYPE "eda-super-bear") +elseif(CONFIG_BOARD_TYPE_MAGICLICK_S3_2P4) + set(BOARD_TYPE "magiclick-2p4") + set(BUILTIN_TEXT_FONT font_noto_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_64) +elseif(CONFIG_BOARD_TYPE_MAGICLICK_S3_2P5) + set(BOARD_TYPE "magiclick-2p5") + set(BUILTIN_TEXT_FONT font_noto_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_64) +elseif(CONFIG_BOARD_TYPE_MAGICLICK_C3) + set(BOARD_TYPE "magiclick-c3") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_MAGICLICK_C3_V2) + set(BOARD_TYPE "magiclick-c3-v2") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_M5STACK_CORE_S3) + set(BOARD_TYPE "m5stack-core-s3") + 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_M5STACK_CORE_TAB5) + set(BOARD_TYPE "m5stack-tab5") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_M5STACK_ATOM_S3_ECHO_BASE) + set(BOARD_TYPE "atoms3-echo-base") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_M5STACK_ATOM_S3R_ECHO_BASE) + set(BOARD_TYPE "atoms3r-echo-base") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_M5STACK_ATOM_S3R_CAM_M12_ECHO_BASE) + set(BOARD_TYPE "atoms3r-cam-m12-echo-base") +elseif(CONFIG_BOARD_TYPE_M5STACK_ATOM_ECHOS3R) + set(BOARD_TYPE "atom-echos3r") +elseif(CONFIG_BOARD_TYPE_M5STACK_CARDPUTER_ADV) + set(BOARD_TYPE "m5stack-cardputer-adv") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_M5STACK_ATOM_MATRIX_ECHO_BASE) + set(BOARD_TYPE "atommatrix-echo-base") +elseif(CONFIG_BOARD_TYPE_XMINI_C3_V3) + set(BOARD_TYPE "xmini-c3-v3") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_XMINI_C3_4G) + set(BOARD_TYPE "xmini-c3-4g") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_XMINI_C3) + set(BOARD_TYPE "xmini-c3") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_ESP_KORVO2_V3) + set(BOARD_TYPE "esp32s3-korvo2-v3") + 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_ESP_KORVO2_V3_RNDIS) + set(BOARD_TYPE "esp32s3-korvo2-v3-rndis") + 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_ESP_SPARKBOT) + set(BOARD_TYPE "esp-sparkbot") + 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_ESP_SPOT_S3) + set(BOARD_TYPE "esp-spot") +elseif(CONFIG_BOARD_TYPE_ESP_SPOT_C5) + set(BOARD_TYPE "esp-spot") +elseif(CONFIG_BOARD_TYPE_ESP_HI) + set(BOARD_TYPE "esp-hi") + # Set ESP_HI emoji directory for DEFAULT_ASSETS_EXTRA_FILES + set(DEFAULT_ASSETS_EXTRA_FILES "${CMAKE_BINARY_DIR}/emoji") +elseif(CONFIG_BOARD_TYPE_ESP_VOCAT) + set(BOARD_TYPE "esp-vocat") + set(BUILTIN_TEXT_FONT font_puhui_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) + set(EMOTE_RESOLUTION "360_360") + # set(EMOTE_EXTERNAL_PATH "${CMAKE_CURRENT_SOURCE_DIR}/boards/esp-vocat/assets") +elseif(CONFIG_BOARD_TYPE_ESP_SENSAIRSHUTTLE) + set(BOARD_TYPE "esp-sensairshuttle") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_TOUCH_LCD_3_5) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-touch-lcd-3.5") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_AUDIO_BOARD) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-audio-board") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_8) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-amoled-1.8") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_1_8) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-c6-touch-amoled-1.8") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_2_06) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-amoled-2.06") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_2_06) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-c6-touch-amoled-2.06") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4B) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-lcd-4b") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4_3C) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-lcd-4.3c") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_75) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-amoled-1.75") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_75C) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-amoled-1.75") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_83) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-lcd-1.83") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_85C) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-lcd-1.85c") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_85) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-lcd-1.85") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_46) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-lcd-1.46") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_5) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-lcd-3.5") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_5B) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-lcd-3.5b") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_ePaper_1_54_v1) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-epaper-1.54") +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_ePaper_1_54_v2) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-epaper-1.54") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_RLCD_4_2) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-rlcd-4.2") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_49) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-lcd-3.49") + set(LVGL_TEXT_FONT font_puhui_basic_30_4) + set(LVGL_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_LCD_1_69) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-c6-lcd-1.69") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_LCD_1_83) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-c6-touch-lcd-1.83") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_1_43) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-c6-touch-amoled-1.43") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_C6_TOUCH_AMOLED_1_32) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-c6-touch-amoled-1.32") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_32) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-s3-touch-amoled-1.32") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_NANO) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-p4-nano") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4B) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7B) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_3_4C) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4C) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_8) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_10_1) + set(MANUFACTURER "waveshare") + set(BOARD_TYPE "esp32-p4-wifi6-touch-lcd") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_ESP_P4_FUNCTION_EV_BOARD) + set(BOARD_TYPE "esp-p4-function-ev-board") +elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI_LCD) + set(BOARD_TYPE "bread-compact-wifi-lcd") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_TUDOUZI) + set(BOARD_TYPE "tudouzi") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_LILYGO_T_CIRCLE_S3) + set(BOARD_TYPE "lilygo-t-circle-s3") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_LILYGO_T_CAMERAPLUS_S3_V1_0_V1_1) + set(BOARD_TYPE "lilygo-t-cameraplus-s3") + 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_LILYGO_T_CAMERAPLUS_S3_V1_2) + set(BOARD_TYPE "lilygo-t-cameraplus-s3") + 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_LILYGO_T_DISPLAY_S3_PRO_MVSRLORA) + set(BOARD_TYPE "lilygo-t-display-s3-pro-mvsrlora") + 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_LILYGO_T_DISPLAY_S3_PRO_MVSRLORA_NO_BATTERY) + set(BOARD_TYPE "lilygo-t-display-s3-pro-mvsrlora") + 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_LILYGO_T_DISPLAY_P4) + set(BOARD_TYPE "lilygo-t-display-p4") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_MOVECALL_MOJI_ESP32S3) + set(BOARD_TYPE "movecall-moji-esp32s3") + 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_MOVECALL_MOJI2_ESP32C5) + set(BOARD_TYPE "movecall-moji2-esp32c5") + 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_MOVECALL_CUICAN_ESP32S3) + set(BOARD_TYPE "movecall-cuican-esp32s3") + 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_ATK_DNESP32S3) + set(BOARD_TYPE "atk-dnesp32s3") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3_BOX) + set(BOARD_TYPE "atk-dnesp32s3-box") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3_BOX0) + set(BOARD_TYPE "atk-dnesp32s3-box0") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3_BOX2_WIFI) + set(BOARD_TYPE "atk-dnesp32s3-box2-wifi") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3_BOX2_4G) + set(BOARD_TYPE "atk-dnesp32s3-box2-4g") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3M_WIFI) + set(BOARD_TYPE "atk-dnesp32s3m-wifi") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_ATK_DNESP32S3M_4G) + set(BOARD_TYPE "atk-dnesp32s3m-4g") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_DU_CHATX) + set(BOARD_TYPE "du-chatx") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_TAIJI_PI_S3) + set(BOARD_TYPE "taiji-pi-s3") + 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_XINGZHI_CUBE_0_85TFT_WIFI) + set(BOARD_TYPE "xingzhi-cube-0.85tft-wifi") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_XINGZHI_CUBE_0_85TFT_ML307) + set(BOARD_TYPE "xingzhi-cube-0.85tft-ml307") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_XINGZHI_CUBE_0_96OLED_WIFI) + set(BOARD_TYPE "xingzhi-cube-0.96oled-wifi") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_XINGZHI_CUBE_0_96OLED_ML307) + set(BOARD_TYPE "xingzhi-cube-0.96oled-ml307") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_XINGZHI_CUBE_1_54TFT_WIFI) + set(BOARD_TYPE "xingzhi-cube-1.54tft-wifi") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_XINGZHI_CUBE_1_54TFT_ML307) + set(BOARD_TYPE "xingzhi-cube-1.54tft-ml307") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_XINGZHI_METAL_1_54_WIFI) + set(BOARD_TYPE "xingzhi-metal-1.54-wifi") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_XINGZHI_ABS_2_0) + set(BOARD_TYPE "xingzhi-abs-2.0") + set(BUILTIN_TEXT_FONT font_noto_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_SEEED_STUDIO_SENSECAP_WATCHER) + set(BOARD_TYPE "sensecap-watcher") + set(BUILTIN_TEXT_FONT font_noto_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION noto-emoji_128) +elseif(CONFIG_BOARD_TYPE_DOIT_S3_AIBOX) + set(BOARD_TYPE "doit-s3-aibox") +elseif(CONFIG_BOARD_TYPE_MIXGO_NOVA) + set(BOARD_TYPE "mixgo-nova") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_GENJUTECH_S3_1_54TFT) + set(BOARD_TYPE "genjutech-s3-1.54tft") + 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_CGC) + set(BOARD_TYPE "esp32-cgc") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_CGC_144) + set(BOARD_TYPE "esp32-cgc-144") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +elseif(CONFIG_BOARD_TYPE_ESP_S3_LCD_EV_Board) + set(BOARD_TYPE "esp-s3-lcd-ev-board") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_ESP_S3_LCD_EV_Board_2) + set(BOARD_TYPE "esp-s3-lcd-ev-board-2") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_ZHENGCHEN_1_54TFT_WIFI) + set(BOARD_TYPE "zhengchen-1.54tft-wifi") + 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_MINSI_K08_DUAL) + set(BOARD_TYPE "minsi-k08-dual") + 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_ZHENGCHEN_1_54TFT_ML307) + set(BOARD_TYPE "zhengchen-1.54tft-ml307") + 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_ZHENGCHEN_CAM) + set(BOARD_TYPE "zhengchen-cam") + 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_ZHENGCHEN_CAM_ML307) + set(BOARD_TYPE "zhengchen-cam-ml307") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_SPOTPEAR_ESP32_S3_1_54_MUMA) + set(BOARD_TYPE "sp-esp32-s3-1.54-muma") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_SPOTPEAR_ESP32_S3_1_28_BOX) + set(BOARD_TYPE "sp-esp32-s3-1.28-box") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_OTTO_ROBOT) + set(BOARD_TYPE "otto-robot") + set(BUILTIN_TEXT_FONT font_puhui_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION otto-gif) +elseif(CONFIG_BOARD_TYPE_ELECTRON_BOT) + set(BOARD_TYPE "electron-bot") + set(BUILTIN_TEXT_FONT font_puhui_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION otto-gif) +elseif(CONFIG_BOARD_TYPE_BREAD_COMPACT_WIFI_CAM) + set(BOARD_TYPE "bread-compact-wifi-s3cam") + set(BUILTIN_TEXT_FONT font_puhui_basic_16_4) + set(BUILTIN_ICON_FONT font_awesome_16_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_JIUCHUAN) + set(BOARD_TYPE "jiuchuan-s3") + 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_LABPLUS_MPYTHON_V3) + set(BOARD_TYPE "labplus-mpython-v3") + 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_LABPLUS_LEDONG_V2) + set(BOARD_TYPE "labplus-ledong-v2") + 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_SURFER_C3_1_14TFT) + set(BOARD_TYPE "surfer-c3-1.14tft") + set(BUILTIN_TEXT_FONT font_puhui_basic_20_4) + set(BUILTIN_ICON_FONT font_awesome_20_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_YUNLIAO_S3) + set(BOARD_TYPE "yunliao-s3") + 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_WIRELESS_TAG_WTP4C5MP07S) + set(BOARD_TYPE "wireless-tag-wtp4c5mp07s") + set(BUILTIN_TEXT_FONT font_puhui_basic_30_4) + set(BUILTIN_ICON_FONT font_awesome_30_4) + set(DEFAULT_EMOJI_COLLECTION twemoji_64) +elseif(CONFIG_BOARD_TYPE_AIPI_LITE) + set(BOARD_TYPE "aipi-lite") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) + set(DEFAULT_EMOJI_COLLECTION twemoji_32) +elseif(CONFIG_BOARD_TYPE_HU_087) + set(BOARD_TYPE "hu-087") + set(BUILTIN_TEXT_FONT font_puhui_basic_14_1) + set(BUILTIN_ICON_FONT font_awesome_14_1) +endif() + +if(MANUFACTURER) + file(GLOB BOARD_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/boards/${MANUFACTURER}/${BOARD_TYPE}/*.cc + ${CMAKE_CURRENT_SOURCE_DIR}/boards/${MANUFACTURER}/${BOARD_TYPE}/*.c + ) +else() + file(GLOB BOARD_SOURCES + ${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_TYPE}/*.cc + ${CMAKE_CURRENT_SOURCE_DIR}/boards/${BOARD_TYPE}/*.c + ) +endif() +list(APPEND SOURCES ${BOARD_SOURCES}) + +# Select audio processor according to Kconfig +if(CONFIG_USE_AUDIO_PROCESSOR) + list(APPEND SOURCES "audio/processors/afe_audio_processor.cc") +else() + list(APPEND SOURCES "audio/processors/no_audio_processor.cc") +endif() +if(CONFIG_IDF_TARGET_ESP32S3 OR CONFIG_IDF_TARGET_ESP32P4) + list(APPEND SOURCES "audio/wake_words/afe_wake_word.cc") + list(APPEND SOURCES "audio/wake_words/custom_wake_word.cc") +else() + list(APPEND SOURCES "audio/wake_words/esp_wake_word.cc") +endif() + +# Auto Select Additional Sources +if (CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING) + list(APPEND SOURCES "boards/common/blufi.cpp") +endif () +# Select language directory according to Kconfig +if(CONFIG_LANGUAGE_ZH_CN) + set(LANG_DIR "zh-CN") +elseif(CONFIG_LANGUAGE_ZH_TW) + set(LANG_DIR "zh-TW") +elseif(CONFIG_LANGUAGE_EN_US) + set(LANG_DIR "en-US") +elseif(CONFIG_LANGUAGE_JA_JP) + set(LANG_DIR "ja-JP") +elseif(CONFIG_LANGUAGE_KO_KR) + set(LANG_DIR "ko-KR") +elseif(CONFIG_LANGUAGE_VI_VN) + set(LANG_DIR "vi-VN") +elseif(CONFIG_LANGUAGE_TH_TH) + set(LANG_DIR "th-TH") +elseif(CONFIG_LANGUAGE_DE_DE) + set(LANG_DIR "de-DE") +elseif(CONFIG_LANGUAGE_FR_FR) + set(LANG_DIR "fr-FR") +elseif(CONFIG_LANGUAGE_ES_ES) + set(LANG_DIR "es-ES") +elseif(CONFIG_LANGUAGE_IT_IT) + set(LANG_DIR "it-IT") +elseif(CONFIG_LANGUAGE_RU_RU) + set(LANG_DIR "ru-RU") +elseif(CONFIG_LANGUAGE_AR_SA) + set(LANG_DIR "ar-SA") +elseif(CONFIG_LANGUAGE_HI_IN) + set(LANG_DIR "hi-IN") +elseif(CONFIG_LANGUAGE_PT_PT) + set(LANG_DIR "pt-PT") +elseif(CONFIG_LANGUAGE_PL_PL) + set(LANG_DIR "pl-PL") +elseif(CONFIG_LANGUAGE_CS_CZ) + set(LANG_DIR "cs-CZ") +elseif(CONFIG_LANGUAGE_FI_FI) + set(LANG_DIR "fi-FI") +elseif(CONFIG_LANGUAGE_TR_TR) + set(LANG_DIR "tr-TR") +elseif(CONFIG_LANGUAGE_ID_ID) + set(LANG_DIR "id-ID") +elseif(CONFIG_LANGUAGE_UK_UA) + set(LANG_DIR "uk-UA") +elseif(CONFIG_LANGUAGE_RO_RO) + set(LANG_DIR "ro-RO") +elseif(CONFIG_LANGUAGE_BG_BG) + set(LANG_DIR "bg-BG") +elseif(CONFIG_LANGUAGE_CA_ES) + set(LANG_DIR "ca-ES") +elseif(CONFIG_LANGUAGE_DA_DK) + set(LANG_DIR "da-DK") +elseif(CONFIG_LANGUAGE_EL_GR) + set(LANG_DIR "el-GR") +elseif(CONFIG_LANGUAGE_FA_IR) + set(LANG_DIR "fa-IR") +elseif(CONFIG_LANGUAGE_FIL_PH) + set(LANG_DIR "fil-PH") +elseif(CONFIG_LANGUAGE_HE_IL) + set(LANG_DIR "he-IL") +elseif(CONFIG_LANGUAGE_HR_HR) + set(LANG_DIR "hr-HR") +elseif(CONFIG_LANGUAGE_HU_HU) + set(LANG_DIR "hu-HU") +elseif(CONFIG_LANGUAGE_MS_MY) + set(LANG_DIR "ms-MY") +elseif(CONFIG_LANGUAGE_NB_NO) + set(LANG_DIR "nb-NO") +elseif(CONFIG_LANGUAGE_NL_NL) + set(LANG_DIR "nl-NL") +elseif(CONFIG_LANGUAGE_SK_SK) + set(LANG_DIR "sk-SK") +elseif(CONFIG_LANGUAGE_SL_SI) + set(LANG_DIR "sl-SI") +elseif(CONFIG_LANGUAGE_SV_SE) + set(LANG_DIR "sv-SE") +elseif(CONFIG_LANGUAGE_SR_RS) + set(LANG_DIR "sr-RS") +endif() + +# Define generation path +set(LANG_JSON "${CMAKE_CURRENT_SOURCE_DIR}/assets/locales/${LANG_DIR}/language.json") +set(LANG_HEADER "${CMAKE_CURRENT_SOURCE_DIR}/assets/lang_config.h") + +# Collect current language audio files +file(GLOB LANG_SOUNDS ${CMAKE_CURRENT_SOURCE_DIR}/assets/locales/${LANG_DIR}/*.ogg) + +# If not en-US, collect en-US audio files as fallback for missing files +if(NOT LANG_DIR STREQUAL "en-US") + file(GLOB EN_US_SOUNDS ${CMAKE_CURRENT_SOURCE_DIR}/assets/locales/en-US/*.ogg) + + # Extract filenames (without path) from current language + set(EXISTING_NAMES "") + foreach(SOUND_FILE ${LANG_SOUNDS}) + get_filename_component(FILENAME ${SOUND_FILE} NAME) + list(APPEND EXISTING_NAMES ${FILENAME}) + endforeach() + + # Only add en-US audio files that are missing in current language + foreach(EN_SOUND ${EN_US_SOUNDS}) + get_filename_component(FILENAME ${EN_SOUND} NAME) + if(NOT ${FILENAME} IN_LIST EXISTING_NAMES) + list(APPEND LANG_SOUNDS ${EN_SOUND}) + message(STATUS "Using en-US fallback for missing audio: ${FILENAME}") + endif() + endforeach() +endif() + +file(GLOB COMMON_SOUNDS ${CMAKE_CURRENT_SOURCE_DIR}/assets/common/*.ogg) + +# If target chip is ESP32, exclude specific files to avoid build errors +if(CONFIG_IDF_TARGET_ESP32) + list(REMOVE_ITEM SOURCES "audio/codecs/box_audio_codec.cc" + "audio/codecs/es8388_audio_codec.cc" + "audio/codecs/es8389_audio_codec.cc" + "led/gpio_led.cc" + "display/lvgl_display/jpg/image_to_jpeg.cpp" + "display/lvgl_display/jpg/jpeg_to_image.c" + "boards/common/nt26_board.cc" + "boards/common/ml307_board.cc" + "boards/common/dual_network_board.cc" + ) +endif() + +# Include EspVideo if target is ESP32S3 or ESP32P4 +if(CONFIG_IDF_TARGET_ESP32S3 OR CONFIG_IDF_TARGET_ESP32P4) + list(APPEND SOURCES "boards/common/esp_video.cc" + "boards/common/rndis_board.cc" + ) +endif() + +# Include Esp32Camera if target is ESP32S3 +if(CONFIG_IDF_TARGET_ESP32S3) + list(APPEND SOURCES "boards/common/esp32_camera.cc") +endif() + +idf_component_register(SRCS ${SOURCES} + EMBED_FILES ${LANG_SOUNDS} ${COMMON_SOUNDS} + INCLUDE_DIRS ${INCLUDE_DIRS} + WHOLE_ARCHIVE + PRIV_REQUIRES + esp_pm + esp_psram + esp_netif + esp_driver_gpio + esp_driver_uart + esp_driver_spi + esp_driver_i2c + esp_driver_i2s + esp_driver_jpeg + esp_driver_ppa + esp_app_format + app_update + spi_flash + console + efuse + bt + fatfs + ) + +# Use target_compile_definitions to define BOARD_TYPE, BOARD_NAME +# If BOARD_NAME is empty, use BOARD_TYPE +if(NOT BOARD_NAME) + set(BOARD_NAME ${BOARD_TYPE}) +endif() +target_compile_definitions(${COMPONENT_LIB} + PRIVATE BOARD_TYPE=\"${BOARD_TYPE}\" BOARD_NAME=\"${BOARD_NAME}\" + PRIVATE BUILTIN_TEXT_FONT=${BUILTIN_TEXT_FONT} BUILTIN_ICON_FONT=${BUILTIN_ICON_FONT} + ) + +# Add generation rules +add_custom_command( + OUTPUT ${LANG_HEADER} + COMMAND python ${PROJECT_DIR}/scripts/gen_lang.py + --language "${LANG_DIR}" + --output "${LANG_HEADER}" + DEPENDS + ${LANG_JSON} + ${PROJECT_DIR}/scripts/gen_lang.py + COMMENT "Generating ${LANG_DIR} language config" +) + +# Force build generation dependencies +add_custom_target(lang_header ALL + DEPENDS ${LANG_HEADER} +) + +# Find ESP-SR component dynamically +find_component_by_pattern("espressif__esp-sr" ESP_SR_COMPONENT ESP_SR_COMPONENT_PATH) +if(ESP_SR_COMPONENT_PATH) + set(ESP_SR_MODEL_PATH "${ESP_SR_COMPONENT_PATH}/model") +endif() + +# Find xiaozhi-fonts component dynamically +find_component_by_pattern("xiaozhi-fonts" XIAOZHI_FONTS_COMPONENT XIAOZHI_FONTS_COMPONENT_PATH) +if(XIAOZHI_FONTS_COMPONENT_PATH) + set(XIAOZHI_FONTS_PATH "${XIAOZHI_FONTS_COMPONENT_PATH}") +endif() + +if(CONFIG_BOARD_TYPE_ESP_HI) +set(URL "https://github.com/espressif2022/image_player/raw/main/test_apps/test_8bit") +set(EMOJI_DIR "${CMAKE_BINARY_DIR}/emoji") +file(MAKE_DIRECTORY ${EMOJI_DIR}) + +# List all files to download +set(FILES_TO_DOWNLOAD "") +list(APPEND FILES_TO_DOWNLOAD "Anger_enter.aaf" "Anger_loop.aaf" "Anger_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "happy_enter.aaf" "happy_loop.aaf" "happ_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "sad_enter.aaf" "sad_loop.aaf" "sad_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "scorn_enter.aaf" "scorn_loop.aaf" "scorn_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "left_enter.aaf" "left_loop.aaf" "left_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "right_enter.aaf" "right_loop.aaf" "right_return.aaf") +list(APPEND FILES_TO_DOWNLOAD "asking.aaf" "blink_once.aaf" "blink_quick.aaf") +list(APPEND FILES_TO_DOWNLOAD "connecting.aaf" "panic_enter.aaf" "panic_loop.aaf") +list(APPEND FILES_TO_DOWNLOAD "panic_return.aaf" "wake.aaf") + +foreach(FILENAME IN LISTS FILES_TO_DOWNLOAD) + set(REMOTE_FILE "${URL}/${FILENAME}") + set(LOCAL_FILE "${EMOJI_DIR}/${FILENAME}") + + # Check if local file exists + if(EXISTS ${LOCAL_FILE}) + message(STATUS "File ${FILENAME} already exists, skipping download") + else() + message(STATUS "Downloading ${FILENAME}") + file(DOWNLOAD ${REMOTE_FILE} ${LOCAL_FILE} + STATUS DOWNLOAD_STATUS) + list(GET DOWNLOAD_STATUS 0 STATUS_CODE) + if(NOT STATUS_CODE EQUAL 0) + message(FATAL_ERROR "Failed to download ${FILENAME} from ${URL}") + endif() + endif() +endforeach() + +endif() + + +# Function to build default assets based on configuration +function(build_default_assets_bin) + # Set output path for generated assets.bin + set(GENERATED_ASSETS_BIN "${CMAKE_BINARY_DIR}/generated_assets.bin") + + # Prepare arguments for build script + set(BUILD_ARGS + "--sdkconfig" "${SDKCONFIG}" + "--output" "${GENERATED_ASSETS_BIN}" + ) + + # Add builtin text font if defined + if(BUILTIN_TEXT_FONT) + list(APPEND BUILD_ARGS "--builtin_text_font" "${BUILTIN_TEXT_FONT}") + endif() + + # Add default emoji collection if defined + if(DEFAULT_EMOJI_COLLECTION) + list(APPEND BUILD_ARGS "--emoji_collection" "${DEFAULT_EMOJI_COLLECTION}") + endif() + + # Add default assets extra files if defined + if(DEFAULT_ASSETS_EXTRA_FILES) + list(APPEND BUILD_ARGS "--extra_files" "${DEFAULT_ASSETS_EXTRA_FILES}") + endif() + + list(APPEND BUILD_ARGS "--esp_sr_model_path" "${ESP_SR_MODEL_PATH}") + list(APPEND BUILD_ARGS "--xiaozhi_fonts_path" "${XIAOZHI_FONTS_PATH}") + + # Create custom command to build assets + add_custom_command( + OUTPUT ${GENERATED_ASSETS_BIN} + COMMAND python ${PROJECT_DIR}/scripts/build_default_assets.py ${BUILD_ARGS} + DEPENDS + ${SDKCONFIG} + ${PROJECT_DIR}/scripts/build_default_assets.py + COMMENT "Building default assets.bin based on configuration" + VERBATIM + ) + + # Create target for generated assets + add_custom_target(generated_default_assets ALL + DEPENDS ${GENERATED_ASSETS_BIN} + ) + + # Set the generated file path in parent scope + set(GENERATED_ASSETS_LOCAL_FILE ${GENERATED_ASSETS_BIN} PARENT_SCOPE) + + message(STATUS "Default assets build configured: ${GENERATED_ASSETS_BIN}") +endfunction() + + +# Function to get local assets file path (handles both URL and local file) +function(get_assets_local_file assets_source assets_local_file_var) + # Check if it's a URL (starts with http:// or https://) + if(assets_source MATCHES "^https?://") + # It's a URL, download it + get_filename_component(ASSETS_FILENAME "${assets_source}" NAME) + set(ASSETS_LOCAL_FILE "${CMAKE_BINARY_DIR}/${ASSETS_FILENAME}") + set(ASSETS_TEMP_FILE "${CMAKE_BINARY_DIR}/${ASSETS_FILENAME}.tmp") + + # Check if local file exists + if(EXISTS ${ASSETS_LOCAL_FILE}) + message(STATUS "Assets file ${ASSETS_FILENAME} already exists, skipping download") + else() + message(STATUS "Downloading ${ASSETS_FILENAME}") + + # Clean up any existing temp file + if(EXISTS ${ASSETS_TEMP_FILE}) + file(REMOVE ${ASSETS_TEMP_FILE}) + endif() + + # Download to temporary file first + file(DOWNLOAD ${assets_source} ${ASSETS_TEMP_FILE} + STATUS DOWNLOAD_STATUS) + list(GET DOWNLOAD_STATUS 0 STATUS_CODE) + if(NOT STATUS_CODE EQUAL 0) + # Clean up temp file on failure + if(EXISTS ${ASSETS_TEMP_FILE}) + file(REMOVE ${ASSETS_TEMP_FILE}) + endif() + message(FATAL_ERROR "Failed to download ${ASSETS_FILENAME} from ${assets_source}") + endif() + + # Move temp file to final location (atomic operation) + file(RENAME ${ASSETS_TEMP_FILE} ${ASSETS_LOCAL_FILE}) + message(STATUS "Successfully downloaded ${ASSETS_FILENAME}") + endif() + else() + # It's a local file path + if(IS_ABSOLUTE "${assets_source}") + set(ASSETS_LOCAL_FILE "${assets_source}") + else() + set(ASSETS_LOCAL_FILE "${CMAKE_CURRENT_SOURCE_DIR}/${assets_source}") + endif() + + # Check if local file exists + if(NOT EXISTS ${ASSETS_LOCAL_FILE}) + message(FATAL_ERROR "Assets file not found: ${ASSETS_LOCAL_FILE}") + endif() + + message(STATUS "Using assets file: ${ASSETS_LOCAL_FILE}") + endif() + + set(${assets_local_file_var} ${ASSETS_LOCAL_FILE} PARENT_SCOPE) +endfunction() + + +partition_table_get_partition_info(size "--partition-name assets" "size") +partition_table_get_partition_info(offset "--partition-name assets" "offset") +if ("${size}" AND "${offset}") + # Flash assets based on configuration + if(CONFIG_FLASH_DEFAULT_ASSETS) + # Build default assets based on configuration + build_default_assets_bin() + esptool_py_flash_to_partition(flash "assets" "${GENERATED_ASSETS_LOCAL_FILE}") + message(STATUS "Generated default assets flash configured: ${GENERATED_ASSETS_LOCAL_FILE} -> assets partition") + elseif(CONFIG_FLASH_CUSTOM_ASSETS) + # Flash custom assets + get_assets_local_file("${CONFIG_CUSTOM_ASSETS_FILE}" ASSETS_LOCAL_FILE) + esptool_py_flash_to_partition(flash "assets" "${ASSETS_LOCAL_FILE}") + message(STATUS "Custom assets flash configured: ${ASSETS_LOCAL_FILE} -> assets partition") + elseif(CONFIG_FLASH_EXPRESSION_ASSETS) + set(ASSETS_NAME "expression_assets") + set(ASSETS_PARTITION "assets") + set(ASSETS_FILE "${CMAKE_BINARY_DIR}/${ASSETS_NAME}.bin") + + build_speaker_assets_bin("${ASSETS_PARTITION}" ${EMOTE_RESOLUTION} ${ASSETS_FILE} ${CONFIG_MMAP_FILE_NAME_LENGTH}) + message(STATUS "Generated emote assets: ${ASSETS_FILE} -> ${ASSETS_PARTITION} partition") + esptool_py_flash_to_partition(flash "${ASSETS_PARTITION}" "${ASSETS_FILE}") + elseif(CONFIG_FLASH_NONE_ASSETS) + message(STATUS "Assets flashing disabled (FLASH_NONE_ASSETS)") + endif() +else() + message(STATUS "Assets partition not found, using v1 partition table") +endif() diff --git a/main/Kconfig.projbuild b/main/Kconfig.projbuild new file mode 100644 index 0000000..0f8f77a --- /dev/null +++ b/main/Kconfig.projbuild @@ -0,0 +1,525 @@ +menu "Xiaozhi Assistant" + +config OTA_URL + string "Default OTA URL" + default "https://api.tenclass.net/xiaozhi/ota/" + help + The application will access this URL to check for new firmwares and server address. + +choice + prompt "Flash Assets" + default FLASH_DEFAULT_ASSETS if !USE_EMOTE_MESSAGE_STYLE + default FLASH_EXPRESSION_ASSETS if USE_EMOTE_MESSAGE_STYLE + help + Select the assets to flash. + + config FLASH_NONE_ASSETS + bool "Do not flash assets" + config FLASH_DEFAULT_ASSETS + bool "Flash Default Assets" + depends on !USE_EMOTE_MESSAGE_STYLE + config FLASH_CUSTOM_ASSETS + bool "Flash Custom Assets" + config FLASH_EXPRESSION_ASSETS + bool "Flash Emote Assets" + depends on USE_EMOTE_MESSAGE_STYLE +endchoice + +config CUSTOM_ASSETS_FILE + depends on FLASH_CUSTOM_ASSETS + string "Custom Assets File" + default "assets.bin" + help + The custom assets file to flash. + It can be a local file relative to the project directory or a remote url. + +choice + prompt "Default Language" + default LANGUAGE_ZH_CN + help + Select device display language + + config LANGUAGE_ZH_CN + bool "Chinese" + config LANGUAGE_ZH_TW + bool "Chinese Traditional" + config LANGUAGE_EN_US + bool "English" + config LANGUAGE_JA_JP + bool "Japanese" + config LANGUAGE_KO_KR + bool "Korean" + config LANGUAGE_VI_VN + bool "Vietnamese" + config LANGUAGE_TH_TH + bool "Thai" + config LANGUAGE_DE_DE + bool "German" + config LANGUAGE_FR_FR + bool "French" + config LANGUAGE_ES_ES + bool "Spanish" + config LANGUAGE_IT_IT + bool "Italian" + config LANGUAGE_RU_RU + bool "Russian" + config LANGUAGE_AR_SA + bool "Arabic" + config LANGUAGE_HI_IN + bool "Hindi" + config LANGUAGE_PT_PT + bool "Portuguese" + config LANGUAGE_PL_PL + bool "Polish" + config LANGUAGE_CS_CZ + bool "Czech" + config LANGUAGE_FI_FI + bool "Finnish" + config LANGUAGE_TR_TR + bool "Turkish" + config LANGUAGE_ID_ID + bool "Indonesian" + config LANGUAGE_UK_UA + bool "Ukrainian" + config LANGUAGE_RO_RO + bool "Romanian" + config LANGUAGE_BG_BG + bool "Bulgarian" + config LANGUAGE_CA_ES + bool "Catalan" + config LANGUAGE_DA_DK + bool "Danish" + config LANGUAGE_EL_GR + bool "Greek" + config LANGUAGE_FA_IR + bool "Persian" + config LANGUAGE_FIL_PH + bool "Filipino" + config LANGUAGE_HE_IL + bool "Hebrew" + config LANGUAGE_HR_HR + bool "Croatian" + config LANGUAGE_HU_HU + bool "Hungarian" + config LANGUAGE_MS_MY + bool "Malay" + config LANGUAGE_NB_NO + bool "Norwegian" + config LANGUAGE_NL_NL + bool "Dutch" + config LANGUAGE_SK_SK + bool "Slovak" + config LANGUAGE_SL_SI + bool "Slovenian" + config LANGUAGE_SV_SE + bool "Swedish" + config LANGUAGE_SR_RS + bool "Serbian" +endchoice + +choice BOARD_TYPE + prompt "Board Type" + default BOARD_TYPE_BREAD_COMPACT_WIFI + help + Board type. 开发板类型 + config BOARD_TYPE_BREAD_COMPACT_WIFI + bool "Bread Compact WiFi (面包板)" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_ZHENGCHEN_CAM + bool "幻鲲科技AI Camera" + depends on IDF_TARGET_ESP32S3 + config BOARD_TYPE_HU_087 + bool "HU-087" + depends on IDF_TARGET_ESP32S3 +endchoice + +choice + depends on BOARD_TYPE_LILYGO_T_DISPLAY_P4 + prompt "Select the screen type" + default SCREEN_TYPE_HI8561 + config SCREEN_TYPE_HI8561 + bool "HI8561" + config SCREEN_TYPE_RM69A10 + bool "RM69A10" +endchoice + +choice + depends on BOARD_TYPE_LILYGO_T_DISPLAY_P4 + prompt "Select the color format of the screen" + default SCREEN_PIXEL_FORMAT_RGB565 + config SCREEN_PIXEL_FORMAT_RGB565 + bool "RGB565" + config SCREEN_PIXEL_FORMAT_RGB888 + bool "RGB888" +endchoice + +choice ESP_S3_LCD_EV_Board_Version_TYPE + depends on BOARD_TYPE_ESP_S3_LCD_EV_Board + prompt "EV_BOARD Type" + default ESP_S3_LCD_EV_Board_1p4 + config ESP_S3_LCD_EV_Board_1p4 + bool "乐鑫ESP32_S3_LCD_EV_Board-MB_V1.4" + config ESP_S3_LCD_EV_Board_1p5 + bool "乐鑫ESP32_S3_LCD_EV_Board-MB_V1.5" +endchoice + +choice DISPLAY_OLED_TYPE + depends on BOARD_TYPE_BREAD_COMPACT_WIFI || BOARD_TYPE_BREAD_COMPACT_ML307 || BOARD_TYPE_BREAD_COMPACT_NT26 || BOARD_TYPE_BREAD_COMPACT_ESP32 || BOARD_TYPE_HU_087 + prompt "OLED Type" + default OLED_SSD1306_128X32 + help + OLED Monochrome Display Type + config OLED_SSD1306_128X32 + bool "SSD1306 128*32" + config OLED_SSD1306_128X64 + bool "SSD1306 128*64" + config OLED_SH1106_128X64 + bool "SH1106 128*64" +endchoice + +choice DISPLAY_LCD_TYPE + depends on BOARD_TYPE_BREAD_COMPACT_WIFI_LCD || BOARD_TYPE_BREAD_COMPACT_ESP32_LCD || BOARD_TYPE_CGC || BOARD_TYPE_BREAD_COMPACT_WIFI_CAM + prompt "LCD Type" + default LCD_ST7789_240X320 + help + LCD Display Type + config LCD_ST7789_240X320 + bool "ST7789 240*320, IPS" + config LCD_ST7789_240X320_NO_IPS + bool "ST7789 240*320, Non-IPS" + config LCD_ST7789_170X320 + bool "ST7789 170*320" + config LCD_ST7789_172X320 + bool "ST7789 172*320" + config LCD_ST7789_240X280 + bool "ST7789 240*280" + config LCD_ST7789_240X240 + bool "ST7789 240*240" + config LCD_ST7789_240X240_7PIN + bool "ST7789 240*240, 7PIN" + config LCD_ST7789_240X135 + bool "ST7789 240*135" + config LCD_ST7735_128X160 + bool "ST7735 128*160" + config LCD_ST7735_128X128 + bool "ST7735 128*128" + config LCD_ST7796_320X480 + bool "ST7796 320*480 IPS" + config LCD_ST7796_320X480_NO_IPS + bool "ST7796 320*480, Non-IPS" + config LCD_ILI9341_240X320 + bool "ILI9341 240*320" + config LCD_ILI9341_240X320_NO_IPS + bool "ILI9341 240*320, Non-IPS" + config LCD_GC9A01_240X240 + bool "GC9A01 240*240 Circle" + config LCD_CUSTOM + bool "Custom LCD (自定义屏幕参数)" +endchoice + +choice DISPLAY_ESP32S3_KORVO2_V3 + depends on BOARD_TYPE_ESP_KORVO2_V3 || BOARD_TYPE_ESP_KORVO2_V3_RNDIS + prompt "ESP32S3_KORVO2_V3 LCD Type" + default ESP32S3_KORVO2_V3_LCD_ST7789 + help + LCD Display Type + config ESP32S3_KORVO2_V3_LCD_ST7789 + bool "ST7789 240*280" + config ESP32S3_KORVO2_V3_LCD_ILI9341 + bool "ILI9341 240*320" +endchoice + +choice DISPLAY_ESP32S3_AUDIO_BOARD + depends on BOARD_TYPE_WAVESHARE_ESP32_S3_AUDIO_BOARD + prompt "ESP32S3_AUDIO_BOARD LCD Type" + default AUDIO_BOARD_LCD_JD9853 + help + LCD Display Type + config AUDIO_BOARD_LCD_JD9853 + bool "JD9853 320*172" + config AUDIO_BOARD_LCD_ST7789 + bool "ST7789 240*320" +endchoice + +choice DISPLAY_ESP32S3_TOUCH_LCD_1_85C + depends on BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_85C + prompt "ESP32S3_TOUCH_LCD_1_85C version" + default VERSION_2_0 + help + hardware version + config VERSION_1_0 + bool "version 1.0" + config VERSION_2_0 + bool "version 2.0" +endchoice + + +choice DISPLAY_STYLE + prompt "Select display style" + default USE_DEFAULT_MESSAGE_STYLE + help + Select display style for Xiaozhi device + + config USE_DEFAULT_MESSAGE_STYLE + bool "Enable default message style" + + config USE_WECHAT_MESSAGE_STYLE + bool "Enable WeChat Message Style" + + config USE_EMOTE_MESSAGE_STYLE + bool "Emote animation style" + depends on BOARD_TYPE_ESP_BOX || BOARD_TYPE_ESP_BOX_3 \ + || BOARD_TYPE_ESP_VOCAT || BOARD_TYPE_LICHUANG_DEV_S3 \ + || BOARD_TYPE_ESP_SENSAIRSHUTTLE +endchoice + +config USE_MULTILINE_CHAT_MESSAGE + bool "Use multiline chat message display (default mode only)" + depends on USE_DEFAULT_MESSAGE_STYLE + default n + help + When enabled, the chat message area in the default display mode shows + multiple wrapped lines that grow upward from the bottom of the screen, + with auto-adaptive height. + When disabled (default), a single-line horizontally scrolling label + is shown at the bottom of the screen. + +choice WAKE_WORD_TYPE + prompt "Wake Word Implementation Type" + default USE_AFE_WAKE_WORD if (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM + default WAKE_WORD_DISABLED + help + Choose the type of wake word implementation to use + + config WAKE_WORD_DISABLED + bool "Disabled" + help + Disable wake word detection + + config USE_ESP_WAKE_WORD + bool "Wakenet model without AFE" + depends on IDF_TARGET_ESP32C3 || IDF_TARGET_ESP32C5 || IDF_TARGET_ESP32C6 || (IDF_TARGET_ESP32 && SPIRAM) + help + Support ESP32 C3、ESP32 C5 与 ESP32 C6, and (ESP32 with PSRAM) + + config USE_AFE_WAKE_WORD + bool "Wakenet model with AFE" + depends on (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM + help + Support AEC if available, requires ESP32 S3 and PSRAM + + config USE_CUSTOM_WAKE_WORD + bool "Multinet model (Custom Wake Word)" + depends on (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM + help + Requires ESP32 S3 and PSRAM + +endchoice + +config CUSTOM_WAKE_WORD + string "Custom Wake Word" + default "xiao tu dou" + depends on USE_CUSTOM_WAKE_WORD + help + Custom Wake Word, use pinyin for Chinese, separated by spaces + +config CUSTOM_WAKE_WORD_DISPLAY + string "Custom Wake Word Display" + default "小土豆" + depends on USE_CUSTOM_WAKE_WORD + help + Greeting sent to the server after wake word detection + +config CUSTOM_WAKE_WORD_THRESHOLD + int "Custom Wake Word Threshold (%)" + default 20 + range 1 99 + depends on USE_CUSTOM_WAKE_WORD + help + Custom Wake Word Threshold, range 1-99, the smaller the more sensitive, default 20 + +config SEND_WAKE_WORD_DATA + bool "Send Wake Word Data" + default y + depends on USE_AFE_WAKE_WORD || USE_CUSTOM_WAKE_WORD + help + Send wake word data to the server as the first message of the conversation and wait for response + +config WAKE_WORD_DETECTION_IN_LISTENING + bool "Enable Wake Word Detection in Listening Mode" + default n + depends on USE_AFE_WAKE_WORD || USE_CUSTOM_WAKE_WORD + help + Enable wake word detection while in listening mode. + When enabled, the device can detect wake word during listening, + which allows interrupting the current conversation. + When disabled (default), wake word detection is turned off during listening. + +config USE_AUDIO_PROCESSOR + bool "Enable Audio Noise Reduction" + default y + depends on (IDF_TARGET_ESP32S3 || IDF_TARGET_ESP32P4) && SPIRAM + help + Requires ESP32 S3 and PSRAM + +config USE_DEVICE_AEC + bool "Enable Device-Side AEC" + default n + depends on USE_AUDIO_PROCESSOR && (BOARD_TYPE_ESP_BOX_3 || BOARD_TYPE_ESP_BOX || BOARD_TYPE_ESP_BOX_LITE \ + || BOARD_TYPE_LICHUANG_DEV_S3 || BOARD_TYPE_ESP_KORVO2_V3 || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_75|| BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_1_75C || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_1_83\ + || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_AMOLED_2_06 || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4B || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4B || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7B \ + || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_3_4C || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_4C || BOARD_TYPE_ESP_S3_LCD_EV_Board_2 || BOARD_TYPE_YUNLIAO_S3 \ + || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_7 || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_8 || BOARD_TYPE_WAVESHARE_ESP32_P4_WIFI6_TOUCH_LCD_10_1 \ + || BOARD_TYPE_ESP_VOCAT || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_3_49 || BOARD_TYPE_WAVESHARE_ESP32_S3_RLCD_4_2 || BOARD_TYPE_ZHENGCHEN_CAM || BOARD_TYPE_ZHENGCHEN_CAM_ML307 \ + || BOARD_TYPE_WAVESHARE_ESP32_S3_TOUCH_LCD_4_3C) + help + To work properly, device-side AEC requires a clean output reference path from the speaker signal and physical acoustic isolation between the microphone and speaker. + +config USE_SERVER_AEC + bool "Enable Server-Side AEC (Unstable)" + default n + depends on USE_AUDIO_PROCESSOR + help + To work perperly, server-side AEC requires server support + +config USE_AUDIO_DEBUGGER + bool "Enable Audio Debugger" + default n + help + Enable audio debugger, send audio data through UDP to the host machine + +menu "WiFi Configuration Method" + help + WiFi Configuration Method Selection + config USE_HOTSPOT_WIFI_PROVISIONING + bool "Hotspot" + default y + help + Use WiFi Hotspot to transmit WiFi configuration data + config USE_ACOUSTIC_WIFI_PROVISIONING + bool "Acoustic" + help + Use audio signal to transmit WiFi configuration data + + config USE_ESP_BLUFI_WIFI_PROVISIONING + bool "Esp Blufi" + help + Use esp blufi protocol to transmit WiFi configuration data + select BT_ENABLED + select BT_BLE_42_FEATURES_SUPPORTED + select BT_BLE_BLUFI_ENABLE + select MBEDTLS_DHM_C +endmenu + +config AUDIO_DEBUG_UDP_SERVER + string "Audio Debug UDP Server Address" + default "192.168.2.100:8000" + depends on USE_AUDIO_DEBUGGER + help + UDP server address, format: IP:PORT, used to receive audio debugging data + +config RECEIVE_CUSTOM_MESSAGE + bool "Enable Custom Message Reception" + default n + help + Enable custom message reception, allow the device to receive custom messages from the server (preferably through the MQTT protocol) + +menu "Camera Configuration" + depends on !IDF_TARGET_ESP32 + + comment "Warning: Please read the help text before modifying these settings." + + config XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + bool "Allow JPEG Input" + default n + help + Allow JPEG Input format for the camera. + + This option may need to be enabled when using a USB camera. + + Not currently supported when used simultaneously with XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE. + + config XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER + bool "Enable Hardware JPEG Encoder" + default y + depends on SOC_JPEG_ENCODE_SUPPORTED + help + Use hardware JPEG encoder on ESP32-P4 to encode image to JPEG. + See https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-reference/peripherals/jpeg.html for more details. + + config XIAOZHI_ENABLE_HARDWARE_JPEG_DECODER + bool "Enable Hardware JPEG Decoder" + default n + depends on SOC_JPEG_DECODE_SUPPORTED && XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + help + Use hardware JPEG decoder on ESP32-P4 to decode JPEG to image. + See https://docs.espressif.com/projects/esp-idf/en/stable/esp32p4/api-reference/peripherals/jpeg.html for more details. + + config XIAOZHI_ENABLE_CAMERA_DEBUG_MODE + bool "Enable Camera Debug Mode" + default n + help + Enable camera debug mode, print camera debug information to the console. + Only works on boards that support camera. + + config XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP + bool "Enable software camera buffer endianness swapping" + default n + depends on !CAMERA_SENSOR_SWAP_PIXEL_BYTE_ORDER + help + This option treats the camera buffer as a uint16_t[] array and performs byte-swapping (endianness conversion) on each element. + + Should only be modified by development board integration engineers. + + **Incorrect usage may result in incorrect image colors!** + + ATTENTION: If the option CAMERA_SENSOR_SWAP_PIXEL_BYTE_ORDER is available for your sensor, please use that instead. + + menuconfig XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + bool "Enable Camera Image Rotation" + default n + depends on !XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + help + Enable camera image rotation, rotate the camera image to the correct orientation. + - On ESP32-P4, rotation is handled by PPA hardware. + - On other chips, rotation is done in software with performance cost. + - For 180° rotation, use HFlip + VFlip instead of this option. + + Not currently supported when used simultaneously with XIAOZHI_CAMERA_ALLOW_JPEG_INPUT. + + if XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + choice XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE + prompt "Camera Image Rotation Angle (clockwise)" + default XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE_90 + help + Camera image rotation angle. + config XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE_90 + bool "90°" + config XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE_270 + bool "270°" + comment "For 180° rotation, use HFlip + VFlip instead of this option" + endchoice + endif +endmenu + +menu "TAIJIPAI_S3_CONFIG" + depends on BOARD_TYPE_TAIJI_PI_S3 + choice I2S_TYPE_TAIJIPI_S3 + prompt "taiji-pi-S3 I2S Type" + default TAIJIPAI_I2S_TYPE_STD + help + I2S 类型选择 + config TAIJIPAI_I2S_TYPE_STD + bool "I2S Type STD" + config TAIJIPAI_I2S_TYPE_PDM + bool "I2S Type PDM" + endchoice + + config I2S_USE_2SLOT + bool "Enable I2S 2 Slot" + default y + help + 启动双声道 +endmenu + +endmenu diff --git a/main/application.cc b/main/application.cc new file mode 100644 index 0000000..d69d43d --- /dev/null +++ b/main/application.cc @@ -0,0 +1,1119 @@ +#include "application.h" +#include "board.h" +#include "display.h" +#include "system_info.h" +#include "audio_codec.h" +#include "mqtt_protocol.h" +#include "websocket_protocol.h" +#include "assets/lang_config.h" +#include "mcp_server.h" +#include "assets.h" +#include "settings.h" + +#include +#include +#include +#include +#include +#include + +#define TAG "Application" + + +Application::Application() { + event_group_ = xEventGroupCreate(); + +#if CONFIG_USE_DEVICE_AEC && CONFIG_USE_SERVER_AEC +#error "CONFIG_USE_DEVICE_AEC and CONFIG_USE_SERVER_AEC cannot be enabled at the same time" +#elif CONFIG_USE_DEVICE_AEC + aec_mode_ = kAecOnDeviceSide; +#elif CONFIG_USE_SERVER_AEC + aec_mode_ = kAecOnServerSide; +#else + aec_mode_ = kAecOff; +#endif + + esp_timer_create_args_t clock_timer_args = { + .callback = [](void* arg) { + Application* app = (Application*)arg; + xEventGroupSetBits(app->event_group_, MAIN_EVENT_CLOCK_TICK); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "clock_timer", + .skip_unhandled_events = true + }; + esp_timer_create(&clock_timer_args, &clock_timer_handle_); +} + +Application::~Application() { + if (clock_timer_handle_ != nullptr) { + esp_timer_stop(clock_timer_handle_); + esp_timer_delete(clock_timer_handle_); + } + vEventGroupDelete(event_group_); +} + +bool Application::SetDeviceState(DeviceState state) { + return state_machine_.TransitionTo(state); +} + +void Application::Initialize() { + auto& board = Board::GetInstance(); + SetDeviceState(kDeviceStateStarting); + + // Setup the display + auto display = board.GetDisplay(); + display->SetupUI(); + // Print board name/version info + display->SetChatMessage("system", SystemInfo::GetUserAgent().c_str()); + + // Setup the audio service + auto codec = board.GetAudioCodec(); + audio_service_.Initialize(codec); + audio_service_.Start(); + + AudioServiceCallbacks callbacks; + callbacks.on_send_queue_available = [this]() { + 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); + }; + callbacks.on_vad_change = [this](bool speaking) { + xEventGroupSetBits(event_group_, MAIN_EVENT_VAD_CHANGE); + }; + audio_service_.SetCallbacks(callbacks); + + // Add state change listeners + state_machine_.AddStateChangeListener([this](DeviceState old_state, DeviceState new_state) { + xEventGroupSetBits(event_group_, MAIN_EVENT_STATE_CHANGED); + }); + + // Start the clock timer to update the status bar + esp_timer_start_periodic(clock_timer_handle_, 1000000); + + // Add MCP common tools (only once during initialization) + auto& mcp_server = McpServer::GetInstance(); + mcp_server.AddCommonTools(); + mcp_server.AddUserOnlyTools(); + + // Set network event callback for UI updates and network state handling + board.SetNetworkEventCallback([this](NetworkEvent event, const std::string& data) { + auto display = Board::GetInstance().GetDisplay(); + + switch (event) { + case NetworkEvent::Scanning: + display->ShowNotification(Lang::Strings::SCANNING_WIFI, 30000); + xEventGroupSetBits(event_group_, MAIN_EVENT_NETWORK_DISCONNECTED); + break; + case NetworkEvent::Connecting: { + if (data.empty()) { + // Cellular network - registering without carrier info yet + display->SetStatus(Lang::Strings::REGISTERING_NETWORK); + } else { + // WiFi or cellular with carrier info + std::string msg = Lang::Strings::CONNECT_TO; + msg += data; + msg += "..."; + display->ShowNotification(msg.c_str(), 30000); + } + break; + } + case NetworkEvent::Connected: { + std::string msg = Lang::Strings::CONNECTED_TO; + msg += data; + display->ShowNotification(msg.c_str(), 30000); + xEventGroupSetBits(event_group_, MAIN_EVENT_NETWORK_CONNECTED); + break; + } + case NetworkEvent::Disconnected: + xEventGroupSetBits(event_group_, MAIN_EVENT_NETWORK_DISCONNECTED); + break; + case NetworkEvent::WifiConfigModeEnter: + // WiFi config mode enter is handled by WifiBoard internally + break; + case NetworkEvent::WifiConfigModeExit: + // WiFi config mode exit is handled by WifiBoard internally + break; + // Cellular modem specific events + case NetworkEvent::ModemDetecting: + display->SetStatus(Lang::Strings::DETECTING_MODULE); + break; + case NetworkEvent::ModemErrorNoSim: + Alert(Lang::Strings::ERROR, Lang::Strings::PIN_ERROR, "triangle_exclamation", Lang::Sounds::OGG_ERR_PIN); + break; + case NetworkEvent::ModemErrorRegDenied: + Alert(Lang::Strings::ERROR, Lang::Strings::REG_ERROR, "triangle_exclamation", Lang::Sounds::OGG_ERR_REG); + break; + case NetworkEvent::ModemErrorInitFailed: + Alert(Lang::Strings::ERROR, Lang::Strings::MODEM_INIT_ERROR, "triangle_exclamation", Lang::Sounds::OGG_EXCLAMATION); + break; + case NetworkEvent::ModemErrorTimeout: + display->SetStatus(Lang::Strings::REGISTERING_NETWORK); + break; + } + }); + + // Start network asynchronously + board.StartNetwork(); + + // Update the status bar immediately to show the network state + display->UpdateStatusBar(true); +} + +void Application::Run() { + // Set the priority of the main task to 10 + vTaskPrioritySet(nullptr, 10); + + const EventBits_t ALL_EVENTS = + MAIN_EVENT_SCHEDULE | + MAIN_EVENT_SEND_AUDIO | + MAIN_EVENT_WAKE_WORD_DETECTED | + MAIN_EVENT_VAD_CHANGE | + MAIN_EVENT_CLOCK_TICK | + MAIN_EVENT_ERROR | + MAIN_EVENT_NETWORK_CONNECTED | + MAIN_EVENT_NETWORK_DISCONNECTED | + MAIN_EVENT_TOGGLE_CHAT | + MAIN_EVENT_START_LISTENING | + MAIN_EVENT_STOP_LISTENING | + MAIN_EVENT_ACTIVATION_DONE | + MAIN_EVENT_STATE_CHANGED; + + while (true) { + auto bits = xEventGroupWaitBits(event_group_, ALL_EVENTS, pdTRUE, pdFALSE, portMAX_DELAY); + + if (bits & MAIN_EVENT_ERROR) { + SetDeviceState(kDeviceStateIdle); + Alert(Lang::Strings::ERROR, last_error_message_.c_str(), "circle_xmark", Lang::Sounds::OGG_EXCLAMATION); + } + + if (bits & MAIN_EVENT_NETWORK_CONNECTED) { + HandleNetworkConnectedEvent(); + } + + if (bits & MAIN_EVENT_NETWORK_DISCONNECTED) { + HandleNetworkDisconnectedEvent(); + } + + if (bits & MAIN_EVENT_ACTIVATION_DONE) { + HandleActivationDoneEvent(); + } + + if (bits & MAIN_EVENT_STATE_CHANGED) { + HandleStateChangedEvent(); + } + + if (bits & MAIN_EVENT_TOGGLE_CHAT) { + HandleToggleChatEvent(); + } + + if (bits & MAIN_EVENT_START_LISTENING) { + HandleStartListeningEvent(); + } + + if (bits & MAIN_EVENT_STOP_LISTENING) { + HandleStopListeningEvent(); + } + + if (bits & MAIN_EVENT_SEND_AUDIO) { + while (auto packet = audio_service_.PopPacketFromSendQueue()) { + if (protocol_ && !protocol_->SendAudio(std::move(packet))) { + break; + } + } + } + + if (bits & MAIN_EVENT_WAKE_WORD_DETECTED) { + HandleWakeWordDetectedEvent(); + } + + if (bits & MAIN_EVENT_VAD_CHANGE) { + if (GetDeviceState() == kDeviceStateListening) { + auto led = Board::GetInstance().GetLed(); + led->OnStateChanged(); + } + } + + if (bits & MAIN_EVENT_SCHEDULE) { + std::unique_lock lock(mutex_); + auto tasks = std::move(main_tasks_); + lock.unlock(); + for (auto& task : tasks) { + task(); + } + } + + if (bits & MAIN_EVENT_CLOCK_TICK) { + clock_ticks_++; + auto display = Board::GetInstance().GetDisplay(); + display->UpdateStatusBar(); + + // Print debug info every 10 seconds + if (clock_ticks_ % 10 == 0) { + SystemInfo::PrintHeapStats(); + } + } + } +} + +void Application::HandleNetworkConnectedEvent() { + ESP_LOGI(TAG, "Network connected"); + auto state = GetDeviceState(); + + if (state == kDeviceStateStarting || state == kDeviceStateWifiConfiguring) { + // Network is ready, start activation + SetDeviceState(kDeviceStateActivating); + if (activation_task_handle_ != nullptr) { + ESP_LOGW(TAG, "Activation task already running"); + return; + } + + xTaskCreate([](void* arg) { + Application* app = static_cast(arg); + app->ActivationTask(); + app->activation_task_handle_ = nullptr; + vTaskDelete(NULL); + }, "activation", 4096 * 2, this, 2, &activation_task_handle_); + } + + // Update the status bar immediately to show the network state + auto display = Board::GetInstance().GetDisplay(); + display->UpdateStatusBar(true); +} + +void Application::HandleNetworkDisconnectedEvent() { + // Close current conversation when network disconnected + auto state = GetDeviceState(); + if (state == kDeviceStateConnecting || state == kDeviceStateListening || state == kDeviceStateSpeaking) { + ESP_LOGI(TAG, "Closing audio channel due to network disconnection"); + protocol_->CloseAudioChannel(); + } + + // Update the status bar immediately to show the network state + auto display = Board::GetInstance().GetDisplay(); + display->UpdateStatusBar(true); +} + +void Application::HandleActivationDoneEvent() { + ESP_LOGI(TAG, "Activation done"); + + SystemInfo::PrintHeapStats(); + SetDeviceState(kDeviceStateIdle); + + has_server_time_ = ota_->HasServerTime(); + + auto display = Board::GetInstance().GetDisplay(); + std::string message = std::string(Lang::Strings::VERSION) + ota_->GetCurrentVersion(); + display->ShowNotification(message.c_str()); + display->SetChatMessage("system", ""); + + // Release OTA object after activation is complete + ota_.reset(); + auto& board = Board::GetInstance(); + board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER); + + Schedule([this]() { + // Play the success sound to indicate the device is ready + audio_service_.PlaySound(Lang::Sounds::OGG_SUCCESS); + }); +} + +void Application::ActivationTask() { + // Create OTA object for activation process + ota_ = std::make_unique(); + + // Check for new assets version + CheckAssetsVersion(); + + // Check for new firmware version + CheckNewVersion(); + + // Initialize the protocol + InitializeProtocol(); + + // Signal completion to main loop + xEventGroupSetBits(event_group_, MAIN_EVENT_ACTIVATION_DONE); +} + +void Application::CheckAssetsVersion() { + // Only allow CheckAssetsVersion to be called once + if (assets_version_checked_) { + return; + } + assets_version_checked_ = true; + + auto& board = Board::GetInstance(); + auto display = board.GetDisplay(); + auto& assets = Assets::GetInstance(); + + if (!assets.partition_valid()) { + ESP_LOGW(TAG, "Assets partition is disabled for board %s", BOARD_NAME); + return; + } + + Settings settings("assets", true); + // Check if there is a new assets need to be downloaded + std::string download_url = settings.GetString("download_url"); + + if (!download_url.empty()) { + settings.EraseKey("download_url"); + + char message[256]; + snprintf(message, sizeof(message), Lang::Strings::FOUND_NEW_ASSETS, download_url.c_str()); + Alert(Lang::Strings::LOADING_ASSETS, message, "cloud_arrow_down", Lang::Sounds::OGG_UPGRADE); + + // Wait for the audio service to be idle for 3 seconds + vTaskDelay(pdMS_TO_TICKS(3000)); + SetDeviceState(kDeviceStateUpgrading); + board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE); + display->SetChatMessage("system", Lang::Strings::PLEASE_WAIT); + + bool success = assets.Download(download_url, [this, display](int progress, size_t speed) -> void { + char buffer[32]; + snprintf(buffer, sizeof(buffer), "%d%% %uKB/s", progress, speed / 1024); + Schedule([display, message = std::string(buffer)]() { + display->SetChatMessage("system", message.c_str()); + }); + }); + + board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER); + vTaskDelay(pdMS_TO_TICKS(1000)); + + if (!success) { + Alert(Lang::Strings::ERROR, Lang::Strings::DOWNLOAD_ASSETS_FAILED, "circle_xmark", Lang::Sounds::OGG_EXCLAMATION); + vTaskDelay(pdMS_TO_TICKS(2000)); + SetDeviceState(kDeviceStateActivating); + return; + } + } + + // Apply assets + assets.Apply(); + display->SetChatMessage("system", ""); + display->SetEmotion("microchip_ai"); +} + +void Application::CheckNewVersion() { + const int MAX_RETRY = 10; + int retry_count = 0; + int retry_delay = 10; // Initial retry delay in seconds + + auto& board = Board::GetInstance(); + while (true) { + auto display = board.GetDisplay(); + display->SetStatus(Lang::Strings::CHECKING_NEW_VERSION); + + esp_err_t err = ota_->CheckVersion(); + if (err != ESP_OK) { + retry_count++; + if (retry_count >= MAX_RETRY) { + ESP_LOGE(TAG, "Too many retries, exit version check"); + return; + } + + char error_message[128]; + snprintf(error_message, sizeof(error_message), "code=%d, url=%s", err, ota_->GetCheckVersionUrl().c_str()); + char buffer[256]; + snprintf(buffer, sizeof(buffer), Lang::Strings::CHECK_NEW_VERSION_FAILED, retry_delay, error_message); + Alert(Lang::Strings::ERROR, buffer, "cloud_slash", Lang::Sounds::OGG_EXCLAMATION); + + ESP_LOGW(TAG, "Check new version failed, retry in %d seconds (%d/%d)", retry_delay, retry_count, MAX_RETRY); + for (int i = 0; i < retry_delay; i++) { + vTaskDelay(pdMS_TO_TICKS(1000)); + if (GetDeviceState() == kDeviceStateIdle) { + break; + } + } + retry_delay *= 2; // Double the retry delay + continue; + } + retry_count = 0; + retry_delay = 10; // Reset retry delay + + if (ota_->HasNewVersion()) { + if (UpgradeFirmware(ota_->GetFirmwareUrl(), ota_->GetFirmwareVersion())) { + return; // This line will never be reached after reboot + } + // If upgrade failed, continue to normal operation + } + + // No new version, mark the current version as valid + ota_->MarkCurrentVersionValid(); + if (!ota_->HasActivationCode() && !ota_->HasActivationChallenge()) { + // Exit the loop if done checking new version + break; + } + + display->SetStatus(Lang::Strings::ACTIVATION); + // Activation code is shown to the user and waiting for the user to input + if (ota_->HasActivationCode()) { + ShowActivationCode(ota_->GetActivationCode(), ota_->GetActivationMessage()); + } + + // This will block the loop until the activation is done or timeout + for (int i = 0; i < 10; ++i) { + ESP_LOGI(TAG, "Activating... %d/%d", i + 1, 10); + esp_err_t err = ota_->Activate(); + if (err == ESP_OK) { + break; + } else if (err == ESP_ERR_TIMEOUT) { + vTaskDelay(pdMS_TO_TICKS(3000)); + } else { + vTaskDelay(pdMS_TO_TICKS(10000)); + } + if (GetDeviceState() == kDeviceStateIdle) { + break; + } + } + } +} + +void Application::InitializeProtocol() { + auto& board = Board::GetInstance(); + auto display = board.GetDisplay(); + auto codec = board.GetAudioCodec(); + + display->SetStatus(Lang::Strings::LOADING_PROTOCOL); + + if (ota_->HasMqttConfig()) { + protocol_ = std::make_unique(); + } else if (ota_->HasWebsocketConfig()) { + protocol_ = std::make_unique(); + } else { + ESP_LOGW(TAG, "No protocol specified in the OTA config, using MQTT"); + protocol_ = std::make_unique(); + } + + protocol_->OnConnected([this]() { + DismissAlert(); + }); + + protocol_->OnNetworkError([this](const std::string& message) { + last_error_message_ = message; + xEventGroupSetBits(event_group_, MAIN_EVENT_ERROR); + }); + + protocol_->OnIncomingAudio([this](std::unique_ptr packet) { + if (GetDeviceState() == kDeviceStateSpeaking) { + audio_service_.PushPacketToDecodeQueue(std::move(packet)); + } + }); + + protocol_->OnAudioChannelOpened([this, codec, &board]() { + board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE); + if (protocol_->server_sample_rate() != codec->output_sample_rate()) { + ESP_LOGW(TAG, "Server sample rate %d does not match device output sample rate %d, resampling may cause distortion", + protocol_->server_sample_rate(), codec->output_sample_rate()); + } + }); + + protocol_->OnAudioChannelClosed([this, &board]() { + board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER); + Schedule([this]() { + auto display = Board::GetInstance().GetDisplay(); + display->SetChatMessage("system", ""); + SetDeviceState(kDeviceStateIdle); + }); + }); + + protocol_->OnIncomingJson([this, display](const cJSON* root) { + // Parse JSON data + auto type = cJSON_GetObjectItem(root, "type"); + if (strcmp(type->valuestring, "tts") == 0) { + auto state = cJSON_GetObjectItem(root, "state"); + if (strcmp(state->valuestring, "start") == 0) { + Schedule([this]() { + aborted_ = false; + SetDeviceState(kDeviceStateSpeaking); + }); + } else if (strcmp(state->valuestring, "stop") == 0) { + Schedule([this]() { + if (GetDeviceState() == kDeviceStateSpeaking) { + if (listening_mode_ == kListeningModeManualStop) { + SetDeviceState(kDeviceStateIdle); + } else { + SetDeviceState(kDeviceStateListening); + } + } + }); + } 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)]() { + display->SetChatMessage("assistant", message.c_str()); + }); + } + } + } else if (strcmp(type->valuestring, "stt") == 0) { + auto text = cJSON_GetObjectItem(root, "text"); + if (cJSON_IsString(text)) { + ESP_LOGI(TAG, ">> %s", text->valuestring); + Schedule([display, message = std::string(text->valuestring)]() { + display->SetChatMessage("user", message.c_str()); + }); + } + } else if (strcmp(type->valuestring, "llm") == 0) { + auto emotion = cJSON_GetObjectItem(root, "emotion"); + if (cJSON_IsString(emotion)) { + Schedule([display, emotion_str = std::string(emotion->valuestring)]() { + display->SetEmotion(emotion_str.c_str()); + }); + } + } else if (strcmp(type->valuestring, "mcp") == 0) { + auto payload = cJSON_GetObjectItem(root, "payload"); + if (cJSON_IsObject(payload)) { + McpServer::GetInstance().ParseMessage(payload); + } + } else if (strcmp(type->valuestring, "system") == 0) { + auto command = cJSON_GetObjectItem(root, "command"); + if (cJSON_IsString(command)) { + ESP_LOGI(TAG, "System command: %s", command->valuestring); + if (strcmp(command->valuestring, "reboot") == 0) { + // Do a reboot if user requests a OTA update + Schedule([this]() { + Reboot(); + }); + } else { + ESP_LOGW(TAG, "Unknown system command: %s", command->valuestring); + } + } + } else if (strcmp(type->valuestring, "alert") == 0) { + auto status = cJSON_GetObjectItem(root, "status"); + auto message = cJSON_GetObjectItem(root, "message"); + auto emotion = cJSON_GetObjectItem(root, "emotion"); + if (cJSON_IsString(status) && cJSON_IsString(message) && cJSON_IsString(emotion)) { + Alert(status->valuestring, message->valuestring, emotion->valuestring, Lang::Sounds::OGG_VIBRATION); + } else { + ESP_LOGW(TAG, "Alert command requires status, message and emotion"); + } +#if CONFIG_RECEIVE_CUSTOM_MESSAGE + } else if (strcmp(type->valuestring, "custom") == 0) { + auto payload = cJSON_GetObjectItem(root, "payload"); + ESP_LOGI(TAG, "Received custom message: %s", cJSON_PrintUnformatted(root)); + if (cJSON_IsObject(payload)) { + Schedule([this, display, payload_str = std::string(cJSON_PrintUnformatted(payload))]() { + display->SetChatMessage("system", payload_str.c_str()); + }); + } else { + ESP_LOGW(TAG, "Invalid custom message format: missing payload"); + } +#endif + } else { + ESP_LOGW(TAG, "Unknown message type: %s", type->valuestring); + } + }); + + protocol_->Start(); +} + +void Application::ShowActivationCode(const std::string& code, const std::string& message) { + struct digit_sound { + char digit; + const std::string_view& sound; + }; + static const std::array digit_sounds{{ + digit_sound{'0', Lang::Sounds::OGG_0}, + digit_sound{'1', Lang::Sounds::OGG_1}, + digit_sound{'2', Lang::Sounds::OGG_2}, + digit_sound{'3', Lang::Sounds::OGG_3}, + digit_sound{'4', Lang::Sounds::OGG_4}, + digit_sound{'5', Lang::Sounds::OGG_5}, + digit_sound{'6', Lang::Sounds::OGG_6}, + digit_sound{'7', Lang::Sounds::OGG_7}, + digit_sound{'8', Lang::Sounds::OGG_8}, + digit_sound{'9', Lang::Sounds::OGG_9} + }}; + + // This sentence uses 9KB of SRAM, so we need to wait for it to finish + Alert(Lang::Strings::ACTIVATION, message.c_str(), "link", Lang::Sounds::OGG_ACTIVATION); + + for (const auto& digit : code) { + auto it = std::find_if(digit_sounds.begin(), digit_sounds.end(), + [digit](const digit_sound& ds) { return ds.digit == digit; }); + if (it != digit_sounds.end()) { + audio_service_.PlaySound(it->sound); + } + } +} + +void Application::Alert(const char* status, const char* message, const char* emotion, const std::string_view& sound) { + ESP_LOGW(TAG, "Alert [%s] %s: %s", emotion, status, message); + auto display = Board::GetInstance().GetDisplay(); + display->SetStatus(status); + display->SetEmotion(emotion); + display->SetChatMessage("system", message); + if (!sound.empty()) { + audio_service_.PlaySound(sound); + } +} + +void Application::DismissAlert() { + if (GetDeviceState() == kDeviceStateIdle) { + auto display = Board::GetInstance().GetDisplay(); + display->SetStatus(Lang::Strings::STANDBY); + display->SetEmotion("neutral"); + display->SetChatMessage("system", ""); + } +} + +void Application::ToggleChatState() { + xEventGroupSetBits(event_group_, MAIN_EVENT_TOGGLE_CHAT); +} + +void Application::StartListening() { + xEventGroupSetBits(event_group_, MAIN_EVENT_START_LISTENING); +} + +void Application::StopListening() { + xEventGroupSetBits(event_group_, MAIN_EVENT_STOP_LISTENING); +} + +void Application::HandleToggleChatEvent() { + auto state = GetDeviceState(); + + if (state == kDeviceStateActivating) { + SetDeviceState(kDeviceStateIdle); + return; + } else if (state == kDeviceStateWifiConfiguring) { + audio_service_.EnableAudioTesting(true); + SetDeviceState(kDeviceStateAudioTesting); + return; + } else if (state == kDeviceStateAudioTesting) { + audio_service_.EnableAudioTesting(false); + SetDeviceState(kDeviceStateWifiConfiguring); + return; + } + + if (!protocol_) { + ESP_LOGE(TAG, "Protocol not initialized"); + return; + } + + if (state == kDeviceStateIdle) { + ListeningMode mode = GetDefaultListeningMode(); + if (!protocol_->IsAudioChannelOpened()) { + SetDeviceState(kDeviceStateConnecting); + // Schedule to let the state change be processed first (UI update) + Schedule([this, mode]() { + ContinueOpenAudioChannel(mode); + }); + return; + } + SetListeningMode(mode); + } else if (state == kDeviceStateSpeaking) { + AbortSpeaking(kAbortReasonNone); + } else if (state == kDeviceStateListening) { + protocol_->CloseAudioChannel(); + } +} + +void Application::ContinueOpenAudioChannel(ListeningMode mode) { + // Check state again in case it was changed during scheduling + if (GetDeviceState() != kDeviceStateConnecting) { + return; + } + + if (!protocol_->IsAudioChannelOpened()) { + if (!protocol_->OpenAudioChannel()) { + return; + } + } + + SetListeningMode(mode); +} + +void Application::HandleStartListeningEvent() { + auto state = GetDeviceState(); + + if (state == kDeviceStateActivating) { + SetDeviceState(kDeviceStateIdle); + return; + } else if (state == kDeviceStateWifiConfiguring) { + audio_service_.EnableAudioTesting(true); + SetDeviceState(kDeviceStateAudioTesting); + return; + } + + if (!protocol_) { + ESP_LOGE(TAG, "Protocol not initialized"); + return; + } + + if (state == kDeviceStateIdle) { + if (!protocol_->IsAudioChannelOpened()) { + SetDeviceState(kDeviceStateConnecting); + // Schedule to let the state change be processed first (UI update) + Schedule([this]() { + ContinueOpenAudioChannel(kListeningModeManualStop); + }); + return; + } + SetListeningMode(kListeningModeManualStop); + } else if (state == kDeviceStateSpeaking) { + AbortSpeaking(kAbortReasonNone); + SetListeningMode(kListeningModeManualStop); + } +} + +void Application::HandleStopListeningEvent() { + auto state = GetDeviceState(); + + if (state == kDeviceStateAudioTesting) { + audio_service_.EnableAudioTesting(false); + SetDeviceState(kDeviceStateWifiConfiguring); + return; + } else if (state == kDeviceStateListening) { + if (protocol_) { + protocol_->SendStopListening(); + } + SetDeviceState(kDeviceStateIdle); + } +} + +void Application::HandleWakeWordDetectedEvent() { + if (!protocol_) { + return; + } + + auto state = GetDeviceState(); + auto wake_word = audio_service_.GetLastWakeWord(); + ESP_LOGI(TAG, "Wake word detected: %s (state: %d)", wake_word.c_str(), (int)state); + + if (state == kDeviceStateIdle) { + audio_service_.EncodeWakeWord(); + auto wake_word = audio_service_.GetLastWakeWord(); + + if (!protocol_->IsAudioChannelOpened()) { + SetDeviceState(kDeviceStateConnecting); + // Schedule to let the state change be processed first (UI update), + // then continue with OpenAudioChannel which may block for ~1 second + Schedule([this, wake_word]() { + ContinueWakeWordInvoke(wake_word); + }); + return; + } + // Channel already opened, continue directly + ContinueWakeWordInvoke(wake_word); + } else if (state == kDeviceStateSpeaking || state == kDeviceStateListening) { + AbortSpeaking(kAbortReasonWakeWordDetected); + // Clear send queue to avoid sending residues to server + while (audio_service_.PopPacketFromSendQueue()); + + if (state == kDeviceStateListening) { + protocol_->SendStartListening(GetDefaultListeningMode()); + audio_service_.ResetDecoder(); + audio_service_.PlaySound(Lang::Sounds::OGG_POPUP); + // Re-enable wake word detection as it was stopped by the detection itself + audio_service_.EnableWakeWordDetection(true); + } else { + // Play popup sound and start listening again + play_popup_on_listening_ = true; + SetListeningMode(GetDefaultListeningMode()); + } + } else if (state == kDeviceStateActivating) { + // Restart the activation check if the wake word is detected during activation + SetDeviceState(kDeviceStateIdle); + } +} + +void Application::ContinueWakeWordInvoke(const std::string& wake_word) { + // Check state again in case it was changed during scheduling + if (GetDeviceState() != kDeviceStateConnecting) { + return; + } + + if (!protocol_->IsAudioChannelOpened()) { + if (!protocol_->OpenAudioChannel()) { + audio_service_.EnableWakeWordDetection(true); + return; + } + } + + ESP_LOGI(TAG, "Wake word detected: %s", wake_word.c_str()); +#if CONFIG_SEND_WAKE_WORD_DATA + // Encode and send the wake word data to the server + while (auto packet = audio_service_.PopWakeWordPacket()) { + protocol_->SendAudio(std::move(packet)); + } + // Set the chat state to wake word detected + protocol_->SendWakeWordDetected(wake_word); + + // Set flag to play popup sound after state changes to listening + play_popup_on_listening_ = true; + SetListeningMode(GetDefaultListeningMode()); +#else + // Set flag to play popup sound after state changes to listening + // (PlaySound here would be cleared by ResetDecoder in EnableVoiceProcessing) + play_popup_on_listening_ = true; + SetListeningMode(GetDefaultListeningMode()); +#endif +} + +void Application::HandleStateChangedEvent() { + DeviceState new_state = state_machine_.GetState(); + clock_ticks_ = 0; + + auto& board = Board::GetInstance(); + auto display = board.GetDisplay(); + auto led = board.GetLed(); + led->OnStateChanged(); + + switch (new_state) { + case kDeviceStateUnknown: + case kDeviceStateIdle: + display->SetStatus(Lang::Strings::STANDBY); + display->ClearChatMessages(); // Clear messages first + display->SetEmotion("neutral"); // Then set emotion (wechat mode checks child count) + audio_service_.EnableVoiceProcessing(false); + audio_service_.EnableWakeWordDetection(true); + break; + case kDeviceStateConnecting: + display->SetStatus(Lang::Strings::CONNECTING); + display->SetEmotion("neutral"); + display->SetChatMessage("system", ""); + break; + case kDeviceStateListening: + display->SetStatus(Lang::Strings::LISTENING); + display->SetEmotion("neutral"); + + // Make sure the audio processor is running + if (play_popup_on_listening_ || !audio_service_.IsAudioProcessorRunning()) { + // For auto mode, wait for playback queue to be empty before enabling voice processing + // This prevents audio truncation when STOP arrives late due to network jitter + if (listening_mode_ == kListeningModeAutoStop) { + audio_service_.WaitForPlaybackQueueEmpty(); + } + + // Send the start listening command + protocol_->SendStartListening(listening_mode_); + audio_service_.EnableVoiceProcessing(true); + } + +#ifdef CONFIG_WAKE_WORD_DETECTION_IN_LISTENING + // Enable wake word detection in listening mode (configured via Kconfig) + audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord()); +#else + // Disable wake word detection in listening mode + audio_service_.EnableWakeWordDetection(false); +#endif + + // Play popup sound after ResetDecoder (in EnableVoiceProcessing) has been called + if (play_popup_on_listening_) { + play_popup_on_listening_ = false; + audio_service_.PlaySound(Lang::Sounds::OGG_POPUP); + } + break; + case kDeviceStateSpeaking: + display->SetStatus(Lang::Strings::SPEAKING); + + if (listening_mode_ != kListeningModeRealtime) { + audio_service_.EnableVoiceProcessing(false); + // Only AFE wake word can be detected in speaking mode + audio_service_.EnableWakeWordDetection(audio_service_.IsAfeWakeWord()); + } + audio_service_.ResetDecoder(); + break; + case kDeviceStateWifiConfiguring: + audio_service_.EnableVoiceProcessing(false); + audio_service_.EnableWakeWordDetection(false); + break; + default: + // Do nothing + break; + } +} + +void Application::Schedule(std::function&& callback) { + { + std::lock_guard lock(mutex_); + main_tasks_.push_back(std::move(callback)); + } + xEventGroupSetBits(event_group_, MAIN_EVENT_SCHEDULE); +} + +void Application::AbortSpeaking(AbortReason reason) { + ESP_LOGI(TAG, "Abort speaking"); + aborted_ = true; + if (protocol_) { + protocol_->SendAbortSpeaking(reason); + } +} + +void Application::SetListeningMode(ListeningMode mode) { + listening_mode_ = mode; + SetDeviceState(kDeviceStateListening); +} + +ListeningMode Application::GetDefaultListeningMode() const { + return aec_mode_ == kAecOff ? kListeningModeAutoStop : kListeningModeRealtime; +} + +void Application::Reboot() { + ESP_LOGI(TAG, "Rebooting..."); + // Disconnect the audio channel + if (protocol_ && protocol_->IsAudioChannelOpened()) { + protocol_->CloseAudioChannel(); + } + protocol_.reset(); + audio_service_.Stop(); + + vTaskDelay(pdMS_TO_TICKS(1000)); + esp_restart(); +} + +bool Application::UpgradeFirmware(const std::string& url, const std::string& version) { + auto& board = Board::GetInstance(); + auto display = board.GetDisplay(); + + std::string upgrade_url = url; + std::string version_info = version.empty() ? "(Manual upgrade)" : version; + + // Close audio channel if it's open + if (protocol_ && protocol_->IsAudioChannelOpened()) { + ESP_LOGI(TAG, "Closing audio channel before firmware upgrade"); + protocol_->CloseAudioChannel(); + } + ESP_LOGI(TAG, "Starting firmware upgrade from URL: %s", upgrade_url.c_str()); + + Alert(Lang::Strings::OTA_UPGRADE, Lang::Strings::UPGRADING, "download", Lang::Sounds::OGG_UPGRADE); + vTaskDelay(pdMS_TO_TICKS(3000)); + + SetDeviceState(kDeviceStateUpgrading); + + std::string message = std::string(Lang::Strings::NEW_VERSION) + version_info; + display->SetChatMessage("system", message.c_str()); + + board.SetPowerSaveLevel(PowerSaveLevel::PERFORMANCE); + audio_service_.Stop(); + vTaskDelay(pdMS_TO_TICKS(1000)); + + bool upgrade_success = Ota::Upgrade(upgrade_url, [this, display](int progress, size_t speed) { + char buffer[32]; + snprintf(buffer, sizeof(buffer), "%d%% %uKB/s", progress, speed / 1024); + Schedule([display, message = std::string(buffer)]() { + display->SetChatMessage("system", message.c_str()); + }); + }); + + if (!upgrade_success) { + // Upgrade failed, restart audio service and continue running + ESP_LOGE(TAG, "Firmware upgrade failed, restarting audio service and continuing operation..."); + audio_service_.Start(); // Restart audio service + board.SetPowerSaveLevel(PowerSaveLevel::LOW_POWER); // Restore power save level + Alert(Lang::Strings::ERROR, Lang::Strings::UPGRADE_FAILED, "circle_xmark", Lang::Sounds::OGG_EXCLAMATION); + vTaskDelay(pdMS_TO_TICKS(3000)); + return false; + } else { + // Upgrade success, reboot immediately + ESP_LOGI(TAG, "Firmware upgrade successful, rebooting..."); + display->SetChatMessage("system", "Upgrade successful, rebooting..."); + vTaskDelay(pdMS_TO_TICKS(1000)); // Brief pause to show message + Reboot(); + return true; + } +} + +void Application::WakeWordInvoke(const std::string& wake_word) { + if (!protocol_) { + return; + } + + auto state = GetDeviceState(); + + if (state == kDeviceStateIdle) { + audio_service_.EncodeWakeWord(); + + if (!protocol_->IsAudioChannelOpened()) { + SetDeviceState(kDeviceStateConnecting); + // Schedule to let the state change be processed first (UI update) + Schedule([this, wake_word]() { + ContinueWakeWordInvoke(wake_word); + }); + return; + } + // Channel already opened, continue directly + ContinueWakeWordInvoke(wake_word); + } else if (state == kDeviceStateSpeaking) { + Schedule([this]() { + AbortSpeaking(kAbortReasonNone); + }); + } else if (state == kDeviceStateListening) { + Schedule([this]() { + if (protocol_) { + protocol_->CloseAudioChannel(); + } + }); + } +} + +bool Application::CanEnterSleepMode() { + if (GetDeviceState() != kDeviceStateIdle) { + return false; + } + + if (protocol_ && protocol_->IsAudioChannelOpened()) { + return false; + } + + if (!audio_service_.IsIdle()) { + return false; + } + + // Now it is safe to enter sleep mode + return true; +} + +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_) { + protocol_->SendMcpMessage(payload); + } + }); +} + +void Application::SetAecMode(AecMode mode) { + aec_mode_ = mode; + Schedule([this]() { + auto& board = Board::GetInstance(); + auto display = board.GetDisplay(); + switch (aec_mode_) { + case kAecOff: + audio_service_.EnableDeviceAec(false); + display->ShowNotification(Lang::Strings::RTC_MODE_OFF); + break; + case kAecOnServerSide: + audio_service_.EnableDeviceAec(false); + display->ShowNotification(Lang::Strings::RTC_MODE_ON); + break; + case kAecOnDeviceSide: + audio_service_.EnableDeviceAec(true); + display->ShowNotification(Lang::Strings::RTC_MODE_ON); + break; + } + + // If the AEC mode is changed, close the audio channel + if (protocol_ && protocol_->IsAudioChannelOpened()) { + protocol_->CloseAudioChannel(); + } + }); +} + +void Application::PlaySound(const std::string_view& sound) { + audio_service_.PlaySound(sound); +} + +void Application::ResetProtocol() { + Schedule([this]() { + // Close audio channel if opened + if (protocol_ && protocol_->IsAudioChannelOpened()) { + protocol_->CloseAudioChannel(); + } + // Reset protocol + protocol_.reset(); + }); +} + diff --git a/main/application.h b/main/application.h new file mode 100644 index 0000000..7ca7af4 --- /dev/null +++ b/main/application.h @@ -0,0 +1,189 @@ +#ifndef _APPLICATION_H_ +#define _APPLICATION_H_ + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "protocol.h" +#include "ota.h" +#include "audio_service.h" +#include "device_state.h" +#include "device_state_machine.h" + +// Main event bits +#define MAIN_EVENT_SCHEDULE (1 << 0) +#define MAIN_EVENT_SEND_AUDIO (1 << 1) +#define MAIN_EVENT_WAKE_WORD_DETECTED (1 << 2) +#define MAIN_EVENT_VAD_CHANGE (1 << 3) +#define MAIN_EVENT_ERROR (1 << 4) +#define MAIN_EVENT_ACTIVATION_DONE (1 << 5) +#define MAIN_EVENT_CLOCK_TICK (1 << 6) +#define MAIN_EVENT_NETWORK_CONNECTED (1 << 7) +#define MAIN_EVENT_NETWORK_DISCONNECTED (1 << 8) +#define MAIN_EVENT_TOGGLE_CHAT (1 << 9) +#define MAIN_EVENT_START_LISTENING (1 << 10) +#define MAIN_EVENT_STOP_LISTENING (1 << 11) +#define MAIN_EVENT_STATE_CHANGED (1 << 12) + + +enum AecMode { + kAecOff, + kAecOnDeviceSide, + kAecOnServerSide, +}; + +class Application { +public: + static Application& GetInstance() { + static Application instance; + return instance; + } + // Delete copy constructor and assignment operator + Application(const Application&) = delete; + Application& operator=(const Application&) = delete; + + /** + * Initialize the application + * This sets up display, audio, network callbacks, etc. + * Network connection starts asynchronously. + */ + void Initialize(); + + /** + * Run the main event loop + * This function runs in the main task and never returns. + * It handles all events including network, state changes, and user interactions. + */ + void Run(); + + DeviceState GetDeviceState() const { return state_machine_.GetState(); } + bool IsVoiceDetected() const { return audio_service_.IsVoiceDetected(); } + + /** + * Request state transition + * Returns true if transition was successful + */ + bool SetDeviceState(DeviceState state); + + /** + * Schedule a callback to be executed in the main task + */ + void Schedule(std::function&& callback); + + /** + * Alert with status, message, emotion and optional sound + */ + void Alert(const char* status, const char* message, const char* emotion = "", const std::string_view& sound = ""); + void DismissAlert(); + + void AbortSpeaking(AbortReason reason); + + /** + * Toggle chat state (event-based, thread-safe) + * Sends MAIN_EVENT_TOGGLE_CHAT to be handled in Run() + */ + void ToggleChatState(); + + /** + * Start listening (event-based, thread-safe) + * Sends MAIN_EVENT_START_LISTENING to be handled in Run() + */ + void StartListening(); + + /** + * Stop listening (event-based, thread-safe) + * Sends MAIN_EVENT_STOP_LISTENING to be handled in Run() + */ + void StopListening(); + + void Reboot(); + void WakeWordInvoke(const std::string& wake_word); + bool UpgradeFirmware(const std::string& url, const std::string& version = ""); + bool CanEnterSleepMode(); + void SendMcpMessage(const std::string& payload); + void SetAecMode(AecMode mode); + AecMode GetAecMode() const { return aec_mode_; } + void PlaySound(const std::string_view& sound); + AudioService& GetAudioService() { return audio_service_; } + + /** + * Reset protocol resources (thread-safe) + * Can be called from any task to release resources allocated after network connected + * This includes closing audio channel, resetting protocol and ota objects + */ + void ResetProtocol(); + +private: + Application(); + ~Application(); + + std::mutex mutex_; + std::deque> main_tasks_; + std::unique_ptr protocol_; + EventGroupHandle_t event_group_ = nullptr; + esp_timer_handle_t clock_timer_handle_ = nullptr; + DeviceStateMachine state_machine_; + ListeningMode listening_mode_ = kListeningModeAutoStop; + AecMode aec_mode_ = kAecOff; + std::string last_error_message_; + AudioService audio_service_; + std::unique_ptr ota_; + + bool has_server_time_ = false; + bool aborted_ = false; + bool assets_version_checked_ = false; + bool play_popup_on_listening_ = false; // Flag to play popup sound after state changes to listening + int clock_ticks_ = 0; + TaskHandle_t activation_task_handle_ = nullptr; + + + // Event handlers + void HandleStateChangedEvent(); + void HandleToggleChatEvent(); + void HandleStartListeningEvent(); + void HandleStopListeningEvent(); + void HandleNetworkConnectedEvent(); + void HandleNetworkDisconnectedEvent(); + void HandleActivationDoneEvent(); + void HandleWakeWordDetectedEvent(); + void ContinueOpenAudioChannel(ListeningMode mode); + void ContinueWakeWordInvoke(const std::string& wake_word); + + // Activation task (runs in background) + void ActivationTask(); + + // Helper methods + void CheckAssetsVersion(); + void CheckNewVersion(); + void InitializeProtocol(); + void ShowActivationCode(const std::string& code, const std::string& message); + void SetListeningMode(ListeningMode mode); + ListeningMode GetDefaultListeningMode() const; + + // State change handler called by state machine + void OnStateChanged(DeviceState old_state, DeviceState new_state); +}; + + +class TaskPriorityReset { +public: + TaskPriorityReset(BaseType_t priority) { + original_priority_ = uxTaskPriorityGet(NULL); + vTaskPrioritySet(NULL, priority); + } + ~TaskPriorityReset() { + vTaskPrioritySet(NULL, original_priority_); + } + +private: + BaseType_t original_priority_; +}; + +#endif // _APPLICATION_H_ diff --git a/main/assets.cc b/main/assets.cc new file mode 100644 index 0000000..ad61944 --- /dev/null +++ b/main/assets.cc @@ -0,0 +1,560 @@ +#include "assets.h" +#include "board.h" +#include "display.h" +#include "application.h" +#include "lvgl_theme.h" +#include "emote_display.h" +#include "expression_emote.h" +#if HAVE_LVGL +#include "display/lcd_display.h" +#include +#endif + +#include +#include +#include +#include + + +#define TAG "Assets" +#define PARTITION_LABEL "assets" + +struct mmap_assets_table { + char asset_name[32]; /*!< Name of the asset */ + uint32_t asset_size; /*!< Size of the asset */ + uint32_t asset_offset; /*!< Offset of the asset */ + uint16_t asset_width; /*!< Width of the asset */ + uint16_t asset_height; /*!< Height of the asset */ +}; + +Assets::Assets() { +#if HAVE_LVGL + strategy_ = std::make_unique(); +#else + strategy_ = std::make_unique(); +#endif + // Initialize the partition + InitializePartition(); +} + +Assets::~Assets() { + UnApplyPartition(); +} + +bool Assets::FindPartition(Assets* assets) { + assets->partition_ = esp_partition_find_first(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, PARTITION_LABEL); + if (assets->partition_ == nullptr) { + ESP_LOGI(TAG, "No assets partition found"); + return false; + } + return true; +} + +bool Assets::Apply() { + return strategy_ ? strategy_->Apply(this) : false; +} + +bool Assets::InitializePartition() { + return strategy_ ? strategy_->InitializePartition(this) : false; +} + +void Assets::UnApplyPartition() { + if (strategy_) { + strategy_->UnApplyPartition(this); + } +} + +bool Assets::GetAssetData(const std::string& name, void*& ptr, size_t& size) { + return strategy_ ? strategy_->GetAssetData(this, name, ptr, size) : false; +} + +bool Assets::LoadSrmodelsFromIndex(Assets* assets, cJSON* root) { + void* ptr = nullptr; + size_t size = 0; + bool need_delete_root = false; + + // If root is not provided, parse index.json + if (root == nullptr) { + if (!assets->GetAssetData("index.json", ptr, size)) { + ESP_LOGE(TAG, "The index.json file is not found"); + return false; + } + + root = cJSON_ParseWithLength(static_cast(ptr), size); + if (root == nullptr) { + ESP_LOGE(TAG, "The index.json file is not valid"); + return false; + } + need_delete_root = true; + } + + cJSON* srmodels = cJSON_GetObjectItem(root, "srmodels"); + if (cJSON_IsString(srmodels)) { + std::string srmodels_file = srmodels->valuestring; + if (assets->GetAssetData(srmodels_file, ptr, size)) { + if (assets->models_list_ != nullptr) { + esp_srmodel_deinit(assets->models_list_); + assets->models_list_ = nullptr; + } + assets->models_list_ = srmodel_load(static_cast(ptr)); + if (assets->models_list_ != nullptr) { + auto& app = Application::GetInstance(); + app.GetAudioService().SetModelsList(assets->models_list_); + if (need_delete_root) { + cJSON_Delete(root); + } + return true; + } else { + ESP_LOGE(TAG, "Failed to load srmodels.bin"); + } + } else { + ESP_LOGE(TAG, "The srmodels file %s is not found", srmodels_file.c_str()); + } + } + + if (need_delete_root) { + cJSON_Delete(root); + } + return false; +} + +#if HAVE_LVGL +uint32_t Assets::LvglStrategy::CalculateChecksum(const char* data, uint32_t length) { + uint32_t checksum = 0; + for (uint32_t i = 0; i < length; i++) { + checksum += data[i]; + } + return checksum & 0xFFFF; +} + +bool Assets::LvglStrategy::InitializePartition(Assets* assets) { + assets->partition_valid_ = false; + assets_.clear(); + + if (!Assets::FindPartition(assets)) { + return false; + } + + int free_pages = spi_flash_mmap_get_free_pages(SPI_FLASH_MMAP_DATA); + uint32_t storage_size = free_pages * 64 * 1024; + ESP_LOGI(TAG, "The storage free size is %ld KB", storage_size / 1024); + ESP_LOGI(TAG, "The partition size is %ld KB", assets->partition_->size / 1024); + if (storage_size < assets->partition_->size) { + ESP_LOGE(TAG, "The free size %ld KB is less than assets partition required %ld KB", storage_size / 1024, assets->partition_->size / 1024); + return false; + } + + esp_err_t err = esp_partition_mmap(assets->partition_, 0, assets->partition_->size, ESP_PARTITION_MMAP_DATA, (const void**)&mmap_root_, &mmap_handle_); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to mmap assets partition: %s", esp_err_to_name(err)); + return false; + } + + assets->partition_valid_ = true; + + uint32_t stored_files = *(uint32_t*)(mmap_root_ + 0); + uint32_t stored_chksum = *(uint32_t*)(mmap_root_ + 4); + uint32_t stored_len = *(uint32_t*)(mmap_root_ + 8); + + if (stored_len > assets->partition_->size - 12) { + ESP_LOGD(TAG, "The stored_len (0x%lx) is greater than the partition size (0x%lx) - 12", stored_len, assets->partition_->size); + return false; + } + + auto start_time = esp_timer_get_time(); + uint32_t calculated_checksum = CalculateChecksum(mmap_root_ + 12, stored_len); + auto end_time = esp_timer_get_time(); + ESP_LOGI(TAG, "The checksum calculation time is %d ms", int((end_time - start_time) / 1000)); + + if (calculated_checksum != stored_chksum) { + ESP_LOGE(TAG, "The calculated checksum (0x%lx) does not match the stored checksum (0x%lx)", calculated_checksum, stored_chksum); + return false; + } + + checksum_valid_ = true; + + for (uint32_t i = 0; i < stored_files; i++) { + auto item = (const mmap_assets_table*)(mmap_root_ + 12 + i * sizeof(mmap_assets_table)); + auto asset = Asset{ + .size = static_cast(item->asset_size), + .offset = static_cast(12 + sizeof(mmap_assets_table) * stored_files + item->asset_offset) + }; + assets_[item->asset_name] = asset; + } + return checksum_valid_; +} + +void Assets::LvglStrategy::UnApplyPartition(Assets* assets) { + if (mmap_handle_ != 0) { + esp_partition_munmap(mmap_handle_); + mmap_handle_ = 0; + mmap_root_ = nullptr; + } + checksum_valid_ = false; + assets_.clear(); + (void)assets; // Unused parameter +} + +bool Assets::LvglStrategy::GetAssetData(Assets* assets, const std::string& name, void*& ptr, size_t& size) { + auto asset = assets_.find(name); + if (asset == assets_.end()) { + return false; + } + auto data = (const char*)(mmap_root_ + asset->second.offset); + if (data[0] != 'Z' || data[1] != 'Z') { + ESP_LOGE(TAG, "The asset %s is not valid with magic %02x%02x", name.c_str(), data[0], data[1]); + return false; + } + + ptr = static_cast(const_cast(data + 2)); + size = asset->second.size; + return true; +} + +bool Assets::LvglStrategy::Apply(Assets* assets) { + void* ptr = nullptr; + size_t size = 0; + if (!assets->GetAssetData("index.json", ptr, size)) { + ESP_LOGE(TAG, "The index.json file is not found"); + return false; + } + + cJSON* root = cJSON_ParseWithLength(static_cast(ptr), size); + if (root == nullptr) { + ESP_LOGE(TAG, "The index.json file is not valid"); + return false; + } + + cJSON* version = cJSON_GetObjectItem(root, "version"); + if (cJSON_IsNumber(version)) { + if (version->valuedouble > 1) { + ESP_LOGE(TAG, "The assets version %d is not supported, please upgrade the firmware", version->valueint); + return false; + } + } + + Assets::LoadSrmodelsFromIndex(assets, root); + + auto& theme_manager = LvglThemeManager::GetInstance(); + auto light_theme = theme_manager.GetTheme("light"); + auto dark_theme = theme_manager.GetTheme("dark"); + + cJSON* font = cJSON_GetObjectItem(root, "text_font"); + if (cJSON_IsString(font)) { + std::string fonts_text_file = font->valuestring; + if (assets->GetAssetData(fonts_text_file, ptr, size)) { + auto text_font = std::make_shared(ptr); + if (text_font->font() == nullptr) { + ESP_LOGE(TAG, "Failed to load fonts.bin"); + return false; + } + if (light_theme != nullptr) { + light_theme->set_text_font(text_font); + } + if (dark_theme != nullptr) { + dark_theme->set_text_font(text_font); + } + } else { + ESP_LOGE(TAG, "The font file %s is not found", fonts_text_file.c_str()); + } + } + + cJSON* emoji_collection = cJSON_GetObjectItem(root, "emoji_collection"); + if (cJSON_IsArray(emoji_collection)) { + auto custom_emoji_collection = std::make_shared(); + int emoji_count = cJSON_GetArraySize(emoji_collection); + for (int i = 0; i < emoji_count; i++) { + cJSON* emoji = cJSON_GetArrayItem(emoji_collection, i); + if (cJSON_IsObject(emoji)) { + cJSON* name = cJSON_GetObjectItem(emoji, "name"); + cJSON* file = cJSON_GetObjectItem(emoji, "file"); + cJSON* eaf = cJSON_GetObjectItem(emoji, "eaf"); + if (cJSON_IsString(name) && cJSON_IsString(file) && (NULL== eaf)) { + if (!assets->GetAssetData(file->valuestring, ptr, size)) { + ESP_LOGE(TAG, "Emoji %s image file %s is not found", name->valuestring, file->valuestring); + continue; + } + custom_emoji_collection->AddEmoji(name->valuestring, new LvglRawImage(ptr, size)); + } + } + } + if (light_theme != nullptr) { + light_theme->set_emoji_collection(custom_emoji_collection); + } + if (dark_theme != nullptr) { + dark_theme->set_emoji_collection(custom_emoji_collection); + } + } + + cJSON* skin = cJSON_GetObjectItem(root, "skin"); + if (cJSON_IsObject(skin)) { + cJSON* light_skin = cJSON_GetObjectItem(skin, "light"); + if (cJSON_IsObject(light_skin) && light_theme != nullptr) { + cJSON* text_color = cJSON_GetObjectItem(light_skin, "text_color"); + cJSON* background_color = cJSON_GetObjectItem(light_skin, "background_color"); + cJSON* background_image = cJSON_GetObjectItem(light_skin, "background_image"); + if (cJSON_IsString(text_color)) { + light_theme->set_text_color(LvglTheme::ParseColor(text_color->valuestring)); + } + if (cJSON_IsString(background_color)) { + light_theme->set_background_color(LvglTheme::ParseColor(background_color->valuestring)); + light_theme->set_chat_background_color(LvglTheme::ParseColor(background_color->valuestring)); + } + if (cJSON_IsString(background_image)) { + if (!assets->GetAssetData(background_image->valuestring, ptr, size)) { + ESP_LOGE(TAG, "The background image file %s is not found", background_image->valuestring); + return false; + } + auto background_image = std::make_shared(ptr); + light_theme->set_background_image(background_image); + } + } + cJSON* dark_skin = cJSON_GetObjectItem(skin, "dark"); + if (cJSON_IsObject(dark_skin) && dark_theme != nullptr) { + cJSON* text_color = cJSON_GetObjectItem(dark_skin, "text_color"); + cJSON* background_color = cJSON_GetObjectItem(dark_skin, "background_color"); + cJSON* background_image = cJSON_GetObjectItem(dark_skin, "background_image"); + if (cJSON_IsString(text_color)) { + dark_theme->set_text_color(LvglTheme::ParseColor(text_color->valuestring)); + } + if (cJSON_IsString(background_color)) { + dark_theme->set_background_color(LvglTheme::ParseColor(background_color->valuestring)); + dark_theme->set_chat_background_color(LvglTheme::ParseColor(background_color->valuestring)); + } + if (cJSON_IsString(background_image)) { + if (!assets->GetAssetData(background_image->valuestring, ptr, size)) { + ESP_LOGE(TAG, "The background image file %s is not found", background_image->valuestring); + return false; + } + auto background_image = std::make_shared(ptr); + dark_theme->set_background_image(background_image); + } + } + } + + auto display = Board::GetInstance().GetDisplay(); + ESP_LOGI(TAG, "Refreshing display theme..."); + + auto current_theme = display->GetTheme(); + if (current_theme != nullptr) { + display->SetTheme(current_theme); + } + + // Parse hide_subtitle configuration + cJSON* hide_subtitle = cJSON_GetObjectItem(root, "hide_subtitle"); + if (cJSON_IsBool(hide_subtitle)) { + bool hide = cJSON_IsTrue(hide_subtitle); + auto lcd_display = dynamic_cast(display); + if (lcd_display != nullptr) { + lcd_display->SetHideSubtitle(hide); + ESP_LOGI(TAG, "Set hide_subtitle to %s", hide ? "true" : "false"); + } + } + + cJSON_Delete(root); + return true; +} +#endif // HAVE_LVGL + +bool Assets::EmoteStrategy::InitializePartition(Assets* assets) { + assets->partition_valid_ = false; + + if (!Assets::FindPartition(assets)) { + return false; + } + + esp_err_t ret = ESP_ERR_INVALID_STATE; + auto display = Board::GetInstance().GetDisplay(); + auto* emote_display = dynamic_cast(display); + if (emote_display && emote_display->GetEmoteHandle() != nullptr) { + const emote_data_t data = { + .type = EMOTE_SOURCE_PARTITION, + .source = { + .partition_label = PARTITION_LABEL, + }, + .flags = { + .mmap_enable = true, //must be true here!!! + }, + }; + ret = emote_mount_assets(emote_display->GetEmoteHandle(), &data); + } else { + ESP_LOGE(TAG, "Emote display is not initialized"); + } + assets->partition_valid_ = ((ret == ESP_OK) ? true : false); + return assets->partition_valid_; +} + +void Assets::EmoteStrategy::UnApplyPartition(Assets* assets) { + auto display = Board::GetInstance().GetDisplay(); + auto* emote_display = dynamic_cast(display); + if (emote_display && emote_display->GetEmoteHandle() != nullptr) { + emote_unmount_assets(emote_display->GetEmoteHandle()); + } + (void)assets; // Unused parameter +} + +bool Assets::EmoteStrategy::GetAssetData(Assets* assets, const std::string& name, void*& ptr, size_t& size) { + auto display = Board::GetInstance().GetDisplay(); + auto* emote_display = dynamic_cast(display); + if (emote_display && emote_display->GetEmoteHandle() != nullptr) { + const uint8_t* data = nullptr; + size_t data_size = 0; + if (ESP_OK == emote_get_asset_data_by_name(emote_display->GetEmoteHandle(), name.c_str(), &data, &data_size)) { + ptr = const_cast(static_cast(data)); + size = data_size; + return true; + } + ESP_LOGE(TAG, "Failed to get asset data by name: %s", name.c_str()); + return false; + } + (void)assets; // Unused parameter + return false; +} + +bool Assets::EmoteStrategy::Apply(Assets* assets) { + Assets::LoadSrmodelsFromIndex(assets); + + auto display = Board::GetInstance().GetDisplay(); + auto* emote_display = dynamic_cast(display); + + if (emote_display && emote_display->GetEmoteHandle() != nullptr) { + emote_load_assets(emote_display->GetEmoteHandle()); + } + return true; +} + +bool Assets::Download(std::string url, std::function progress_callback) { + ESP_LOGI(TAG, "Downloading new version of assets from %s", url.c_str()); + + // 取消当前资源分区的内存映射 + UnApplyPartition(); + + // 下载新的资源文件 + auto network = Board::GetInstance().GetNetwork(); + auto http = network->CreateHttp(0); + + if (!http->Open("GET", url)) { + ESP_LOGE(TAG, "Failed to open HTTP connection"); + return false; + } + + if (http->GetStatusCode() != 200) { + ESP_LOGE(TAG, "Failed to get assets, status code: %d", http->GetStatusCode()); + return false; + } + + size_t content_length = http->GetBodyLength(); + if (content_length == 0) { + ESP_LOGE(TAG, "Failed to get content length"); + return false; + } + + if (content_length > partition_->size) { + ESP_LOGE(TAG, "Assets file size (%u) is larger than partition size (%lu)", content_length, partition_->size); + return false; + } + + // 定义扇区大小为4KB(ESP32的标准扇区大小) + const size_t SECTOR_SIZE = esp_partition_get_main_flash_sector_size(); + + // 计算需要擦除的扇区数量 + size_t sectors_to_erase = (content_length + SECTOR_SIZE - 1) / SECTOR_SIZE; // 向上取整 + size_t total_erase_size = sectors_to_erase * SECTOR_SIZE; + + ESP_LOGI(TAG, "Sector size: %u, content length: %u, sectors to erase: %u, total erase size: %u", + SECTOR_SIZE, content_length, sectors_to_erase, total_erase_size); + + // 写入新的资源文件到分区,一边erase一边写入 + char* buffer = (char*)heap_caps_malloc(SECTOR_SIZE, MALLOC_CAP_INTERNAL); + if (buffer == nullptr) { + ESP_LOGE(TAG, "Failed to allocate buffer"); + return false; + } + size_t total_written = 0; + size_t recent_written = 0; + size_t current_sector = 0; + auto last_calc_time = esp_timer_get_time(); + + while (true) { + int ret = http->Read(buffer, SECTOR_SIZE); + if (ret < 0) { + ESP_LOGE(TAG, "Failed to read HTTP data: %s", esp_err_to_name(ret)); + heap_caps_free(buffer); + return false; + } + + if (ret == 0) { + break; + } + + // 检查是否需要擦除新的扇区 + size_t write_end_offset = total_written + ret; + size_t needed_sectors = (write_end_offset + SECTOR_SIZE - 1) / SECTOR_SIZE; + + // 擦除需要的新扇区 + while (current_sector < needed_sectors) { + size_t sector_start = current_sector * SECTOR_SIZE; + size_t sector_end = (current_sector + 1) * SECTOR_SIZE; + + // 确保擦除范围不超过分区大小 + if (sector_end > partition_->size) { + ESP_LOGE(TAG, "Sector end (%u) exceeds partition size (%lu)", sector_end, partition_->size); + heap_caps_free(buffer); + return false; + } + + ESP_LOGD(TAG, "Erasing sector %u (offset: %u, size: %u)", current_sector, sector_start, SECTOR_SIZE); + esp_err_t err = esp_partition_erase_range(partition_, sector_start, SECTOR_SIZE); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to erase sector %u at offset %u: %s", current_sector, sector_start, esp_err_to_name(err)); + heap_caps_free(buffer); + return false; + } + + current_sector++; + } + + // 写入数据到分区 + esp_err_t err = esp_partition_write(partition_, total_written, buffer, ret); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to write to assets partition at offset %u: %s", total_written, esp_err_to_name(err)); + heap_caps_free(buffer); + return false; + } + + total_written += ret; + recent_written += ret; + + // 计算进度和速度 + if (esp_timer_get_time() - last_calc_time >= 1000000 || total_written == content_length || ret == 0) { + size_t progress = total_written * 100 / content_length; + size_t speed = recent_written; // 每秒的字节数 + ESP_LOGI(TAG, "Progress: %u%% (%u/%u), Speed: %u B/s, Sectors erased: %u", + progress, total_written, content_length, speed, current_sector); + if (progress_callback) { + progress_callback(progress, speed); + } + last_calc_time = esp_timer_get_time(); + recent_written = 0; // 重置最近写入的字节数 + } + } + + http->Close(); + heap_caps_free(buffer); + + if (total_written != content_length) { + ESP_LOGE(TAG, "Downloaded size (%u) does not match expected size (%u)", total_written, content_length); + return false; + } + + ESP_LOGI(TAG, "Assets download completed, total written: %u bytes, total sectors erased: %u", + total_written, current_sector); + + // 重新初始化资源分区 + if (!InitializePartition()) { + ESP_LOGE(TAG, "Failed to re-initialize assets partition"); + return false; + } + + return true; +} diff --git a/main/assets.h b/main/assets.h new file mode 100644 index 0000000..3692e0c --- /dev/null +++ b/main/assets.h @@ -0,0 +1,89 @@ +#ifndef ASSETS_H +#define ASSETS_H + +#include +#include +#include + +#include +#include +#include +#include +#include + +#if HAVE_LVGL +#include +#endif + +struct Asset { + size_t size; + size_t offset; +}; + +class Assets { +public: + static Assets& GetInstance() { + static Assets instance; + return instance; + } + ~Assets(); + + bool Download(std::string url, std::function progress_callback); + bool Apply(); + bool GetAssetData(const std::string& name, void*& ptr, size_t& size); + + inline bool partition_valid() const { return partition_valid_; } + inline std::string default_assets_url() const { return default_assets_url_; } + +private: + Assets(); + Assets(const Assets&) = delete; + Assets& operator=(const Assets&) = delete; + + bool InitializePartition(); + void UnApplyPartition(); + static bool FindPartition(Assets* assets); + static bool LoadSrmodelsFromIndex(Assets* assets, cJSON* root = nullptr); + + class AssetStrategy { + public: + virtual ~AssetStrategy() = default; + virtual bool Apply(Assets* assets) = 0; + virtual bool InitializePartition(Assets* assets) = 0; + virtual void UnApplyPartition(Assets* assets) = 0; + virtual bool GetAssetData(Assets* assets, const std::string& name, void*& ptr, size_t& size) = 0; + }; + + class LvglStrategy : public AssetStrategy { + public: + bool Apply(Assets* assets) override; + bool InitializePartition(Assets* assets) override; + void UnApplyPartition(Assets* assets) override; + bool GetAssetData(Assets* assets, const std::string& name, void*& ptr, size_t& size) override; + private: + static uint32_t CalculateChecksum(const char* data, uint32_t length); + std::map assets_; + esp_partition_mmap_handle_t mmap_handle_ = 0; + const char* mmap_root_ = nullptr; + bool checksum_valid_ = false; + }; + + class EmoteStrategy : public AssetStrategy { + public: + bool Apply(Assets* assets) override; + bool InitializePartition(Assets* assets) override; + void UnApplyPartition(Assets* assets) override; + bool GetAssetData(Assets* assets, const std::string& name, void*& ptr, size_t& size) override; + }; + + // Strategy instance + std::unique_ptr strategy_; + +protected: + const esp_partition_t* partition_ = nullptr; + bool partition_valid_ = false; + std::string default_assets_url_; + srmodel_list_t* models_list_ = nullptr; +}; + +#endif diff --git a/main/assets/common/exclamation.ogg b/main/assets/common/exclamation.ogg new file mode 100644 index 0000000..9a4cf58 Binary files /dev/null and b/main/assets/common/exclamation.ogg differ diff --git a/main/assets/common/low_battery.ogg b/main/assets/common/low_battery.ogg new file mode 100644 index 0000000..968b3ee Binary files /dev/null and b/main/assets/common/low_battery.ogg differ diff --git a/main/assets/common/popup.ogg b/main/assets/common/popup.ogg new file mode 100644 index 0000000..ea73b6f Binary files /dev/null and b/main/assets/common/popup.ogg differ diff --git a/main/assets/common/success.ogg b/main/assets/common/success.ogg new file mode 100644 index 0000000..ced1902 Binary files /dev/null and b/main/assets/common/success.ogg differ diff --git a/main/assets/common/vibration.ogg b/main/assets/common/vibration.ogg new file mode 100644 index 0000000..0076436 Binary files /dev/null and b/main/assets/common/vibration.ogg differ diff --git a/main/assets/locales/ar-SA/0.ogg b/main/assets/locales/ar-SA/0.ogg new file mode 100644 index 0000000..c656582 Binary files /dev/null and b/main/assets/locales/ar-SA/0.ogg differ diff --git a/main/assets/locales/ar-SA/1.ogg b/main/assets/locales/ar-SA/1.ogg new file mode 100644 index 0000000..dca3e06 Binary files /dev/null and b/main/assets/locales/ar-SA/1.ogg differ diff --git a/main/assets/locales/ar-SA/2.ogg b/main/assets/locales/ar-SA/2.ogg new file mode 100644 index 0000000..6d1f4d7 Binary files /dev/null and b/main/assets/locales/ar-SA/2.ogg differ diff --git a/main/assets/locales/ar-SA/3.ogg b/main/assets/locales/ar-SA/3.ogg new file mode 100644 index 0000000..12667d9 Binary files /dev/null and b/main/assets/locales/ar-SA/3.ogg differ diff --git a/main/assets/locales/ar-SA/4.ogg b/main/assets/locales/ar-SA/4.ogg new file mode 100644 index 0000000..e12dd7a Binary files /dev/null and b/main/assets/locales/ar-SA/4.ogg differ diff --git a/main/assets/locales/ar-SA/5.ogg b/main/assets/locales/ar-SA/5.ogg new file mode 100644 index 0000000..7dae30d Binary files /dev/null and b/main/assets/locales/ar-SA/5.ogg differ diff --git a/main/assets/locales/ar-SA/6.ogg b/main/assets/locales/ar-SA/6.ogg new file mode 100644 index 0000000..697278b Binary files /dev/null and b/main/assets/locales/ar-SA/6.ogg differ diff --git a/main/assets/locales/ar-SA/7.ogg b/main/assets/locales/ar-SA/7.ogg new file mode 100644 index 0000000..f83701d Binary files /dev/null and b/main/assets/locales/ar-SA/7.ogg differ diff --git a/main/assets/locales/ar-SA/8.ogg b/main/assets/locales/ar-SA/8.ogg new file mode 100644 index 0000000..bf3d2a1 Binary files /dev/null and b/main/assets/locales/ar-SA/8.ogg differ diff --git a/main/assets/locales/ar-SA/9.ogg b/main/assets/locales/ar-SA/9.ogg new file mode 100644 index 0000000..6d62228 Binary files /dev/null and b/main/assets/locales/ar-SA/9.ogg differ diff --git a/main/assets/locales/ar-SA/activation.ogg b/main/assets/locales/ar-SA/activation.ogg new file mode 100644 index 0000000..17aa008 Binary files /dev/null and b/main/assets/locales/ar-SA/activation.ogg differ diff --git a/main/assets/locales/ar-SA/err_pin.ogg b/main/assets/locales/ar-SA/err_pin.ogg new file mode 100644 index 0000000..8c624f5 Binary files /dev/null and b/main/assets/locales/ar-SA/err_pin.ogg differ diff --git a/main/assets/locales/ar-SA/err_reg.ogg b/main/assets/locales/ar-SA/err_reg.ogg new file mode 100644 index 0000000..b540a47 Binary files /dev/null and b/main/assets/locales/ar-SA/err_reg.ogg differ diff --git a/main/assets/locales/ar-SA/language.json b/main/assets/locales/ar-SA/language.json new file mode 100644 index 0000000..31d3078 --- /dev/null +++ b/main/assets/locales/ar-SA/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "ar-SA" + }, + "strings": { + "WARNING": "تحذير", + "INFO": "معلومات", + "ERROR": "خطأ", + "VERSION": "الإصدار ", + "LOADING_PROTOCOL": "الاتصال بالخادم...", + "INITIALIZING": "التهيئة...", + "PIN_ERROR": "يرجى إدخال بطاقة SIM", + "REG_ERROR": "لا يمكن الوصول إلى الشبكة، يرجى التحقق من حالة بطاقة البيانات", + "DETECTING_MODULE": "اكتشاف الوحدة...", + "REGISTERING_NETWORK": "انتظار الشبكة...", + "CHECKING_NEW_VERSION": "فحص الإصدار الجديد...", + "CHECK_NEW_VERSION_FAILED": "فشل فحص الإصدار الجديد، سيتم المحاولة خلال %d ثانية: %s", + "SWITCH_TO_WIFI_NETWORK": "التبديل إلى Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "التبديل إلى 4G...", + "STANDBY": "في الانتظار", + "CONNECT_TO": "الاتصال بـ ", + "CONNECTING": "جاري الاتصال...", + "CONNECTED_TO": "متصل بـ ", + "LISTENING": "الاستماع...", + "SPEAKING": "التحدث...", + "SERVER_NOT_FOUND": "البحث عن خدمة متاحة", + "SERVER_NOT_CONNECTED": "لا يمكن الاتصال بالخدمة، يرجى المحاولة لاحقاً", + "SERVER_TIMEOUT": "انتهت مهلة الاستجابة", + "SERVER_ERROR": "فشل الإرسال، يرجى التحقق من الشبكة", + "CONNECT_TO_HOTSPOT": "اتصل الهاتف بنقطة الاتصال ", + "ACCESS_VIA_BROWSER": "،الوصول عبر المتصفح ", + "WIFI_CONFIG_MODE": "وضع تكوين الشبكة", + "ENTERING_WIFI_CONFIG_MODE": "الدخول في وضع تكوين الشبكة...", + "SCANNING_WIFI": "فحص Wi-Fi...", + "NEW_VERSION": "إصدار جديد ", + "OTA_UPGRADE": "تحديث OTA", + "UPGRADING": "تحديث النظام...", + "UPGRADE_FAILED": "فشل التحديث", + "ACTIVATION": "تفعيل الجهاز", + "BATTERY_LOW": "البطارية منخفضة", + "BATTERY_CHARGING": "جاري الشحن", + "BATTERY_FULL": "البطارية ممتلئة", + "BATTERY_NEED_CHARGE": "البطارية منخفضة، يرجى الشحن", + "VOLUME": "الصوت ", + "MUTED": "صامت", + "MAX_VOLUME": "أقصى صوت", + "RTC_MODE_OFF": "AEC مُوقف", + "RTC_MODE_ON": "AEC مُشغل", + "DOWNLOAD_ASSETS_FAILED": "فشل في تنزيل الموارد", + "LOADING_ASSETS": "جاري تحميل الموارد...", + "PLEASE_WAIT": "يرجى الانتظار...", + "FOUND_NEW_ASSETS": "تم العثور على موارد جديدة: %s", + "HELLO_MY_FRIEND": "مرحباً، صديقي!", + "CONNECTION_SUCCESSFUL": "تم الاتصال بنجاح", + "FLIGHT_MODE_OFF": "وضع الطيران معطل", + "FLIGHT_MODE_ON": "وضع الطيران قيد التشغيل", + "MODEM_INIT_ERROR": "فشل تهيئة المودم" + } +} \ No newline at end of file diff --git a/main/assets/locales/ar-SA/upgrade.ogg b/main/assets/locales/ar-SA/upgrade.ogg new file mode 100644 index 0000000..f60d374 Binary files /dev/null and b/main/assets/locales/ar-SA/upgrade.ogg differ diff --git a/main/assets/locales/ar-SA/welcome.ogg b/main/assets/locales/ar-SA/welcome.ogg new file mode 100644 index 0000000..77028ad Binary files /dev/null and b/main/assets/locales/ar-SA/welcome.ogg differ diff --git a/main/assets/locales/ar-SA/wificonfig.ogg b/main/assets/locales/ar-SA/wificonfig.ogg new file mode 100644 index 0000000..b82f7a8 Binary files /dev/null and b/main/assets/locales/ar-SA/wificonfig.ogg differ diff --git a/main/assets/locales/bg-BG/0.ogg b/main/assets/locales/bg-BG/0.ogg new file mode 100644 index 0000000..0cf6483 Binary files /dev/null and b/main/assets/locales/bg-BG/0.ogg differ diff --git a/main/assets/locales/bg-BG/1.ogg b/main/assets/locales/bg-BG/1.ogg new file mode 100644 index 0000000..68bc66b Binary files /dev/null and b/main/assets/locales/bg-BG/1.ogg differ diff --git a/main/assets/locales/bg-BG/2.ogg b/main/assets/locales/bg-BG/2.ogg new file mode 100644 index 0000000..71f874d Binary files /dev/null and b/main/assets/locales/bg-BG/2.ogg differ diff --git a/main/assets/locales/bg-BG/3.ogg b/main/assets/locales/bg-BG/3.ogg new file mode 100644 index 0000000..95aaceb Binary files /dev/null and b/main/assets/locales/bg-BG/3.ogg differ diff --git a/main/assets/locales/bg-BG/4.ogg b/main/assets/locales/bg-BG/4.ogg new file mode 100644 index 0000000..af31cf7 Binary files /dev/null and b/main/assets/locales/bg-BG/4.ogg differ diff --git a/main/assets/locales/bg-BG/5.ogg b/main/assets/locales/bg-BG/5.ogg new file mode 100644 index 0000000..bae3dd8 Binary files /dev/null and b/main/assets/locales/bg-BG/5.ogg differ diff --git a/main/assets/locales/bg-BG/6.ogg b/main/assets/locales/bg-BG/6.ogg new file mode 100644 index 0000000..7271edc Binary files /dev/null and b/main/assets/locales/bg-BG/6.ogg differ diff --git a/main/assets/locales/bg-BG/7.ogg b/main/assets/locales/bg-BG/7.ogg new file mode 100644 index 0000000..707f7f2 Binary files /dev/null and b/main/assets/locales/bg-BG/7.ogg differ diff --git a/main/assets/locales/bg-BG/8.ogg b/main/assets/locales/bg-BG/8.ogg new file mode 100644 index 0000000..52c4513 Binary files /dev/null and b/main/assets/locales/bg-BG/8.ogg differ diff --git a/main/assets/locales/bg-BG/9.ogg b/main/assets/locales/bg-BG/9.ogg new file mode 100644 index 0000000..dd5a37f Binary files /dev/null and b/main/assets/locales/bg-BG/9.ogg differ diff --git a/main/assets/locales/bg-BG/activation.ogg b/main/assets/locales/bg-BG/activation.ogg new file mode 100644 index 0000000..d9bfb78 Binary files /dev/null and b/main/assets/locales/bg-BG/activation.ogg differ diff --git a/main/assets/locales/bg-BG/err_pin.ogg b/main/assets/locales/bg-BG/err_pin.ogg new file mode 100644 index 0000000..5a1f5cc Binary files /dev/null and b/main/assets/locales/bg-BG/err_pin.ogg differ diff --git a/main/assets/locales/bg-BG/err_reg.ogg b/main/assets/locales/bg-BG/err_reg.ogg new file mode 100644 index 0000000..992557c Binary files /dev/null and b/main/assets/locales/bg-BG/err_reg.ogg differ diff --git a/main/assets/locales/bg-BG/language.json b/main/assets/locales/bg-BG/language.json new file mode 100644 index 0000000..d222be6 --- /dev/null +++ b/main/assets/locales/bg-BG/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "bg-BG" + }, + "strings": { + "WARNING": "Предупреждение", + "INFO": "Информация", + "ERROR": "Грешка", + "VERSION": "Версия ", + "LOADING_PROTOCOL": "Влизане в системата...", + "INITIALIZING": "Инициализация...", + "PIN_ERROR": "Моля, поставете SIM карта", + "REG_ERROR": "Не може да се осъществи достъп до мрежата, моля проверете статуса на SIM картата", + "DETECTING_MODULE": "Откриване на модул...", + "REGISTERING_NETWORK": "Изчакване на мрежата...", + "CHECKING_NEW_VERSION": "Проверка за нова версия...", + "CHECK_NEW_VERSION_FAILED": "Проверката за нова версия е неуспешна, ще се опита отново след %d секунди: %s", + "SWITCH_TO_WIFI_NETWORK": "Превключване към Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Превключване към 4G...", + "STANDBY": "Режим на готовност", + "CONNECT_TO": "Свързване към ", + "CONNECTING": "Свързване...", + "CONNECTION_SUCCESSFUL": "Успешно свързване", + "CONNECTED_TO": "Свързан към ", + "LISTENING": "Слушане...", + "SPEAKING": "Говорене...", + "SERVER_NOT_FOUND": "Търсене на налична услуга", + "SERVER_NOT_CONNECTED": "Не може да се свърже с услугата, моля опитайте по-късно", + "SERVER_TIMEOUT": "Времето за изчакване на отговор изтече", + "SERVER_ERROR": "Неуспешно изпращане, моля проверете мрежата", + "CONNECT_TO_HOTSPOT": "Горещa точка: ", + "ACCESS_VIA_BROWSER": " Конфигурационен URL: ", + "WIFI_CONFIG_MODE": "Режим на конфигуриране на Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "Влизане в режим на конфигуриране на Wi-Fi...", + "SCANNING_WIFI": "Сканиране на Wi-Fi...", + "NEW_VERSION": "Нова версия ", + "OTA_UPGRADE": "OTA надстройка", + "UPGRADING": "Системата се надстройва...", + "UPGRADE_FAILED": "Надстройката е неуспешна", + "ACTIVATION": "Активация", + "BATTERY_LOW": "Слаба батерия", + "BATTERY_CHARGING": "Зарядна", + "BATTERY_FULL": "Батерията е пълна", + "BATTERY_NEED_CHARGE": "Слаба батерия, моля заредете", + "VOLUME": "Сила на звука ", + "MUTED": "Заглушено", + "MAX_VOLUME": "Максимална сила на звука", + "RTC_MODE_OFF": "AEC изключен", + "RTC_MODE_ON": "AEC включен", + "PLEASE_WAIT": "Моля, изчакайте...", + "FOUND_NEW_ASSETS": "Намерени нови ресурси: %s", + "DOWNLOAD_ASSETS_FAILED": "Неуспешно изтегляне на ресурси", + "LOADING_ASSETS": "Зареждане на ресурси...", + "HELLO_MY_FRIEND": "Здравей, мой приятел!", + "FLIGHT_MODE_OFF": "Режим на самолет е изключен", + "FLIGHT_MODE_ON": "Режим на самолет е включен", + "MODEM_INIT_ERROR": "Неуспешна инициализация на модема" + } +} \ No newline at end of file diff --git a/main/assets/locales/bg-BG/upgrade.ogg b/main/assets/locales/bg-BG/upgrade.ogg new file mode 100644 index 0000000..9c1a885 Binary files /dev/null and b/main/assets/locales/bg-BG/upgrade.ogg differ diff --git a/main/assets/locales/bg-BG/welcome.ogg b/main/assets/locales/bg-BG/welcome.ogg new file mode 100644 index 0000000..71e547b Binary files /dev/null and b/main/assets/locales/bg-BG/welcome.ogg differ diff --git a/main/assets/locales/bg-BG/wificonfig.ogg b/main/assets/locales/bg-BG/wificonfig.ogg new file mode 100644 index 0000000..7d84033 Binary files /dev/null and b/main/assets/locales/bg-BG/wificonfig.ogg differ diff --git a/main/assets/locales/ca-ES/0.ogg b/main/assets/locales/ca-ES/0.ogg new file mode 100644 index 0000000..2934044 Binary files /dev/null and b/main/assets/locales/ca-ES/0.ogg differ diff --git a/main/assets/locales/ca-ES/1.ogg b/main/assets/locales/ca-ES/1.ogg new file mode 100644 index 0000000..2069b40 Binary files /dev/null and b/main/assets/locales/ca-ES/1.ogg differ diff --git a/main/assets/locales/ca-ES/2.ogg b/main/assets/locales/ca-ES/2.ogg new file mode 100644 index 0000000..926011c Binary files /dev/null and b/main/assets/locales/ca-ES/2.ogg differ diff --git a/main/assets/locales/ca-ES/3.ogg b/main/assets/locales/ca-ES/3.ogg new file mode 100644 index 0000000..ce93833 Binary files /dev/null and b/main/assets/locales/ca-ES/3.ogg differ diff --git a/main/assets/locales/ca-ES/4.ogg b/main/assets/locales/ca-ES/4.ogg new file mode 100644 index 0000000..8a3b4a9 Binary files /dev/null and b/main/assets/locales/ca-ES/4.ogg differ diff --git a/main/assets/locales/ca-ES/5.ogg b/main/assets/locales/ca-ES/5.ogg new file mode 100644 index 0000000..87144f5 Binary files /dev/null and b/main/assets/locales/ca-ES/5.ogg differ diff --git a/main/assets/locales/ca-ES/6.ogg b/main/assets/locales/ca-ES/6.ogg new file mode 100644 index 0000000..b640829 Binary files /dev/null and b/main/assets/locales/ca-ES/6.ogg differ diff --git a/main/assets/locales/ca-ES/7.ogg b/main/assets/locales/ca-ES/7.ogg new file mode 100644 index 0000000..f6039c5 Binary files /dev/null and b/main/assets/locales/ca-ES/7.ogg differ diff --git a/main/assets/locales/ca-ES/8.ogg b/main/assets/locales/ca-ES/8.ogg new file mode 100644 index 0000000..c6cca34 Binary files /dev/null and b/main/assets/locales/ca-ES/8.ogg differ diff --git a/main/assets/locales/ca-ES/9.ogg b/main/assets/locales/ca-ES/9.ogg new file mode 100644 index 0000000..704dca0 Binary files /dev/null and b/main/assets/locales/ca-ES/9.ogg differ diff --git a/main/assets/locales/ca-ES/activation.ogg b/main/assets/locales/ca-ES/activation.ogg new file mode 100644 index 0000000..082e561 Binary files /dev/null and b/main/assets/locales/ca-ES/activation.ogg differ diff --git a/main/assets/locales/ca-ES/err_pin.ogg b/main/assets/locales/ca-ES/err_pin.ogg new file mode 100644 index 0000000..ea11662 Binary files /dev/null and b/main/assets/locales/ca-ES/err_pin.ogg differ diff --git a/main/assets/locales/ca-ES/err_reg.ogg b/main/assets/locales/ca-ES/err_reg.ogg new file mode 100644 index 0000000..3e9b042 Binary files /dev/null and b/main/assets/locales/ca-ES/err_reg.ogg differ diff --git a/main/assets/locales/ca-ES/language.json b/main/assets/locales/ca-ES/language.json new file mode 100644 index 0000000..566ceab --- /dev/null +++ b/main/assets/locales/ca-ES/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "ca-ES" + }, + "strings": { + "WARNING": "Advertència", + "INFO": "Informació", + "ERROR": "Error", + "VERSION": "Versió ", + "LOADING_PROTOCOL": "Iniciant sessió...", + "INITIALIZING": "Inicialitzant...", + "PIN_ERROR": "Si us plau, inseriu la targeta SIM", + "REG_ERROR": "No es pot accedir a la xarxa, si us plau comproveu l'estat de la targeta SIM", + "DETECTING_MODULE": "Detectant mòdul...", + "REGISTERING_NETWORK": "Esperant la xarxa...", + "CHECKING_NEW_VERSION": "Comprovant nova versió...", + "CHECK_NEW_VERSION_FAILED": "La comprovació de nova versió ha fallat, es tornarà a intentar en %d segons: %s", + "SWITCH_TO_WIFI_NETWORK": "Canviant a Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Canviant a 4G...", + "STANDBY": "En espera", + "CONNECT_TO": "Connectar a ", + "CONNECTING": "Connectant...", + "CONNECTION_SUCCESSFUL": "Connexió exitosa", + "CONNECTED_TO": "Connectat a ", + "LISTENING": "Escoltant...", + "SPEAKING": "Parlant...", + "SERVER_NOT_FOUND": "Buscant servei disponible", + "SERVER_NOT_CONNECTED": "No es pot connectar al servei, si us plau intenteu-ho més tard", + "SERVER_TIMEOUT": "Temps d'espera de resposta exhaurit", + "SERVER_ERROR": "L'enviament ha fallat, si us plau comproveu la xarxa", + "CONNECT_TO_HOTSPOT": "Punt d'accés: ", + "ACCESS_VIA_BROWSER": " URL de configuració: ", + "WIFI_CONFIG_MODE": "Mode de configuració Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "Entrant en mode de configuració Wi-Fi...", + "SCANNING_WIFI": "Escanejant Wi-Fi...", + "NEW_VERSION": "Nova versió ", + "OTA_UPGRADE": "Actualització OTA", + "UPGRADING": "El sistema s'està actualitzant...", + "UPGRADE_FAILED": "L'actualització ha fallat", + "ACTIVATION": "Activació", + "BATTERY_LOW": "Bateria baixa", + "BATTERY_CHARGING": "Carregant", + "BATTERY_FULL": "Bateria plena", + "BATTERY_NEED_CHARGE": "Bateria baixa, si us plau carregueu", + "VOLUME": "Volum ", + "MUTED": "Silenciat", + "MAX_VOLUME": "Volum màxim", + "RTC_MODE_OFF": "AEC desactivat", + "RTC_MODE_ON": "AEC activat", + "PLEASE_WAIT": "Si us plau, espereu...", + "FOUND_NEW_ASSETS": "S'han trobat nous recursos: %s", + "DOWNLOAD_ASSETS_FAILED": "No s'han pogut descarregar els recursos", + "LOADING_ASSETS": "Carregant recursos...", + "HELLO_MY_FRIEND": "Hola, amic meu!", + "FLIGHT_MODE_OFF": "El mode avió està desactivat", + "FLIGHT_MODE_ON": "El mode avió està activat", + "MODEM_INIT_ERROR": "Error d'inicialització del mòdem" + } +} \ No newline at end of file diff --git a/main/assets/locales/ca-ES/upgrade.ogg b/main/assets/locales/ca-ES/upgrade.ogg new file mode 100644 index 0000000..1bb2f28 Binary files /dev/null and b/main/assets/locales/ca-ES/upgrade.ogg differ diff --git a/main/assets/locales/ca-ES/welcome.ogg b/main/assets/locales/ca-ES/welcome.ogg new file mode 100644 index 0000000..530d04e Binary files /dev/null and b/main/assets/locales/ca-ES/welcome.ogg differ diff --git a/main/assets/locales/ca-ES/wificonfig.ogg b/main/assets/locales/ca-ES/wificonfig.ogg new file mode 100644 index 0000000..a4b77cd Binary files /dev/null and b/main/assets/locales/ca-ES/wificonfig.ogg differ diff --git a/main/assets/locales/cs-CZ/0.ogg b/main/assets/locales/cs-CZ/0.ogg new file mode 100644 index 0000000..96891e6 Binary files /dev/null and b/main/assets/locales/cs-CZ/0.ogg differ diff --git a/main/assets/locales/cs-CZ/1.ogg b/main/assets/locales/cs-CZ/1.ogg new file mode 100644 index 0000000..a12d638 Binary files /dev/null and b/main/assets/locales/cs-CZ/1.ogg differ diff --git a/main/assets/locales/cs-CZ/2.ogg b/main/assets/locales/cs-CZ/2.ogg new file mode 100644 index 0000000..a18d97e Binary files /dev/null and b/main/assets/locales/cs-CZ/2.ogg differ diff --git a/main/assets/locales/cs-CZ/3.ogg b/main/assets/locales/cs-CZ/3.ogg new file mode 100644 index 0000000..a2d9d3d Binary files /dev/null and b/main/assets/locales/cs-CZ/3.ogg differ diff --git a/main/assets/locales/cs-CZ/4.ogg b/main/assets/locales/cs-CZ/4.ogg new file mode 100644 index 0000000..76646ed Binary files /dev/null and b/main/assets/locales/cs-CZ/4.ogg differ diff --git a/main/assets/locales/cs-CZ/5.ogg b/main/assets/locales/cs-CZ/5.ogg new file mode 100644 index 0000000..2d9ab25 Binary files /dev/null and b/main/assets/locales/cs-CZ/5.ogg differ diff --git a/main/assets/locales/cs-CZ/6.ogg b/main/assets/locales/cs-CZ/6.ogg new file mode 100644 index 0000000..571ff30 Binary files /dev/null and b/main/assets/locales/cs-CZ/6.ogg differ diff --git a/main/assets/locales/cs-CZ/7.ogg b/main/assets/locales/cs-CZ/7.ogg new file mode 100644 index 0000000..e8f0f94 Binary files /dev/null and b/main/assets/locales/cs-CZ/7.ogg differ diff --git a/main/assets/locales/cs-CZ/8.ogg b/main/assets/locales/cs-CZ/8.ogg new file mode 100644 index 0000000..0f72a58 Binary files /dev/null and b/main/assets/locales/cs-CZ/8.ogg differ diff --git a/main/assets/locales/cs-CZ/9.ogg b/main/assets/locales/cs-CZ/9.ogg new file mode 100644 index 0000000..c102667 Binary files /dev/null and b/main/assets/locales/cs-CZ/9.ogg differ diff --git a/main/assets/locales/cs-CZ/activation.ogg b/main/assets/locales/cs-CZ/activation.ogg new file mode 100644 index 0000000..b4334a4 Binary files /dev/null and b/main/assets/locales/cs-CZ/activation.ogg differ diff --git a/main/assets/locales/cs-CZ/err_pin.ogg b/main/assets/locales/cs-CZ/err_pin.ogg new file mode 100644 index 0000000..07818a1 Binary files /dev/null and b/main/assets/locales/cs-CZ/err_pin.ogg differ diff --git a/main/assets/locales/cs-CZ/err_reg.ogg b/main/assets/locales/cs-CZ/err_reg.ogg new file mode 100644 index 0000000..6d4752e Binary files /dev/null and b/main/assets/locales/cs-CZ/err_reg.ogg differ diff --git a/main/assets/locales/cs-CZ/language.json b/main/assets/locales/cs-CZ/language.json new file mode 100644 index 0000000..7386fdf --- /dev/null +++ b/main/assets/locales/cs-CZ/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "cs-CZ" + }, + "strings": { + "WARNING": "Varování", + "INFO": "Informace", + "ERROR": "Chyba", + "VERSION": "Verze ", + "LOADING_PROTOCOL": "Připojování k serveru...", + "INITIALIZING": "Inicializace...", + "PIN_ERROR": "Prosím vložte SIM kartu", + "REG_ERROR": "Nelze se připojit k síti, zkontrolujte stav datové karty", + "DETECTING_MODULE": "Detekce modulu...", + "REGISTERING_NETWORK": "Čekání na síť...", + "CHECKING_NEW_VERSION": "Kontrola nové verze...", + "CHECK_NEW_VERSION_FAILED": "Kontrola nové verze selhala, opakování za %d sekund: %s", + "SWITCH_TO_WIFI_NETWORK": "Přepínání na Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Přepínání na 4G...", + "STANDBY": "Pohotovost", + "CONNECT_TO": "Připojit k ", + "CONNECTING": "Připojování...", + "CONNECTED_TO": "Připojeno k ", + "LISTENING": "Naslouchání...", + "SPEAKING": "Mluvení...", + "SERVER_NOT_FOUND": "Hledání dostupné služby", + "SERVER_NOT_CONNECTED": "Nelze se připojit ke službě, zkuste to později", + "SERVER_TIMEOUT": "Čas odpovědi vypršel", + "SERVER_ERROR": "Odeslání selhalo, zkontrolujte síť", + "CONNECT_TO_HOTSPOT": "Připojte telefon k hotspotu ", + "ACCESS_VIA_BROWSER": ",přístup přes prohlížeč ", + "WIFI_CONFIG_MODE": "Režim konfigurace sítě", + "ENTERING_WIFI_CONFIG_MODE": "Vstup do režimu konfigurace sítě...", + "SCANNING_WIFI": "Skenování Wi-Fi...", + "NEW_VERSION": "Nová verze ", + "OTA_UPGRADE": "OTA upgrade", + "UPGRADING": "Aktualizace systému...", + "UPGRADE_FAILED": "Upgrade selhal", + "ACTIVATION": "Aktivace zařízení", + "BATTERY_LOW": "Slabá baterie", + "BATTERY_CHARGING": "Nabíjení", + "BATTERY_FULL": "Baterie plná", + "BATTERY_NEED_CHARGE": "Slabá baterie, prosím nabijte", + "VOLUME": "Hlasitost ", + "MUTED": "Ztlumeno", + "MAX_VOLUME": "Maximální hlasitost", + "RTC_MODE_OFF": "AEC vypnuto", + "RTC_MODE_ON": "AEC zapnuto", + "DOWNLOAD_ASSETS_FAILED": "Nepodařilo se stáhnout prostředky", + "LOADING_ASSETS": "Načítání prostředků...", + "PLEASE_WAIT": "Prosím čekejte...", + "FOUND_NEW_ASSETS": "Nalezeny nové prostředky: %s", + "HELLO_MY_FRIEND": "Ahoj, můj příteli!", + "CONNECTION_SUCCESSFUL": "Připojení úspěšné", + "FLIGHT_MODE_OFF": "Letecký režim je vypnutý", + "FLIGHT_MODE_ON": "Letecký režim je zapnutý", + "MODEM_INIT_ERROR": "Chyba inicializace modemu" + } +} \ No newline at end of file diff --git a/main/assets/locales/cs-CZ/upgrade.ogg b/main/assets/locales/cs-CZ/upgrade.ogg new file mode 100644 index 0000000..e29edc5 Binary files /dev/null and b/main/assets/locales/cs-CZ/upgrade.ogg differ diff --git a/main/assets/locales/cs-CZ/welcome.ogg b/main/assets/locales/cs-CZ/welcome.ogg new file mode 100644 index 0000000..7a0625e Binary files /dev/null and b/main/assets/locales/cs-CZ/welcome.ogg differ diff --git a/main/assets/locales/cs-CZ/wificonfig.ogg b/main/assets/locales/cs-CZ/wificonfig.ogg new file mode 100644 index 0000000..bc945a9 Binary files /dev/null and b/main/assets/locales/cs-CZ/wificonfig.ogg differ diff --git a/main/assets/locales/da-DK/0.ogg b/main/assets/locales/da-DK/0.ogg new file mode 100644 index 0000000..b27207c Binary files /dev/null and b/main/assets/locales/da-DK/0.ogg differ diff --git a/main/assets/locales/da-DK/1.ogg b/main/assets/locales/da-DK/1.ogg new file mode 100644 index 0000000..5fd2fce Binary files /dev/null and b/main/assets/locales/da-DK/1.ogg differ diff --git a/main/assets/locales/da-DK/2.ogg b/main/assets/locales/da-DK/2.ogg new file mode 100644 index 0000000..f3be2ac Binary files /dev/null and b/main/assets/locales/da-DK/2.ogg differ diff --git a/main/assets/locales/da-DK/3.ogg b/main/assets/locales/da-DK/3.ogg new file mode 100644 index 0000000..3ad6f10 Binary files /dev/null and b/main/assets/locales/da-DK/3.ogg differ diff --git a/main/assets/locales/da-DK/4.ogg b/main/assets/locales/da-DK/4.ogg new file mode 100644 index 0000000..8684e63 Binary files /dev/null and b/main/assets/locales/da-DK/4.ogg differ diff --git a/main/assets/locales/da-DK/5.ogg b/main/assets/locales/da-DK/5.ogg new file mode 100644 index 0000000..29559fd Binary files /dev/null and b/main/assets/locales/da-DK/5.ogg differ diff --git a/main/assets/locales/da-DK/6.ogg b/main/assets/locales/da-DK/6.ogg new file mode 100644 index 0000000..d0b9ad7 Binary files /dev/null and b/main/assets/locales/da-DK/6.ogg differ diff --git a/main/assets/locales/da-DK/7.ogg b/main/assets/locales/da-DK/7.ogg new file mode 100644 index 0000000..28ecaca Binary files /dev/null and b/main/assets/locales/da-DK/7.ogg differ diff --git a/main/assets/locales/da-DK/8.ogg b/main/assets/locales/da-DK/8.ogg new file mode 100644 index 0000000..2d71851 Binary files /dev/null and b/main/assets/locales/da-DK/8.ogg differ diff --git a/main/assets/locales/da-DK/9.ogg b/main/assets/locales/da-DK/9.ogg new file mode 100644 index 0000000..11af485 Binary files /dev/null and b/main/assets/locales/da-DK/9.ogg differ diff --git a/main/assets/locales/da-DK/activation.ogg b/main/assets/locales/da-DK/activation.ogg new file mode 100644 index 0000000..58b9b43 Binary files /dev/null and b/main/assets/locales/da-DK/activation.ogg differ diff --git a/main/assets/locales/da-DK/err_pin.ogg b/main/assets/locales/da-DK/err_pin.ogg new file mode 100644 index 0000000..391d984 Binary files /dev/null and b/main/assets/locales/da-DK/err_pin.ogg differ diff --git a/main/assets/locales/da-DK/err_reg.ogg b/main/assets/locales/da-DK/err_reg.ogg new file mode 100644 index 0000000..199f807 Binary files /dev/null and b/main/assets/locales/da-DK/err_reg.ogg differ diff --git a/main/assets/locales/da-DK/language.json b/main/assets/locales/da-DK/language.json new file mode 100644 index 0000000..96c9d72 --- /dev/null +++ b/main/assets/locales/da-DK/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "da-DK" + }, + "strings": { + "WARNING": "Advarsel", + "INFO": "Information", + "ERROR": "Fejl", + "VERSION": "Version ", + "LOADING_PROTOCOL": "Logger ind...", + "INITIALIZING": "Initialiserer...", + "PIN_ERROR": "Indsæt venligst SIM-kort", + "REG_ERROR": "Kan ikke få adgang til netværket, tjek venligst SIM-kortets status", + "DETECTING_MODULE": "Detekterer modul...", + "REGISTERING_NETWORK": "Venter på netværk...", + "CHECKING_NEW_VERSION": "Tjekker for ny version...", + "CHECK_NEW_VERSION_FAILED": "Tjek for ny version mislykkedes, prøver igen om %d sekunder: %s", + "SWITCH_TO_WIFI_NETWORK": "Skifter til Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Skifter til 4G...", + "STANDBY": "Standby", + "CONNECT_TO": "Forbind til ", + "CONNECTING": "Forbinder...", + "CONNECTION_SUCCESSFUL": "Forbindelse lykkedes", + "CONNECTED_TO": "Forbundet til ", + "LISTENING": "Lytter...", + "SPEAKING": "Taler...", + "SERVER_NOT_FOUND": "Søger efter tilgængelig tjeneste", + "SERVER_NOT_CONNECTED": "Kan ikke forbinde til tjeneste, prøv venligst igen senere", + "SERVER_TIMEOUT": "Timeout ved venten på svar", + "SERVER_ERROR": "Afsendelse mislykkedes, tjek venligst netværket", + "CONNECT_TO_HOTSPOT": "Hotspot: ", + "ACCESS_VIA_BROWSER": " Konfigurations-URL: ", + "WIFI_CONFIG_MODE": "Wi-Fi-konfigurationstilstand", + "ENTERING_WIFI_CONFIG_MODE": "Går ind i Wi-Fi-konfigurationstilstand...", + "SCANNING_WIFI": "Scanner Wi-Fi...", + "NEW_VERSION": "Ny version ", + "OTA_UPGRADE": "OTA-opgradering", + "UPGRADING": "Systemet opgraderes...", + "UPGRADE_FAILED": "Opgradering mislykkedes", + "ACTIVATION": "Aktivering", + "BATTERY_LOW": "Lavt batteri", + "BATTERY_CHARGING": "Oplader", + "BATTERY_FULL": "Batteriet er fuldt", + "BATTERY_NEED_CHARGE": "Lavt batteri, oplad venligst", + "VOLUME": "Lydstyrke ", + "MUTED": "Lydløs", + "MAX_VOLUME": "Maksimal lydstyrke", + "RTC_MODE_OFF": "AEC slukket", + "RTC_MODE_ON": "AEC tændt", + "PLEASE_WAIT": "Vent venligst...", + "FOUND_NEW_ASSETS": "Fandt nye ressourcer: %s", + "DOWNLOAD_ASSETS_FAILED": "Download af ressourcer mislykkedes", + "LOADING_ASSETS": "Indlæser ressourcer...", + "HELLO_MY_FRIEND": "Hej, min ven!", + "FLIGHT_MODE_OFF": "Flytilstand er slukket", + "FLIGHT_MODE_ON": "Flytilstand er tændt", + "MODEM_INIT_ERROR": "Modeminitialisering mislykkedes" + } +} \ No newline at end of file diff --git a/main/assets/locales/da-DK/upgrade.ogg b/main/assets/locales/da-DK/upgrade.ogg new file mode 100644 index 0000000..b3e1c29 Binary files /dev/null and b/main/assets/locales/da-DK/upgrade.ogg differ diff --git a/main/assets/locales/da-DK/welcome.ogg b/main/assets/locales/da-DK/welcome.ogg new file mode 100644 index 0000000..f211cc4 Binary files /dev/null and b/main/assets/locales/da-DK/welcome.ogg differ diff --git a/main/assets/locales/da-DK/wificonfig.ogg b/main/assets/locales/da-DK/wificonfig.ogg new file mode 100644 index 0000000..33ea9c5 Binary files /dev/null and b/main/assets/locales/da-DK/wificonfig.ogg differ diff --git a/main/assets/locales/de-DE/0.ogg b/main/assets/locales/de-DE/0.ogg new file mode 100644 index 0000000..a3cdb23 Binary files /dev/null and b/main/assets/locales/de-DE/0.ogg differ diff --git a/main/assets/locales/de-DE/1.ogg b/main/assets/locales/de-DE/1.ogg new file mode 100644 index 0000000..fb4fd93 Binary files /dev/null and b/main/assets/locales/de-DE/1.ogg differ diff --git a/main/assets/locales/de-DE/2.ogg b/main/assets/locales/de-DE/2.ogg new file mode 100644 index 0000000..fb09b60 Binary files /dev/null and b/main/assets/locales/de-DE/2.ogg differ diff --git a/main/assets/locales/de-DE/3.ogg b/main/assets/locales/de-DE/3.ogg new file mode 100644 index 0000000..d3f7549 Binary files /dev/null and b/main/assets/locales/de-DE/3.ogg differ diff --git a/main/assets/locales/de-DE/4.ogg b/main/assets/locales/de-DE/4.ogg new file mode 100644 index 0000000..46db0ce Binary files /dev/null and b/main/assets/locales/de-DE/4.ogg differ diff --git a/main/assets/locales/de-DE/5.ogg b/main/assets/locales/de-DE/5.ogg new file mode 100644 index 0000000..3ceee0f Binary files /dev/null and b/main/assets/locales/de-DE/5.ogg differ diff --git a/main/assets/locales/de-DE/6.ogg b/main/assets/locales/de-DE/6.ogg new file mode 100644 index 0000000..8942550 Binary files /dev/null and b/main/assets/locales/de-DE/6.ogg differ diff --git a/main/assets/locales/de-DE/7.ogg b/main/assets/locales/de-DE/7.ogg new file mode 100644 index 0000000..3b6957d Binary files /dev/null and b/main/assets/locales/de-DE/7.ogg differ diff --git a/main/assets/locales/de-DE/8.ogg b/main/assets/locales/de-DE/8.ogg new file mode 100644 index 0000000..421f018 Binary files /dev/null and b/main/assets/locales/de-DE/8.ogg differ diff --git a/main/assets/locales/de-DE/9.ogg b/main/assets/locales/de-DE/9.ogg new file mode 100644 index 0000000..40b7876 Binary files /dev/null and b/main/assets/locales/de-DE/9.ogg differ diff --git a/main/assets/locales/de-DE/activation.ogg b/main/assets/locales/de-DE/activation.ogg new file mode 100644 index 0000000..3dfccbd Binary files /dev/null and b/main/assets/locales/de-DE/activation.ogg differ diff --git a/main/assets/locales/de-DE/err_pin.ogg b/main/assets/locales/de-DE/err_pin.ogg new file mode 100644 index 0000000..8d2eef9 Binary files /dev/null and b/main/assets/locales/de-DE/err_pin.ogg differ diff --git a/main/assets/locales/de-DE/err_reg.ogg b/main/assets/locales/de-DE/err_reg.ogg new file mode 100644 index 0000000..430b876 Binary files /dev/null and b/main/assets/locales/de-DE/err_reg.ogg differ diff --git a/main/assets/locales/de-DE/language.json b/main/assets/locales/de-DE/language.json new file mode 100644 index 0000000..20dfbca --- /dev/null +++ b/main/assets/locales/de-DE/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "de-DE" + }, + "strings": { + "WARNING": "Warnung", + "INFO": "Information", + "ERROR": "Fehler", + "VERSION": "Version ", + "LOADING_PROTOCOL": "Verbindung zum Server...", + "INITIALIZING": "Initialisierung...", + "PIN_ERROR": "Bitte SIM-Karte einlegen", + "REG_ERROR": "Netzwerkverbindung fehlgeschlagen, bitte Datenkartenstatus prüfen", + "DETECTING_MODULE": "Modul erkennen...", + "REGISTERING_NETWORK": "Auf Netzwerk warten...", + "CHECKING_NEW_VERSION": "Neue Version prüfen...", + "CHECK_NEW_VERSION_FAILED": "Neue Version prüfen fehlgeschlagen, Wiederholung in %d Sekunden: %s", + "SWITCH_TO_WIFI_NETWORK": "Zu Wi-Fi wechseln...", + "SWITCH_TO_4G_NETWORK": "Zu 4G wechseln...", + "STANDBY": "Bereitschaft", + "CONNECT_TO": "Verbinden zu ", + "CONNECTING": "Verbindung wird hergestellt...", + "CONNECTED_TO": "Verbunden mit ", + "LISTENING": "Zuhören...", + "SPEAKING": "Sprechen...", + "SERVER_NOT_FOUND": "Verfügbaren Service suchen", + "SERVER_NOT_CONNECTED": "Service-Verbindung fehlgeschlagen, bitte später versuchen", + "SERVER_TIMEOUT": "Antwort-Timeout", + "SERVER_ERROR": "Senden fehlgeschlagen, bitte Netzwerk prüfen", + "CONNECT_TO_HOTSPOT": "Handy mit Hotspot verbinden ", + "ACCESS_VIA_BROWSER": ",Browser öffnen ", + "WIFI_CONFIG_MODE": "Netzwerkkonfigurationsmodus", + "ENTERING_WIFI_CONFIG_MODE": "Netzwerkkonfigurationsmodus eingeben...", + "SCANNING_WIFI": "Wi-Fi scannen...", + "NEW_VERSION": "Neue Version ", + "OTA_UPGRADE": "OTA-Upgrade", + "UPGRADING": "System wird aktualisiert...", + "UPGRADE_FAILED": "Upgrade fehlgeschlagen", + "ACTIVATION": "Gerät aktivieren", + "BATTERY_LOW": "Niedriger Batteriestand", + "BATTERY_CHARGING": "Wird geladen", + "BATTERY_FULL": "Batterie voll", + "BATTERY_NEED_CHARGE": "Niedriger Batteriestand, bitte aufladen", + "VOLUME": "Lautstärke ", + "MUTED": "Stummgeschaltet", + "MAX_VOLUME": "Maximale Lautstärke", + "RTC_MODE_OFF": "AEC aus", + "RTC_MODE_ON": "AEC ein", + "DOWNLOAD_ASSETS_FAILED": "Fehler beim Herunterladen der Ressourcen", + "LOADING_ASSETS": "Ressourcen werden geladen...", + "PLEASE_WAIT": "Bitte warten...", + "FOUND_NEW_ASSETS": "Neue Ressourcen gefunden: %s", + "HELLO_MY_FRIEND": "Hallo, mein Freund!", + "CONNECTION_SUCCESSFUL": "Verbindung erfolgreich", + "FLIGHT_MODE_OFF": "Flugmodus ist deaktiviert", + "FLIGHT_MODE_ON": "Flugmodus ist aktiviert", + "MODEM_INIT_ERROR": "Modem-Initialisierung fehlgeschlagen" + } +} \ No newline at end of file diff --git a/main/assets/locales/de-DE/upgrade.ogg b/main/assets/locales/de-DE/upgrade.ogg new file mode 100644 index 0000000..716d7cb Binary files /dev/null and b/main/assets/locales/de-DE/upgrade.ogg differ diff --git a/main/assets/locales/de-DE/welcome.ogg b/main/assets/locales/de-DE/welcome.ogg new file mode 100644 index 0000000..69271ce Binary files /dev/null and b/main/assets/locales/de-DE/welcome.ogg differ diff --git a/main/assets/locales/de-DE/wificonfig.ogg b/main/assets/locales/de-DE/wificonfig.ogg new file mode 100644 index 0000000..a3d753d Binary files /dev/null and b/main/assets/locales/de-DE/wificonfig.ogg differ diff --git a/main/assets/locales/el-GR/0.ogg b/main/assets/locales/el-GR/0.ogg new file mode 100644 index 0000000..b06bdb5 Binary files /dev/null and b/main/assets/locales/el-GR/0.ogg differ diff --git a/main/assets/locales/el-GR/1.ogg b/main/assets/locales/el-GR/1.ogg new file mode 100644 index 0000000..4a9b7d2 Binary files /dev/null and b/main/assets/locales/el-GR/1.ogg differ diff --git a/main/assets/locales/el-GR/2.ogg b/main/assets/locales/el-GR/2.ogg new file mode 100644 index 0000000..eeb149d Binary files /dev/null and b/main/assets/locales/el-GR/2.ogg differ diff --git a/main/assets/locales/el-GR/3.ogg b/main/assets/locales/el-GR/3.ogg new file mode 100644 index 0000000..5364664 Binary files /dev/null and b/main/assets/locales/el-GR/3.ogg differ diff --git a/main/assets/locales/el-GR/4.ogg b/main/assets/locales/el-GR/4.ogg new file mode 100644 index 0000000..cbdf259 Binary files /dev/null and b/main/assets/locales/el-GR/4.ogg differ diff --git a/main/assets/locales/el-GR/5.ogg b/main/assets/locales/el-GR/5.ogg new file mode 100644 index 0000000..1e93b72 Binary files /dev/null and b/main/assets/locales/el-GR/5.ogg differ diff --git a/main/assets/locales/el-GR/6.ogg b/main/assets/locales/el-GR/6.ogg new file mode 100644 index 0000000..a831a42 Binary files /dev/null and b/main/assets/locales/el-GR/6.ogg differ diff --git a/main/assets/locales/el-GR/7.ogg b/main/assets/locales/el-GR/7.ogg new file mode 100644 index 0000000..1c64b31 Binary files /dev/null and b/main/assets/locales/el-GR/7.ogg differ diff --git a/main/assets/locales/el-GR/8.ogg b/main/assets/locales/el-GR/8.ogg new file mode 100644 index 0000000..d7a9696 Binary files /dev/null and b/main/assets/locales/el-GR/8.ogg differ diff --git a/main/assets/locales/el-GR/9.ogg b/main/assets/locales/el-GR/9.ogg new file mode 100644 index 0000000..8ff0b07 Binary files /dev/null and b/main/assets/locales/el-GR/9.ogg differ diff --git a/main/assets/locales/el-GR/activation.ogg b/main/assets/locales/el-GR/activation.ogg new file mode 100644 index 0000000..aa8b401 Binary files /dev/null and b/main/assets/locales/el-GR/activation.ogg differ diff --git a/main/assets/locales/el-GR/err_pin.ogg b/main/assets/locales/el-GR/err_pin.ogg new file mode 100644 index 0000000..7c10086 Binary files /dev/null and b/main/assets/locales/el-GR/err_pin.ogg differ diff --git a/main/assets/locales/el-GR/err_reg.ogg b/main/assets/locales/el-GR/err_reg.ogg new file mode 100644 index 0000000..f65513a Binary files /dev/null and b/main/assets/locales/el-GR/err_reg.ogg differ diff --git a/main/assets/locales/el-GR/language.json b/main/assets/locales/el-GR/language.json new file mode 100644 index 0000000..287db5e --- /dev/null +++ b/main/assets/locales/el-GR/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "el-GR" + }, + "strings": { + "WARNING": "Προειδοποίηση", + "INFO": "Πληροφορίες", + "ERROR": "Σφάλμα", + "VERSION": "Έκδοση ", + "LOADING_PROTOCOL": "Σύνδεση...", + "INITIALIZING": "Αρχικοποίηση...", + "PIN_ERROR": "Παρακαλώ εισαγάγετε κάρτα SIM", + "REG_ERROR": "Αδυναμία πρόσβασης στο δίκτυο, ελέγξτε την κατάσταση της κάρτας SIM", + "DETECTING_MODULE": "Ανίχνευση μονάδας...", + "REGISTERING_NETWORK": "Αναμονή δικτύου...", + "CHECKING_NEW_VERSION": "Έλεγχος για νέα έκδοση...", + "CHECK_NEW_VERSION_FAILED": "Ο έλεγχος για νέα έκδοση απέτυχε, θα επαναληφθεί σε %d δευτερόλεπτα: %s", + "SWITCH_TO_WIFI_NETWORK": "Μετάβαση σε Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Μετάβαση σε 4G...", + "STANDBY": "Αναμονή", + "CONNECT_TO": "Σύνδεση σε ", + "CONNECTING": "Σύνδεση...", + "CONNECTION_SUCCESSFUL": "Επιτυχής σύνδεση", + "CONNECTED_TO": "Συνδέθηκε σε ", + "LISTENING": "Ακρόαση...", + "SPEAKING": "Ομιλία...", + "SERVER_NOT_FOUND": "Αναζήτηση διαθέσιμης υπηρεσίας", + "SERVER_NOT_CONNECTED": "Αδυναμία σύνδεσης στην υπηρεσία, παρακαλώ δοκιμάστε αργότερα", + "SERVER_TIMEOUT": "Λήξη χρόνου αναμονής απόκρισης", + "SERVER_ERROR": "Η αποστολή απέτυχε, ελέγξτε το δίκτυο", + "CONNECT_TO_HOTSPOT": "Σημείο πρόσβασης: ", + "ACCESS_VIA_BROWSER": " URL διαμόρφωσης: ", + "WIFI_CONFIG_MODE": "Λειτουργία διαμόρφωσης Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "Είσοδος σε λειτουργία διαμόρφωσης Wi-Fi...", + "SCANNING_WIFI": "Σάρωση Wi-Fi...", + "NEW_VERSION": "Νέα έκδοση ", + "OTA_UPGRADE": "Αναβάθμιση OTA", + "UPGRADING": "Το σύστημα αναβαθμίζεται...", + "UPGRADE_FAILED": "Η αναβάθμιση απέτυχε", + "ACTIVATION": "Ενεργοποίηση", + "BATTERY_LOW": "Χαμηλή μπαταρία", + "BATTERY_CHARGING": "Φόρτιση", + "BATTERY_FULL": "Πλήρης μπαταρία", + "BATTERY_NEED_CHARGE": "Χαμηλή μπαταρία, παρακαλώ φορτίστε", + "VOLUME": "Ένταση ", + "MUTED": "Σίγαση", + "MAX_VOLUME": "Μέγιστη ένταση", + "RTC_MODE_OFF": "AEC απενεργοποιημένο", + "RTC_MODE_ON": "AEC ενεργοποιημένο", + "PLEASE_WAIT": "Παρακαλώ περιμένετε...", + "FOUND_NEW_ASSETS": "Βρέθηκαν νέοι πόροι: %s", + "DOWNLOAD_ASSETS_FAILED": "Αποτυχία λήψης πόρων", + "LOADING_ASSETS": "Φόρτωση πόρων...", + "HELLO_MY_FRIEND": "Γεια σου, φίλε μου!", + "FLIGHT_MODE_OFF": "Η λειτουργία πτήσης είναι απενεργοποιημένη", + "FLIGHT_MODE_ON": "Η λειτουργία πτήσης είναι ενεργή", + "MODEM_INIT_ERROR": "Αποτυχία αρχικοποίησης modem" + } +} \ No newline at end of file diff --git a/main/assets/locales/el-GR/upgrade.ogg b/main/assets/locales/el-GR/upgrade.ogg new file mode 100644 index 0000000..81bef14 Binary files /dev/null and b/main/assets/locales/el-GR/upgrade.ogg differ diff --git a/main/assets/locales/el-GR/welcome.ogg b/main/assets/locales/el-GR/welcome.ogg new file mode 100644 index 0000000..c7a2c7c Binary files /dev/null and b/main/assets/locales/el-GR/welcome.ogg differ diff --git a/main/assets/locales/el-GR/wificonfig.ogg b/main/assets/locales/el-GR/wificonfig.ogg new file mode 100644 index 0000000..fe3eb66 Binary files /dev/null and b/main/assets/locales/el-GR/wificonfig.ogg differ diff --git a/main/assets/locales/en-US/0.ogg b/main/assets/locales/en-US/0.ogg new file mode 100644 index 0000000..51dff41 Binary files /dev/null and b/main/assets/locales/en-US/0.ogg differ diff --git a/main/assets/locales/en-US/1.ogg b/main/assets/locales/en-US/1.ogg new file mode 100644 index 0000000..814753e Binary files /dev/null and b/main/assets/locales/en-US/1.ogg differ diff --git a/main/assets/locales/en-US/2.ogg b/main/assets/locales/en-US/2.ogg new file mode 100644 index 0000000..420a994 Binary files /dev/null and b/main/assets/locales/en-US/2.ogg differ diff --git a/main/assets/locales/en-US/3.ogg b/main/assets/locales/en-US/3.ogg new file mode 100644 index 0000000..1d3fa8a Binary files /dev/null and b/main/assets/locales/en-US/3.ogg differ diff --git a/main/assets/locales/en-US/4.ogg b/main/assets/locales/en-US/4.ogg new file mode 100644 index 0000000..8e364ad Binary files /dev/null and b/main/assets/locales/en-US/4.ogg differ diff --git a/main/assets/locales/en-US/5.ogg b/main/assets/locales/en-US/5.ogg new file mode 100644 index 0000000..5be437d Binary files /dev/null and b/main/assets/locales/en-US/5.ogg differ diff --git a/main/assets/locales/en-US/6.ogg b/main/assets/locales/en-US/6.ogg new file mode 100644 index 0000000..e398409 Binary files /dev/null and b/main/assets/locales/en-US/6.ogg differ diff --git a/main/assets/locales/en-US/7.ogg b/main/assets/locales/en-US/7.ogg new file mode 100644 index 0000000..879aebd Binary files /dev/null and b/main/assets/locales/en-US/7.ogg differ diff --git a/main/assets/locales/en-US/8.ogg b/main/assets/locales/en-US/8.ogg new file mode 100644 index 0000000..0e9efd2 Binary files /dev/null and b/main/assets/locales/en-US/8.ogg differ diff --git a/main/assets/locales/en-US/9.ogg b/main/assets/locales/en-US/9.ogg new file mode 100644 index 0000000..434c320 Binary files /dev/null and b/main/assets/locales/en-US/9.ogg differ diff --git a/main/assets/locales/en-US/activation.ogg b/main/assets/locales/en-US/activation.ogg new file mode 100644 index 0000000..0e9b843 Binary files /dev/null and b/main/assets/locales/en-US/activation.ogg differ diff --git a/main/assets/locales/en-US/err_pin.ogg b/main/assets/locales/en-US/err_pin.ogg new file mode 100644 index 0000000..c52fdf4 Binary files /dev/null and b/main/assets/locales/en-US/err_pin.ogg differ diff --git a/main/assets/locales/en-US/err_reg.ogg b/main/assets/locales/en-US/err_reg.ogg new file mode 100644 index 0000000..95593f4 Binary files /dev/null and b/main/assets/locales/en-US/err_reg.ogg differ diff --git a/main/assets/locales/en-US/language.json b/main/assets/locales/en-US/language.json new file mode 100644 index 0000000..8bb764a --- /dev/null +++ b/main/assets/locales/en-US/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "en-US" + }, + "strings": { + "WARNING": "Warning", + "INFO": "Information", + "ERROR": "Error", + "VERSION": "Ver ", + "LOADING_PROTOCOL": "Logging in...", + "INITIALIZING": "Initializing...", + "PIN_ERROR": "Please insert SIM card", + "REG_ERROR": "Unable to access network, please check SIM card status", + "MODEM_INIT_ERROR": "Modem initialization failed", + "DETECTING_MODULE": "Detecting module...", + "FLIGHT_MODE_ON": "Flight mode is on", + "FLIGHT_MODE_OFF": "Flight mode is off", + "REGISTERING_NETWORK": "Waiting for network...", + "CHECKING_NEW_VERSION": "Checking for new version...", + "CHECK_NEW_VERSION_FAILED": "Check for new version failed, will retry in %d seconds: %s", + "SWITCH_TO_WIFI_NETWORK": "Switching to Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Switching to 4G...", + "STANDBY": "Standby", + "CONNECT_TO": "Connect to ", + "CONNECTING": "Connecting...", + "CONNECTION_SUCCESSFUL": "Connection Successful", + "CONNECTED_TO": "Connected to ", + "LISTENING": "Listening...", + "SPEAKING": "Speaking...", + "SERVER_NOT_FOUND": "Looking for available service", + "SERVER_NOT_CONNECTED": "Unable to connect to service, please try again later", + "SERVER_TIMEOUT": "Waiting for response timeout", + "SERVER_ERROR": "Sending failed, please check the network", + "CONNECT_TO_HOTSPOT": "Hotspot: ", + "ACCESS_VIA_BROWSER": " Config URL: ", + "WIFI_CONFIG_MODE": "Wi-Fi Configuration Mode", + "ENTERING_WIFI_CONFIG_MODE": "Entering Wi-Fi configuration mode...", + "SCANNING_WIFI": "Scanning Wi-Fi...", + "NEW_VERSION": "New version ", + "OTA_UPGRADE": "OTA Upgrade", + "UPGRADING": "System is upgrading...", + "UPGRADE_FAILED": "Upgrade failed", + "ACTIVATION": "Activation", + "BATTERY_LOW": "Low battery", + "BATTERY_CHARGING": "Charging", + "BATTERY_FULL": "Battery full", + "BATTERY_NEED_CHARGE": "Low battery, please charge", + "VOLUME": "Volume ", + "MUTED": "Muted", + "MAX_VOLUME": "Max volume", + "RTC_MODE_OFF": "AEC Off", + "RTC_MODE_ON": "AEC On", + "PLEASE_WAIT": "Please wait...", + "FOUND_NEW_ASSETS": "Found new assets: %s", + "DOWNLOAD_ASSETS_FAILED": "Failed to download assets", + "LOADING_ASSETS": "Loading assets...", + "HELLO_MY_FRIEND": "Hello, my friend!" + } +} \ No newline at end of file diff --git a/main/assets/locales/en-US/upgrade.ogg b/main/assets/locales/en-US/upgrade.ogg new file mode 100644 index 0000000..f27ec7a Binary files /dev/null and b/main/assets/locales/en-US/upgrade.ogg differ diff --git a/main/assets/locales/en-US/welcome.ogg b/main/assets/locales/en-US/welcome.ogg new file mode 100644 index 0000000..4d4d8b9 Binary files /dev/null and b/main/assets/locales/en-US/welcome.ogg differ diff --git a/main/assets/locales/en-US/wificonfig.ogg b/main/assets/locales/en-US/wificonfig.ogg new file mode 100644 index 0000000..f4ed3d4 Binary files /dev/null and b/main/assets/locales/en-US/wificonfig.ogg differ diff --git a/main/assets/locales/es-ES/0.ogg b/main/assets/locales/es-ES/0.ogg new file mode 100644 index 0000000..98fbba2 Binary files /dev/null and b/main/assets/locales/es-ES/0.ogg differ diff --git a/main/assets/locales/es-ES/1.ogg b/main/assets/locales/es-ES/1.ogg new file mode 100644 index 0000000..2c2f4fb Binary files /dev/null and b/main/assets/locales/es-ES/1.ogg differ diff --git a/main/assets/locales/es-ES/2.ogg b/main/assets/locales/es-ES/2.ogg new file mode 100644 index 0000000..8e962b9 Binary files /dev/null and b/main/assets/locales/es-ES/2.ogg differ diff --git a/main/assets/locales/es-ES/3.ogg b/main/assets/locales/es-ES/3.ogg new file mode 100644 index 0000000..6b6ba47 Binary files /dev/null and b/main/assets/locales/es-ES/3.ogg differ diff --git a/main/assets/locales/es-ES/4.ogg b/main/assets/locales/es-ES/4.ogg new file mode 100644 index 0000000..ae5909c Binary files /dev/null and b/main/assets/locales/es-ES/4.ogg differ diff --git a/main/assets/locales/es-ES/5.ogg b/main/assets/locales/es-ES/5.ogg new file mode 100644 index 0000000..a8a4ca3 Binary files /dev/null and b/main/assets/locales/es-ES/5.ogg differ diff --git a/main/assets/locales/es-ES/6.ogg b/main/assets/locales/es-ES/6.ogg new file mode 100644 index 0000000..bcc46c0 Binary files /dev/null and b/main/assets/locales/es-ES/6.ogg differ diff --git a/main/assets/locales/es-ES/7.ogg b/main/assets/locales/es-ES/7.ogg new file mode 100644 index 0000000..09e7df4 Binary files /dev/null and b/main/assets/locales/es-ES/7.ogg differ diff --git a/main/assets/locales/es-ES/8.ogg b/main/assets/locales/es-ES/8.ogg new file mode 100644 index 0000000..e06e023 Binary files /dev/null and b/main/assets/locales/es-ES/8.ogg differ diff --git a/main/assets/locales/es-ES/9.ogg b/main/assets/locales/es-ES/9.ogg new file mode 100644 index 0000000..4108458 Binary files /dev/null and b/main/assets/locales/es-ES/9.ogg differ diff --git a/main/assets/locales/es-ES/activation.ogg b/main/assets/locales/es-ES/activation.ogg new file mode 100644 index 0000000..8d50682 Binary files /dev/null and b/main/assets/locales/es-ES/activation.ogg differ diff --git a/main/assets/locales/es-ES/err_pin.ogg b/main/assets/locales/es-ES/err_pin.ogg new file mode 100644 index 0000000..b3e55f8 Binary files /dev/null and b/main/assets/locales/es-ES/err_pin.ogg differ diff --git a/main/assets/locales/es-ES/err_reg.ogg b/main/assets/locales/es-ES/err_reg.ogg new file mode 100644 index 0000000..509451e Binary files /dev/null and b/main/assets/locales/es-ES/err_reg.ogg differ diff --git a/main/assets/locales/es-ES/language.json b/main/assets/locales/es-ES/language.json new file mode 100644 index 0000000..9722fc4 --- /dev/null +++ b/main/assets/locales/es-ES/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "es-ES" + }, + "strings": { + "WARNING": "Advertencia", + "INFO": "Información", + "ERROR": "Error", + "VERSION": "Versión ", + "LOADING_PROTOCOL": "Conectando al servidor...", + "INITIALIZING": "Inicializando...", + "PIN_ERROR": "Por favor inserte la tarjeta SIM", + "REG_ERROR": "No se puede acceder a la red, verifique el estado de la tarjeta de datos", + "DETECTING_MODULE": "Detectando módulo...", + "REGISTERING_NETWORK": "Esperando red...", + "CHECKING_NEW_VERSION": "Verificando nueva versión...", + "CHECK_NEW_VERSION_FAILED": "Error al verificar nueva versión, reintentando en %d segundos: %s", + "SWITCH_TO_WIFI_NETWORK": "Cambiando a Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Cambiando a 4G...", + "STANDBY": "En espera", + "CONNECT_TO": "Conectar a ", + "CONNECTING": "Conectando...", + "CONNECTED_TO": "Conectado a ", + "LISTENING": "Escuchando...", + "SPEAKING": "Hablando...", + "SERVER_NOT_FOUND": "Buscando servicio disponible", + "SERVER_NOT_CONNECTED": "No se puede conectar al servicio, inténtelo más tarde", + "SERVER_TIMEOUT": "Tiempo de espera agotado", + "SERVER_ERROR": "Error de envío, verifique la red", + "CONNECT_TO_HOTSPOT": "Conectar teléfono al punto de acceso ", + "ACCESS_VIA_BROWSER": ",acceder mediante navegador ", + "WIFI_CONFIG_MODE": "Modo configuración de red", + "ENTERING_WIFI_CONFIG_MODE": "Entrando en modo configuración de red...", + "SCANNING_WIFI": "Escaneando Wi-Fi...", + "NEW_VERSION": "Nueva versión ", + "OTA_UPGRADE": "Actualización OTA", + "UPGRADING": "Actualizando sistema...", + "UPGRADE_FAILED": "Actualización fallida", + "ACTIVATION": "Activación del dispositivo", + "BATTERY_LOW": "Batería baja", + "BATTERY_CHARGING": "Cargando", + "BATTERY_FULL": "Batería llena", + "BATTERY_NEED_CHARGE": "Batería baja, por favor cargar", + "VOLUME": "Volumen ", + "MUTED": "Silenciado", + "MAX_VOLUME": "Volumen máximo", + "RTC_MODE_OFF": "AEC desactivado", + "RTC_MODE_ON": "AEC activado", + "DOWNLOAD_ASSETS_FAILED": "Error al descargar recursos", + "LOADING_ASSETS": "Cargando recursos...", + "PLEASE_WAIT": "Por favor espere...", + "FOUND_NEW_ASSETS": "Encontrados nuevos recursos: %s", + "HELLO_MY_FRIEND": "¡Hola, mi amigo!", + "CONNECTION_SUCCESSFUL": "Conexión exitosa", + "FLIGHT_MODE_OFF": "El modo avión está desactivado", + "FLIGHT_MODE_ON": "El modo avión está activado", + "MODEM_INIT_ERROR": "Error de inicialización del módem" + } +} \ No newline at end of file diff --git a/main/assets/locales/es-ES/upgrade.ogg b/main/assets/locales/es-ES/upgrade.ogg new file mode 100644 index 0000000..c29826c Binary files /dev/null and b/main/assets/locales/es-ES/upgrade.ogg differ diff --git a/main/assets/locales/es-ES/welcome.ogg b/main/assets/locales/es-ES/welcome.ogg new file mode 100644 index 0000000..a506db5 Binary files /dev/null and b/main/assets/locales/es-ES/welcome.ogg differ diff --git a/main/assets/locales/es-ES/wificonfig.ogg b/main/assets/locales/es-ES/wificonfig.ogg new file mode 100644 index 0000000..11755fa Binary files /dev/null and b/main/assets/locales/es-ES/wificonfig.ogg differ diff --git a/main/assets/locales/fa-IR/0.ogg b/main/assets/locales/fa-IR/0.ogg new file mode 100644 index 0000000..ed0881f Binary files /dev/null and b/main/assets/locales/fa-IR/0.ogg differ diff --git a/main/assets/locales/fa-IR/1.ogg b/main/assets/locales/fa-IR/1.ogg new file mode 100644 index 0000000..401059d Binary files /dev/null and b/main/assets/locales/fa-IR/1.ogg differ diff --git a/main/assets/locales/fa-IR/2.ogg b/main/assets/locales/fa-IR/2.ogg new file mode 100644 index 0000000..5ee8bfd Binary files /dev/null and b/main/assets/locales/fa-IR/2.ogg differ diff --git a/main/assets/locales/fa-IR/3.ogg b/main/assets/locales/fa-IR/3.ogg new file mode 100644 index 0000000..35d7e13 Binary files /dev/null and b/main/assets/locales/fa-IR/3.ogg differ diff --git a/main/assets/locales/fa-IR/4.ogg b/main/assets/locales/fa-IR/4.ogg new file mode 100644 index 0000000..5e19fe1 Binary files /dev/null and b/main/assets/locales/fa-IR/4.ogg differ diff --git a/main/assets/locales/fa-IR/5.ogg b/main/assets/locales/fa-IR/5.ogg new file mode 100644 index 0000000..2ac536b Binary files /dev/null and b/main/assets/locales/fa-IR/5.ogg differ diff --git a/main/assets/locales/fa-IR/6.ogg b/main/assets/locales/fa-IR/6.ogg new file mode 100644 index 0000000..6b9f40a Binary files /dev/null and b/main/assets/locales/fa-IR/6.ogg differ diff --git a/main/assets/locales/fa-IR/7.ogg b/main/assets/locales/fa-IR/7.ogg new file mode 100644 index 0000000..568e6c3 Binary files /dev/null and b/main/assets/locales/fa-IR/7.ogg differ diff --git a/main/assets/locales/fa-IR/8.ogg b/main/assets/locales/fa-IR/8.ogg new file mode 100644 index 0000000..055f395 Binary files /dev/null and b/main/assets/locales/fa-IR/8.ogg differ diff --git a/main/assets/locales/fa-IR/9.ogg b/main/assets/locales/fa-IR/9.ogg new file mode 100644 index 0000000..42023e8 Binary files /dev/null and b/main/assets/locales/fa-IR/9.ogg differ diff --git a/main/assets/locales/fa-IR/activation.ogg b/main/assets/locales/fa-IR/activation.ogg new file mode 100644 index 0000000..6bce7f9 Binary files /dev/null and b/main/assets/locales/fa-IR/activation.ogg differ diff --git a/main/assets/locales/fa-IR/err_pin.ogg b/main/assets/locales/fa-IR/err_pin.ogg new file mode 100644 index 0000000..0450947 Binary files /dev/null and b/main/assets/locales/fa-IR/err_pin.ogg differ diff --git a/main/assets/locales/fa-IR/err_reg.ogg b/main/assets/locales/fa-IR/err_reg.ogg new file mode 100644 index 0000000..d293376 Binary files /dev/null and b/main/assets/locales/fa-IR/err_reg.ogg differ diff --git a/main/assets/locales/fa-IR/language.json b/main/assets/locales/fa-IR/language.json new file mode 100644 index 0000000..597334e --- /dev/null +++ b/main/assets/locales/fa-IR/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "fa-IR" + }, + "strings": { + "WARNING": "هشدار", + "INFO": "اطلاعات", + "ERROR": "خطا", + "VERSION": "نسخه ", + "LOADING_PROTOCOL": "ورود به سیستم...", + "INITIALIZING": "در حال راه‌اندازی...", + "PIN_ERROR": "لطفاً سیم کارت را وارد کنید", + "REG_ERROR": "عدم دسترسی به شبکه، لطفاً وضعیت سیم کارت را بررسی کنید", + "DETECTING_MODULE": "شناسایی ماژول...", + "REGISTERING_NETWORK": "در انتظار شبکه...", + "CHECKING_NEW_VERSION": "بررسی نسخه جدید...", + "CHECK_NEW_VERSION_FAILED": "بررسی نسخه جدید ناموفق بود، پس از %d ثانیه مجدداً تلاش می‌شود: %s", + "SWITCH_TO_WIFI_NETWORK": "تغییر به Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "تغییر به 4G...", + "STANDBY": "آماده به کار", + "CONNECT_TO": "اتصال به ", + "CONNECTING": "در حال اتصال...", + "CONNECTION_SUCCESSFUL": "اتصال موفق", + "CONNECTED_TO": "متصل به ", + "LISTENING": "در حال گوش دادن...", + "SPEAKING": "در حال صحبت...", + "SERVER_NOT_FOUND": "جستجوی سرویس در دسترس", + "SERVER_NOT_CONNECTED": "اتصال به سرویس برقرار نشد، لطفاً بعداً تلاش کنید", + "SERVER_TIMEOUT": "زمان انتظار برای پاسخ به پایان رسید", + "SERVER_ERROR": "ارسال ناموفق، لطفاً شبکه را بررسی کنید", + "CONNECT_TO_HOTSPOT": "نقطه اتصال: ", + "ACCESS_VIA_BROWSER": " آدرس پیکربندی: ", + "WIFI_CONFIG_MODE": "حالت پیکربندی Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "ورود به حالت پیکربندی Wi-Fi...", + "SCANNING_WIFI": "جستجوی Wi-Fi...", + "NEW_VERSION": "نسخه جدید ", + "OTA_UPGRADE": "به‌روزرسانی OTA", + "UPGRADING": "سیستم در حال به‌روزرسانی است...", + "UPGRADE_FAILED": "به‌روزرسانی ناموفق بود", + "ACTIVATION": "فعال‌سازی", + "BATTERY_LOW": "شارژ باتری کم", + "BATTERY_CHARGING": "در حال شارژ", + "BATTERY_FULL": "باتری پر است", + "BATTERY_NEED_CHARGE": "شارژ باتری کم، لطفاً شارژ کنید", + "VOLUME": "صدا ", + "MUTED": "بی‌صدا", + "MAX_VOLUME": "حداکثر صدا", + "RTC_MODE_OFF": "AEC خاموش", + "RTC_MODE_ON": "AEC روشن", + "PLEASE_WAIT": "لطفاً صبر کنید...", + "FOUND_NEW_ASSETS": "منابع جدید یافت شد: %s", + "DOWNLOAD_ASSETS_FAILED": "دانلود منابع ناموفق بود", + "LOADING_ASSETS": "بارگذاری منابع...", + "HELLO_MY_FRIEND": "سلام، دوست من!", + "FLIGHT_MODE_OFF": "حالت پرواز خاموش است", + "FLIGHT_MODE_ON": "حالت پرواز روشن است", + "MODEM_INIT_ERROR": "خطا در راه‌اندازی مودم" + } +} \ No newline at end of file diff --git a/main/assets/locales/fa-IR/upgrade.ogg b/main/assets/locales/fa-IR/upgrade.ogg new file mode 100644 index 0000000..9eff121 Binary files /dev/null and b/main/assets/locales/fa-IR/upgrade.ogg differ diff --git a/main/assets/locales/fa-IR/welcome.ogg b/main/assets/locales/fa-IR/welcome.ogg new file mode 100644 index 0000000..108a62d Binary files /dev/null and b/main/assets/locales/fa-IR/welcome.ogg differ diff --git a/main/assets/locales/fa-IR/wificonfig.ogg b/main/assets/locales/fa-IR/wificonfig.ogg new file mode 100644 index 0000000..179fa5e Binary files /dev/null and b/main/assets/locales/fa-IR/wificonfig.ogg differ diff --git a/main/assets/locales/fi-FI/0.ogg b/main/assets/locales/fi-FI/0.ogg new file mode 100644 index 0000000..d368787 Binary files /dev/null and b/main/assets/locales/fi-FI/0.ogg differ diff --git a/main/assets/locales/fi-FI/1.ogg b/main/assets/locales/fi-FI/1.ogg new file mode 100644 index 0000000..f496dd6 Binary files /dev/null and b/main/assets/locales/fi-FI/1.ogg differ diff --git a/main/assets/locales/fi-FI/2.ogg b/main/assets/locales/fi-FI/2.ogg new file mode 100644 index 0000000..96a2529 Binary files /dev/null and b/main/assets/locales/fi-FI/2.ogg differ diff --git a/main/assets/locales/fi-FI/3.ogg b/main/assets/locales/fi-FI/3.ogg new file mode 100644 index 0000000..1fcdcf2 Binary files /dev/null and b/main/assets/locales/fi-FI/3.ogg differ diff --git a/main/assets/locales/fi-FI/4.ogg b/main/assets/locales/fi-FI/4.ogg new file mode 100644 index 0000000..6ba9ded Binary files /dev/null and b/main/assets/locales/fi-FI/4.ogg differ diff --git a/main/assets/locales/fi-FI/5.ogg b/main/assets/locales/fi-FI/5.ogg new file mode 100644 index 0000000..b94d4a2 Binary files /dev/null and b/main/assets/locales/fi-FI/5.ogg differ diff --git a/main/assets/locales/fi-FI/6.ogg b/main/assets/locales/fi-FI/6.ogg new file mode 100644 index 0000000..ffe8276 Binary files /dev/null and b/main/assets/locales/fi-FI/6.ogg differ diff --git a/main/assets/locales/fi-FI/7.ogg b/main/assets/locales/fi-FI/7.ogg new file mode 100644 index 0000000..ed3197a Binary files /dev/null and b/main/assets/locales/fi-FI/7.ogg differ diff --git a/main/assets/locales/fi-FI/8.ogg b/main/assets/locales/fi-FI/8.ogg new file mode 100644 index 0000000..7f81a62 Binary files /dev/null and b/main/assets/locales/fi-FI/8.ogg differ diff --git a/main/assets/locales/fi-FI/9.ogg b/main/assets/locales/fi-FI/9.ogg new file mode 100644 index 0000000..aaedd05 Binary files /dev/null and b/main/assets/locales/fi-FI/9.ogg differ diff --git a/main/assets/locales/fi-FI/activation.ogg b/main/assets/locales/fi-FI/activation.ogg new file mode 100644 index 0000000..6347745 Binary files /dev/null and b/main/assets/locales/fi-FI/activation.ogg differ diff --git a/main/assets/locales/fi-FI/err_pin.ogg b/main/assets/locales/fi-FI/err_pin.ogg new file mode 100644 index 0000000..8c6eb51 Binary files /dev/null and b/main/assets/locales/fi-FI/err_pin.ogg differ diff --git a/main/assets/locales/fi-FI/err_reg.ogg b/main/assets/locales/fi-FI/err_reg.ogg new file mode 100644 index 0000000..32561bf Binary files /dev/null and b/main/assets/locales/fi-FI/err_reg.ogg differ diff --git a/main/assets/locales/fi-FI/language.json b/main/assets/locales/fi-FI/language.json new file mode 100644 index 0000000..7ca8db7 --- /dev/null +++ b/main/assets/locales/fi-FI/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "fi-FI" + }, + "strings": { + "WARNING": "Varoitus", + "INFO": "Tieto", + "ERROR": "Virhe", + "VERSION": "Versio ", + "LOADING_PROTOCOL": "Yhdistetään palvelimeen...", + "INITIALIZING": "Alustetaan...", + "PIN_ERROR": "Ole hyvä ja aseta SIM-kortti", + "REG_ERROR": "Ei voi muodostaa yhteyttä verkkoon, tarkista datakortin tila", + "DETECTING_MODULE": "Tunnistetaan moduuli...", + "REGISTERING_NETWORK": "Odotetaan verkkoa...", + "CHECKING_NEW_VERSION": "Tarkistetaan uutta versiota...", + "CHECK_NEW_VERSION_FAILED": "Uuden version tarkistus epäonnistui, yritetään uudelleen %d sekunnin kuluttua: %s", + "SWITCH_TO_WIFI_NETWORK": "Vaihdetaan Wi-Fi:hin...", + "SWITCH_TO_4G_NETWORK": "Vaihdetaan 4G:hen...", + "STANDBY": "Valmiustila", + "CONNECT_TO": "Yhdistä ", + "CONNECTING": "Yhdistetään...", + "CONNECTED_TO": "Yhdistetty ", + "LISTENING": "Kuunnellaan...", + "SPEAKING": "Puhutaan...", + "SERVER_NOT_FOUND": "Etsitään käytettävissä olevaa palvelua", + "SERVER_NOT_CONNECTED": "Ei voi yhdistää palveluun, yritä myöhemmin", + "SERVER_TIMEOUT": "Vastauksen aikakatkaisu", + "SERVER_ERROR": "Lähetys epäonnistui, tarkista verkko", + "CONNECT_TO_HOTSPOT": "Yhdistä puhelin hotspottiin ", + "ACCESS_VIA_BROWSER": ",pääsy selaimen kautta ", + "WIFI_CONFIG_MODE": "Verkon konfigurointitila", + "ENTERING_WIFI_CONFIG_MODE": "Siirrytään verkon konfigurointitilaan...", + "SCANNING_WIFI": "Skannataan Wi-Fi...", + "NEW_VERSION": "Uusi versio ", + "OTA_UPGRADE": "OTA-päivitys", + "UPGRADING": "Päivitetään järjestelmää...", + "UPGRADE_FAILED": "Päivitys epäonnistui", + "ACTIVATION": "Laitteen aktivointi", + "BATTERY_LOW": "Akku vähissä", + "BATTERY_CHARGING": "Ladataan", + "BATTERY_FULL": "Akku täynnä", + "BATTERY_NEED_CHARGE": "Akku vähissä, ole hyvä ja lataa", + "VOLUME": "Äänenvoimakkuus ", + "MUTED": "Mykistetty", + "MAX_VOLUME": "Maksimi äänenvoimakkuus", + "RTC_MODE_OFF": "AEC pois päältä", + "RTC_MODE_ON": "AEC päällä", + "DOWNLOAD_ASSETS_FAILED": "Resurssien lataaminen epäonnistui", + "LOADING_ASSETS": "Ladataan resursseja...", + "PLEASE_WAIT": "Odota hetki...", + "FOUND_NEW_ASSETS": "Löydetty uusia resursseja: %s", + "HELLO_MY_FRIEND": "Hei, ystäväni!", + "CONNECTION_SUCCESSFUL": "Yhteys onnistui", + "FLIGHT_MODE_OFF": "Lentotila on pois päältä", + "FLIGHT_MODE_ON": "Lentotila on päällä", + "MODEM_INIT_ERROR": "Modeemin alustus epäonnistui" + } +} \ No newline at end of file diff --git a/main/assets/locales/fi-FI/upgrade.ogg b/main/assets/locales/fi-FI/upgrade.ogg new file mode 100644 index 0000000..5ff02a6 Binary files /dev/null and b/main/assets/locales/fi-FI/upgrade.ogg differ diff --git a/main/assets/locales/fi-FI/welcome.ogg b/main/assets/locales/fi-FI/welcome.ogg new file mode 100644 index 0000000..1430372 Binary files /dev/null and b/main/assets/locales/fi-FI/welcome.ogg differ diff --git a/main/assets/locales/fi-FI/wificonfig.ogg b/main/assets/locales/fi-FI/wificonfig.ogg new file mode 100644 index 0000000..deb233a Binary files /dev/null and b/main/assets/locales/fi-FI/wificonfig.ogg differ diff --git a/main/assets/locales/fil-PH/0.ogg b/main/assets/locales/fil-PH/0.ogg new file mode 100644 index 0000000..1f8c2fc Binary files /dev/null and b/main/assets/locales/fil-PH/0.ogg differ diff --git a/main/assets/locales/fil-PH/1.ogg b/main/assets/locales/fil-PH/1.ogg new file mode 100644 index 0000000..bc38364 Binary files /dev/null and b/main/assets/locales/fil-PH/1.ogg differ diff --git a/main/assets/locales/fil-PH/2.ogg b/main/assets/locales/fil-PH/2.ogg new file mode 100644 index 0000000..01cb65b Binary files /dev/null and b/main/assets/locales/fil-PH/2.ogg differ diff --git a/main/assets/locales/fil-PH/3.ogg b/main/assets/locales/fil-PH/3.ogg new file mode 100644 index 0000000..0f40545 Binary files /dev/null and b/main/assets/locales/fil-PH/3.ogg differ diff --git a/main/assets/locales/fil-PH/4.ogg b/main/assets/locales/fil-PH/4.ogg new file mode 100644 index 0000000..a476dc3 Binary files /dev/null and b/main/assets/locales/fil-PH/4.ogg differ diff --git a/main/assets/locales/fil-PH/5.ogg b/main/assets/locales/fil-PH/5.ogg new file mode 100644 index 0000000..1c39dab Binary files /dev/null and b/main/assets/locales/fil-PH/5.ogg differ diff --git a/main/assets/locales/fil-PH/6.ogg b/main/assets/locales/fil-PH/6.ogg new file mode 100644 index 0000000..b672cbd Binary files /dev/null and b/main/assets/locales/fil-PH/6.ogg differ diff --git a/main/assets/locales/fil-PH/7.ogg b/main/assets/locales/fil-PH/7.ogg new file mode 100644 index 0000000..f02aa1e Binary files /dev/null and b/main/assets/locales/fil-PH/7.ogg differ diff --git a/main/assets/locales/fil-PH/8.ogg b/main/assets/locales/fil-PH/8.ogg new file mode 100644 index 0000000..b699c1e Binary files /dev/null and b/main/assets/locales/fil-PH/8.ogg differ diff --git a/main/assets/locales/fil-PH/9.ogg b/main/assets/locales/fil-PH/9.ogg new file mode 100644 index 0000000..fc0adc8 Binary files /dev/null and b/main/assets/locales/fil-PH/9.ogg differ diff --git a/main/assets/locales/fil-PH/activation.ogg b/main/assets/locales/fil-PH/activation.ogg new file mode 100644 index 0000000..d8b6a62 Binary files /dev/null and b/main/assets/locales/fil-PH/activation.ogg differ diff --git a/main/assets/locales/fil-PH/err_pin.ogg b/main/assets/locales/fil-PH/err_pin.ogg new file mode 100644 index 0000000..527ca8c Binary files /dev/null and b/main/assets/locales/fil-PH/err_pin.ogg differ diff --git a/main/assets/locales/fil-PH/err_reg.ogg b/main/assets/locales/fil-PH/err_reg.ogg new file mode 100644 index 0000000..94632fc Binary files /dev/null and b/main/assets/locales/fil-PH/err_reg.ogg differ diff --git a/main/assets/locales/fil-PH/language.json b/main/assets/locales/fil-PH/language.json new file mode 100644 index 0000000..6907698 --- /dev/null +++ b/main/assets/locales/fil-PH/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "fil-PH" + }, + "strings": { + "WARNING": "Babala", + "INFO": "Impormasyon", + "ERROR": "Error", + "VERSION": "Bersyon ", + "LOADING_PROTOCOL": "Nagla-log in...", + "INITIALIZING": "Nag-i-initialize...", + "PIN_ERROR": "Mangyaring ilagay ang SIM card", + "REG_ERROR": "Hindi ma-access ang network, mangyaring tingnan ang estado ng SIM card", + "DETECTING_MODULE": "Tinutuklas ang module...", + "REGISTERING_NETWORK": "Naghihintay ng network...", + "CHECKING_NEW_VERSION": "Sinusuri ang bagong bersyon...", + "CHECK_NEW_VERSION_FAILED": "Nabigo ang pagsuri ng bagong bersyon, susubukan muli sa %d segundo: %s", + "SWITCH_TO_WIFI_NETWORK": "Lumilipat sa Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Lumilipat sa 4G...", + "STANDBY": "Standby", + "CONNECT_TO": "Kumonekta sa ", + "CONNECTING": "Kumokonekta...", + "CONNECTION_SUCCESSFUL": "Matagumpay na koneksyon", + "CONNECTED_TO": "Nakakonekta sa ", + "LISTENING": "Nakikinig...", + "SPEAKING": "Nagsasalita...", + "SERVER_NOT_FOUND": "Naghahanap ng available na serbisyo", + "SERVER_NOT_CONNECTED": "Hindi makakonekta sa serbisyo, mangyaring subukan muli mamaya", + "SERVER_TIMEOUT": "Nag-timeout ang paghihintay ng tugon", + "SERVER_ERROR": "Nabigo ang pagpapadala, mangyaring tingnan ang network", + "CONNECT_TO_HOTSPOT": "Hotspot: ", + "ACCESS_VIA_BROWSER": " URL ng configuration: ", + "WIFI_CONFIG_MODE": "Wi-Fi Configuration Mode", + "ENTERING_WIFI_CONFIG_MODE": "Pumapasok sa Wi-Fi configuration mode...", + "SCANNING_WIFI": "Nag-i-scan ng Wi-Fi...", + "NEW_VERSION": "Bagong bersyon ", + "OTA_UPGRADE": "OTA Upgrade", + "UPGRADING": "Nag-a-upgrade ang system...", + "UPGRADE_FAILED": "Nabigo ang upgrade", + "ACTIVATION": "Activation", + "BATTERY_LOW": "Mababang baterya", + "BATTERY_CHARGING": "Nagcha-charge", + "BATTERY_FULL": "Puno ang baterya", + "BATTERY_NEED_CHARGE": "Mababang baterya, mangyaring mag-charge", + "VOLUME": "Volume ", + "MUTED": "Naka-mute", + "MAX_VOLUME": "Maximum volume", + "RTC_MODE_OFF": "AEC Off", + "RTC_MODE_ON": "AEC On", + "PLEASE_WAIT": "Mangyaring maghintay...", + "FOUND_NEW_ASSETS": "Nakahanap ng mga bagong assets: %s", + "DOWNLOAD_ASSETS_FAILED": "Nabigo ang pag-download ng mga assets", + "LOADING_ASSETS": "Nilo-load ang mga assets...", + "HELLO_MY_FRIEND": "Kumusta, kaibigan ko!", + "FLIGHT_MODE_OFF": "Naka-off ang flight mode", + "FLIGHT_MODE_ON": "Naka-on ang flight mode", + "MODEM_INIT_ERROR": "Nabigo ang pag-initialize ng modem" + } +} \ No newline at end of file diff --git a/main/assets/locales/fil-PH/upgrade.ogg b/main/assets/locales/fil-PH/upgrade.ogg new file mode 100644 index 0000000..8930d78 Binary files /dev/null and b/main/assets/locales/fil-PH/upgrade.ogg differ diff --git a/main/assets/locales/fil-PH/welcome.ogg b/main/assets/locales/fil-PH/welcome.ogg new file mode 100644 index 0000000..ed59bce Binary files /dev/null and b/main/assets/locales/fil-PH/welcome.ogg differ diff --git a/main/assets/locales/fil-PH/wificonfig.ogg b/main/assets/locales/fil-PH/wificonfig.ogg new file mode 100644 index 0000000..cef030d Binary files /dev/null and b/main/assets/locales/fil-PH/wificonfig.ogg differ diff --git a/main/assets/locales/fr-FR/0.ogg b/main/assets/locales/fr-FR/0.ogg new file mode 100644 index 0000000..79b8faf Binary files /dev/null and b/main/assets/locales/fr-FR/0.ogg differ diff --git a/main/assets/locales/fr-FR/1.ogg b/main/assets/locales/fr-FR/1.ogg new file mode 100644 index 0000000..e517324 Binary files /dev/null and b/main/assets/locales/fr-FR/1.ogg differ diff --git a/main/assets/locales/fr-FR/2.ogg b/main/assets/locales/fr-FR/2.ogg new file mode 100644 index 0000000..311697c Binary files /dev/null and b/main/assets/locales/fr-FR/2.ogg differ diff --git a/main/assets/locales/fr-FR/3.ogg b/main/assets/locales/fr-FR/3.ogg new file mode 100644 index 0000000..5b4acc1 Binary files /dev/null and b/main/assets/locales/fr-FR/3.ogg differ diff --git a/main/assets/locales/fr-FR/4.ogg b/main/assets/locales/fr-FR/4.ogg new file mode 100644 index 0000000..832aeab Binary files /dev/null and b/main/assets/locales/fr-FR/4.ogg differ diff --git a/main/assets/locales/fr-FR/5.ogg b/main/assets/locales/fr-FR/5.ogg new file mode 100644 index 0000000..ddcce17 Binary files /dev/null and b/main/assets/locales/fr-FR/5.ogg differ diff --git a/main/assets/locales/fr-FR/6.ogg b/main/assets/locales/fr-FR/6.ogg new file mode 100644 index 0000000..aced771 Binary files /dev/null and b/main/assets/locales/fr-FR/6.ogg differ diff --git a/main/assets/locales/fr-FR/7.ogg b/main/assets/locales/fr-FR/7.ogg new file mode 100644 index 0000000..293d1c0 Binary files /dev/null and b/main/assets/locales/fr-FR/7.ogg differ diff --git a/main/assets/locales/fr-FR/8.ogg b/main/assets/locales/fr-FR/8.ogg new file mode 100644 index 0000000..acec112 Binary files /dev/null and b/main/assets/locales/fr-FR/8.ogg differ diff --git a/main/assets/locales/fr-FR/9.ogg b/main/assets/locales/fr-FR/9.ogg new file mode 100644 index 0000000..fe27acd Binary files /dev/null and b/main/assets/locales/fr-FR/9.ogg differ diff --git a/main/assets/locales/fr-FR/activation.ogg b/main/assets/locales/fr-FR/activation.ogg new file mode 100644 index 0000000..b48f215 Binary files /dev/null and b/main/assets/locales/fr-FR/activation.ogg differ diff --git a/main/assets/locales/fr-FR/err_pin.ogg b/main/assets/locales/fr-FR/err_pin.ogg new file mode 100644 index 0000000..1a3d84b Binary files /dev/null and b/main/assets/locales/fr-FR/err_pin.ogg differ diff --git a/main/assets/locales/fr-FR/err_reg.ogg b/main/assets/locales/fr-FR/err_reg.ogg new file mode 100644 index 0000000..c3256c5 Binary files /dev/null and b/main/assets/locales/fr-FR/err_reg.ogg differ diff --git a/main/assets/locales/fr-FR/language.json b/main/assets/locales/fr-FR/language.json new file mode 100644 index 0000000..d1e16da --- /dev/null +++ b/main/assets/locales/fr-FR/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "fr-FR" + }, + "strings": { + "WARNING": "Avertissement", + "INFO": "Information", + "ERROR": "Erreur", + "VERSION": "Version ", + "LOADING_PROTOCOL": "Connexion au serveur...", + "INITIALIZING": "Initialisation...", + "PIN_ERROR": "Veuillez insérer la carte SIM", + "REG_ERROR": "Impossible d'accéder au réseau, veuillez vérifier l'état de la carte de données", + "DETECTING_MODULE": "Détection du module...", + "REGISTERING_NETWORK": "En attente du réseau...", + "CHECKING_NEW_VERSION": "Vérification de nouvelle version...", + "CHECK_NEW_VERSION_FAILED": "Échec de vérification de nouvelle version, nouvelle tentative dans %d secondes : %s", + "SWITCH_TO_WIFI_NETWORK": "Basculer vers Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Basculer vers 4G...", + "STANDBY": "En attente", + "CONNECT_TO": "Se connecter à ", + "CONNECTING": "Connexion en cours...", + "CONNECTED_TO": "Connecté à ", + "LISTENING": "Écoute...", + "SPEAKING": "Parole...", + "SERVER_NOT_FOUND": "Recherche d'un service disponible", + "SERVER_NOT_CONNECTED": "Impossible de se connecter au service, veuillez réessayer plus tard", + "SERVER_TIMEOUT": "Délai d'attente de réponse", + "SERVER_ERROR": "Échec d'envoi, veuillez vérifier le réseau", + "CONNECT_TO_HOTSPOT": "Connecter le téléphone au point d'accès ", + "ACCESS_VIA_BROWSER": ",accéder via le navigateur ", + "WIFI_CONFIG_MODE": "Mode configuration réseau", + "ENTERING_WIFI_CONFIG_MODE": "Entrer en mode configuration réseau...", + "SCANNING_WIFI": "Scan Wi-Fi...", + "NEW_VERSION": "Nouvelle version ", + "OTA_UPGRADE": "Mise à jour OTA", + "UPGRADING": "Mise à jour du système...", + "UPGRADE_FAILED": "Échec de mise à jour", + "ACTIVATION": "Activation de l'appareil", + "BATTERY_LOW": "Batterie faible", + "BATTERY_CHARGING": "En charge", + "BATTERY_FULL": "Batterie pleine", + "BATTERY_NEED_CHARGE": "Batterie faible, veuillez charger", + "VOLUME": "Volume ", + "MUTED": "Muet", + "MAX_VOLUME": "Volume maximum", + "RTC_MODE_OFF": "AEC désactivé", + "RTC_MODE_ON": "AEC activé", + "DOWNLOAD_ASSETS_FAILED": "Échec du téléchargement des ressources", + "LOADING_ASSETS": "Chargement des ressources...", + "PLEASE_WAIT": "Veuillez patienter...", + "FOUND_NEW_ASSETS": "Nouvelles ressources trouvées: %s", + "HELLO_MY_FRIEND": "Bonjour, mon ami !", + "CONNECTION_SUCCESSFUL": "Connexion réussie", + "FLIGHT_MODE_OFF": "Le mode avion est désactivé", + "FLIGHT_MODE_ON": "Le mode avion est activé", + "MODEM_INIT_ERROR": "Échec de l'initialisation du modem" + } +} \ No newline at end of file diff --git a/main/assets/locales/fr-FR/upgrade.ogg b/main/assets/locales/fr-FR/upgrade.ogg new file mode 100644 index 0000000..b6ac1f9 Binary files /dev/null and b/main/assets/locales/fr-FR/upgrade.ogg differ diff --git a/main/assets/locales/fr-FR/welcome.ogg b/main/assets/locales/fr-FR/welcome.ogg new file mode 100644 index 0000000..4492de7 Binary files /dev/null and b/main/assets/locales/fr-FR/welcome.ogg differ diff --git a/main/assets/locales/fr-FR/wificonfig.ogg b/main/assets/locales/fr-FR/wificonfig.ogg new file mode 100644 index 0000000..9e2f99b Binary files /dev/null and b/main/assets/locales/fr-FR/wificonfig.ogg differ diff --git a/main/assets/locales/he-IL/0.ogg b/main/assets/locales/he-IL/0.ogg new file mode 100644 index 0000000..efea905 Binary files /dev/null and b/main/assets/locales/he-IL/0.ogg differ diff --git a/main/assets/locales/he-IL/1.ogg b/main/assets/locales/he-IL/1.ogg new file mode 100644 index 0000000..b0984b6 Binary files /dev/null and b/main/assets/locales/he-IL/1.ogg differ diff --git a/main/assets/locales/he-IL/2.ogg b/main/assets/locales/he-IL/2.ogg new file mode 100644 index 0000000..c70358a Binary files /dev/null and b/main/assets/locales/he-IL/2.ogg differ diff --git a/main/assets/locales/he-IL/3.ogg b/main/assets/locales/he-IL/3.ogg new file mode 100644 index 0000000..0398e82 Binary files /dev/null and b/main/assets/locales/he-IL/3.ogg differ diff --git a/main/assets/locales/he-IL/4.ogg b/main/assets/locales/he-IL/4.ogg new file mode 100644 index 0000000..78630a6 Binary files /dev/null and b/main/assets/locales/he-IL/4.ogg differ diff --git a/main/assets/locales/he-IL/5.ogg b/main/assets/locales/he-IL/5.ogg new file mode 100644 index 0000000..f8bd938 Binary files /dev/null and b/main/assets/locales/he-IL/5.ogg differ diff --git a/main/assets/locales/he-IL/6.ogg b/main/assets/locales/he-IL/6.ogg new file mode 100644 index 0000000..d30c9e5 Binary files /dev/null and b/main/assets/locales/he-IL/6.ogg differ diff --git a/main/assets/locales/he-IL/7.ogg b/main/assets/locales/he-IL/7.ogg new file mode 100644 index 0000000..610c469 Binary files /dev/null and b/main/assets/locales/he-IL/7.ogg differ diff --git a/main/assets/locales/he-IL/8.ogg b/main/assets/locales/he-IL/8.ogg new file mode 100644 index 0000000..ce834f6 Binary files /dev/null and b/main/assets/locales/he-IL/8.ogg differ diff --git a/main/assets/locales/he-IL/9.ogg b/main/assets/locales/he-IL/9.ogg new file mode 100644 index 0000000..d452e95 Binary files /dev/null and b/main/assets/locales/he-IL/9.ogg differ diff --git a/main/assets/locales/he-IL/activation.ogg b/main/assets/locales/he-IL/activation.ogg new file mode 100644 index 0000000..31eb4ac Binary files /dev/null and b/main/assets/locales/he-IL/activation.ogg differ diff --git a/main/assets/locales/he-IL/err_pin.ogg b/main/assets/locales/he-IL/err_pin.ogg new file mode 100644 index 0000000..5943f96 Binary files /dev/null and b/main/assets/locales/he-IL/err_pin.ogg differ diff --git a/main/assets/locales/he-IL/err_reg.ogg b/main/assets/locales/he-IL/err_reg.ogg new file mode 100644 index 0000000..a860ec1 Binary files /dev/null and b/main/assets/locales/he-IL/err_reg.ogg differ diff --git a/main/assets/locales/he-IL/language.json b/main/assets/locales/he-IL/language.json new file mode 100644 index 0000000..cac29a4 --- /dev/null +++ b/main/assets/locales/he-IL/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "he-IL" + }, + "strings": { + "WARNING": "אזהרה", + "INFO": "מידע", + "ERROR": "שגיאה", + "VERSION": "גרסה ", + "LOADING_PROTOCOL": "מתחבר...", + "INITIALIZING": "מאתחל...", + "PIN_ERROR": "אנא הכנס כרטיס SIM", + "REG_ERROR": "לא ניתן לגשת לרשת, אנא בדוק את מצב כרטיס ה-SIM", + "DETECTING_MODULE": "מזהה מודול...", + "REGISTERING_NETWORK": "ממתין לרשת...", + "CHECKING_NEW_VERSION": "בודק גרסה חדשה...", + "CHECK_NEW_VERSION_FAILED": "בדיקת גרסה חדשה נכשלה, ינסה שוב בעוד %d שניות: %s", + "SWITCH_TO_WIFI_NETWORK": "עובר ל-Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "עובר ל-4G...", + "STANDBY": "המתנה", + "CONNECT_TO": "התחבר ל-", + "CONNECTING": "מתחבר...", + "CONNECTION_SUCCESSFUL": "התחברות הצליחה", + "CONNECTED_TO": "מחובר ל-", + "LISTENING": "מקשיב...", + "SPEAKING": "מדבר...", + "SERVER_NOT_FOUND": "מחפש שירות זמין", + "SERVER_NOT_CONNECTED": "לא ניתן להתחבר לשירות, אנא נסה שוב מאוחר יותר", + "SERVER_TIMEOUT": "פג זמן ההמתנה לתגובה", + "SERVER_ERROR": "השליחה נכשלה, אנא בדוק את הרשת", + "CONNECT_TO_HOTSPOT": "נקודה חמה: ", + "ACCESS_VIA_BROWSER": " כתובת תצורה: ", + "WIFI_CONFIG_MODE": "מצב תצורת Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "נכנס למצב תצורת Wi-Fi...", + "SCANNING_WIFI": "סורק Wi-Fi...", + "NEW_VERSION": "גרסה חדשה ", + "OTA_UPGRADE": "שדרוג OTA", + "UPGRADING": "המערכת משתדרגת...", + "UPGRADE_FAILED": "השדרוג נכשל", + "ACTIVATION": "הפעלה", + "BATTERY_LOW": "סוללה חלשה", + "BATTERY_CHARGING": "נטען", + "BATTERY_FULL": "הסוללה מלאה", + "BATTERY_NEED_CHARGE": "סוללה חלשה, אנא טען", + "VOLUME": "עוצמת קול ", + "MUTED": "מושתק", + "MAX_VOLUME": "עוצמת קול מקסימלית", + "RTC_MODE_OFF": "AEC כבוי", + "RTC_MODE_ON": "AEC דלוק", + "PLEASE_WAIT": "אנא המתן...", + "FOUND_NEW_ASSETS": "נמצאו משאבים חדשים: %s", + "DOWNLOAD_ASSETS_FAILED": "הורדת משאבים נכשלה", + "LOADING_ASSETS": "טוען משאבים...", + "HELLO_MY_FRIEND": "שלום, ידידי!", + "FLIGHT_MODE_OFF": "מצב טיסה כבוי", + "FLIGHT_MODE_ON": "מצב טיסה מופעל", + "MODEM_INIT_ERROR": "אתחול המודם נכשל" + } +} \ No newline at end of file diff --git a/main/assets/locales/he-IL/upgrade.ogg b/main/assets/locales/he-IL/upgrade.ogg new file mode 100644 index 0000000..ff36a86 Binary files /dev/null and b/main/assets/locales/he-IL/upgrade.ogg differ diff --git a/main/assets/locales/he-IL/welcome.ogg b/main/assets/locales/he-IL/welcome.ogg new file mode 100644 index 0000000..6594d37 Binary files /dev/null and b/main/assets/locales/he-IL/welcome.ogg differ diff --git a/main/assets/locales/he-IL/wificonfig.ogg b/main/assets/locales/he-IL/wificonfig.ogg new file mode 100644 index 0000000..6cf0597 Binary files /dev/null and b/main/assets/locales/he-IL/wificonfig.ogg differ diff --git a/main/assets/locales/hi-IN/0.ogg b/main/assets/locales/hi-IN/0.ogg new file mode 100644 index 0000000..e531348 Binary files /dev/null and b/main/assets/locales/hi-IN/0.ogg differ diff --git a/main/assets/locales/hi-IN/1.ogg b/main/assets/locales/hi-IN/1.ogg new file mode 100644 index 0000000..cb2f9d5 Binary files /dev/null and b/main/assets/locales/hi-IN/1.ogg differ diff --git a/main/assets/locales/hi-IN/2.ogg b/main/assets/locales/hi-IN/2.ogg new file mode 100644 index 0000000..2244d45 Binary files /dev/null and b/main/assets/locales/hi-IN/2.ogg differ diff --git a/main/assets/locales/hi-IN/3.ogg b/main/assets/locales/hi-IN/3.ogg new file mode 100644 index 0000000..63fde6d Binary files /dev/null and b/main/assets/locales/hi-IN/3.ogg differ diff --git a/main/assets/locales/hi-IN/4.ogg b/main/assets/locales/hi-IN/4.ogg new file mode 100644 index 0000000..76caee9 Binary files /dev/null and b/main/assets/locales/hi-IN/4.ogg differ diff --git a/main/assets/locales/hi-IN/5.ogg b/main/assets/locales/hi-IN/5.ogg new file mode 100644 index 0000000..c96a386 Binary files /dev/null and b/main/assets/locales/hi-IN/5.ogg differ diff --git a/main/assets/locales/hi-IN/6.ogg b/main/assets/locales/hi-IN/6.ogg new file mode 100644 index 0000000..88f7643 Binary files /dev/null and b/main/assets/locales/hi-IN/6.ogg differ diff --git a/main/assets/locales/hi-IN/7.ogg b/main/assets/locales/hi-IN/7.ogg new file mode 100644 index 0000000..3a997d4 Binary files /dev/null and b/main/assets/locales/hi-IN/7.ogg differ diff --git a/main/assets/locales/hi-IN/8.ogg b/main/assets/locales/hi-IN/8.ogg new file mode 100644 index 0000000..56f125e Binary files /dev/null and b/main/assets/locales/hi-IN/8.ogg differ diff --git a/main/assets/locales/hi-IN/9.ogg b/main/assets/locales/hi-IN/9.ogg new file mode 100644 index 0000000..25e64c0 Binary files /dev/null and b/main/assets/locales/hi-IN/9.ogg differ diff --git a/main/assets/locales/hi-IN/activation.ogg b/main/assets/locales/hi-IN/activation.ogg new file mode 100644 index 0000000..aa262df Binary files /dev/null and b/main/assets/locales/hi-IN/activation.ogg differ diff --git a/main/assets/locales/hi-IN/err_pin.ogg b/main/assets/locales/hi-IN/err_pin.ogg new file mode 100644 index 0000000..00669fa Binary files /dev/null and b/main/assets/locales/hi-IN/err_pin.ogg differ diff --git a/main/assets/locales/hi-IN/err_reg.ogg b/main/assets/locales/hi-IN/err_reg.ogg new file mode 100644 index 0000000..6194739 Binary files /dev/null and b/main/assets/locales/hi-IN/err_reg.ogg differ diff --git a/main/assets/locales/hi-IN/language.json b/main/assets/locales/hi-IN/language.json new file mode 100644 index 0000000..25af4eb --- /dev/null +++ b/main/assets/locales/hi-IN/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "hi-IN" + }, + "strings": { + "WARNING": "चेतावनी", + "INFO": "जानकारी", + "ERROR": "त्रुटि", + "VERSION": "संस्करण ", + "LOADING_PROTOCOL": "सर्वर से कनेक्ट हो रहे हैं...", + "INITIALIZING": "आरंभीकरण...", + "PIN_ERROR": "कृपया सिम कार्ड डालें", + "REG_ERROR": "नेटवर्क तक पहुंच नहीं हो सकती, कृपया डेटा कार्ड स्थिति जांचें", + "DETECTING_MODULE": "मॉड्यूल का पता लगाया जा रहा है...", + "REGISTERING_NETWORK": "नेटवर्क की प्रतीक्षा...", + "CHECKING_NEW_VERSION": "नया संस्करण जाँच रहे हैं...", + "CHECK_NEW_VERSION_FAILED": "नया संस्करण जाँचना असफल, %d सेकंड में पुनः प्रयास: %s", + "SWITCH_TO_WIFI_NETWORK": "Wi-Fi पर स्विच कर रहे हैं...", + "SWITCH_TO_4G_NETWORK": "4G पर स्विच कर रहे हैं...", + "STANDBY": "स्टैंडबाय", + "CONNECT_TO": "कनेक्ट करें ", + "CONNECTING": "कनेक्ट हो रहे हैं...", + "CONNECTED_TO": "कनेक्ट हो गए ", + "LISTENING": "सुन रहे हैं...", + "SPEAKING": "बोल रहे हैं...", + "SERVER_NOT_FOUND": "उपलब्ध सेवा खोज रहे हैं", + "SERVER_NOT_CONNECTED": "सेवा से कनेक्ट नहीं हो सकते, कृपया बाद में कोशिश करें", + "SERVER_TIMEOUT": "प्रतिक्रिया का समय समाप्त", + "SERVER_ERROR": "भेजना असफल, कृपया नेटवर्क जांचें", + "CONNECT_TO_HOTSPOT": "फोन को हॉटस्पॉट से कनेक्ट करें ", + "ACCESS_VIA_BROWSER": ",ब्राउज़र के माध्यम से पहुंचें ", + "WIFI_CONFIG_MODE": "नेटवर्क कॉन्फ़िगरेशन मोड", + "ENTERING_WIFI_CONFIG_MODE": "नेटवर्क कॉन्फ़िगरेशन मोड में प्रवेश...", + "SCANNING_WIFI": "Wi-Fi स्कैन कर रहे हैं...", + "NEW_VERSION": "नया संस्करण ", + "OTA_UPGRADE": "OTA अपग्रेड", + "UPGRADING": "सिस्टम अपग्रेड हो रहा है...", + "UPGRADE_FAILED": "अपग्रेड असफल", + "ACTIVATION": "डिवाइस सक्रियण", + "BATTERY_LOW": "बैटरी कम", + "BATTERY_CHARGING": "चार्ज हो रही है", + "BATTERY_FULL": "बैटरी फुल", + "BATTERY_NEED_CHARGE": "बैटरी कम है, कृपया चार्ज करें", + "VOLUME": "आवाज़ ", + "MUTED": "म्यूट", + "MAX_VOLUME": "अधिकतम आवाज़", + "RTC_MODE_OFF": "AEC बंद", + "RTC_MODE_ON": "AEC चालू", + "DOWNLOAD_ASSETS_FAILED": "संसाधन डाउनलोड करने में विफल", + "LOADING_ASSETS": "संसाधन लोड हो रहे हैं...", + "PLEASE_WAIT": "कृपया प्रतीक्षा करें...", + "FOUND_NEW_ASSETS": "नए संसाधन मिले: %s", + "HELLO_MY_FRIEND": "नमस्ते, मेरे दोस्त!", + "CONNECTION_SUCCESSFUL": "कनेक्शन सफल", + "FLIGHT_MODE_OFF": "फ़्लाइट मोड बंद है", + "FLIGHT_MODE_ON": "फ़्लाइट मोड चालू है", + "MODEM_INIT_ERROR": "मॉडेम आरंभीकरण विफल" + } +} \ No newline at end of file diff --git a/main/assets/locales/hi-IN/upgrade.ogg b/main/assets/locales/hi-IN/upgrade.ogg new file mode 100644 index 0000000..8de0693 Binary files /dev/null and b/main/assets/locales/hi-IN/upgrade.ogg differ diff --git a/main/assets/locales/hi-IN/welcome.ogg b/main/assets/locales/hi-IN/welcome.ogg new file mode 100644 index 0000000..3352a57 Binary files /dev/null and b/main/assets/locales/hi-IN/welcome.ogg differ diff --git a/main/assets/locales/hi-IN/wificonfig.ogg b/main/assets/locales/hi-IN/wificonfig.ogg new file mode 100644 index 0000000..5a9853f Binary files /dev/null and b/main/assets/locales/hi-IN/wificonfig.ogg differ diff --git a/main/assets/locales/hr-HR/0.ogg b/main/assets/locales/hr-HR/0.ogg new file mode 100644 index 0000000..5ee48d5 Binary files /dev/null and b/main/assets/locales/hr-HR/0.ogg differ diff --git a/main/assets/locales/hr-HR/1.ogg b/main/assets/locales/hr-HR/1.ogg new file mode 100644 index 0000000..13b1dbe Binary files /dev/null and b/main/assets/locales/hr-HR/1.ogg differ diff --git a/main/assets/locales/hr-HR/2.ogg b/main/assets/locales/hr-HR/2.ogg new file mode 100644 index 0000000..8a9c764 Binary files /dev/null and b/main/assets/locales/hr-HR/2.ogg differ diff --git a/main/assets/locales/hr-HR/3.ogg b/main/assets/locales/hr-HR/3.ogg new file mode 100644 index 0000000..18080eb Binary files /dev/null and b/main/assets/locales/hr-HR/3.ogg differ diff --git a/main/assets/locales/hr-HR/4.ogg b/main/assets/locales/hr-HR/4.ogg new file mode 100644 index 0000000..0f33e69 Binary files /dev/null and b/main/assets/locales/hr-HR/4.ogg differ diff --git a/main/assets/locales/hr-HR/5.ogg b/main/assets/locales/hr-HR/5.ogg new file mode 100644 index 0000000..e9bbedc Binary files /dev/null and b/main/assets/locales/hr-HR/5.ogg differ diff --git a/main/assets/locales/hr-HR/6.ogg b/main/assets/locales/hr-HR/6.ogg new file mode 100644 index 0000000..10dff31 Binary files /dev/null and b/main/assets/locales/hr-HR/6.ogg differ diff --git a/main/assets/locales/hr-HR/7.ogg b/main/assets/locales/hr-HR/7.ogg new file mode 100644 index 0000000..6e82da9 Binary files /dev/null and b/main/assets/locales/hr-HR/7.ogg differ diff --git a/main/assets/locales/hr-HR/8.ogg b/main/assets/locales/hr-HR/8.ogg new file mode 100644 index 0000000..cfac805 Binary files /dev/null and b/main/assets/locales/hr-HR/8.ogg differ diff --git a/main/assets/locales/hr-HR/9.ogg b/main/assets/locales/hr-HR/9.ogg new file mode 100644 index 0000000..ed4edfd Binary files /dev/null and b/main/assets/locales/hr-HR/9.ogg differ diff --git a/main/assets/locales/hr-HR/activation.ogg b/main/assets/locales/hr-HR/activation.ogg new file mode 100644 index 0000000..221f102 Binary files /dev/null and b/main/assets/locales/hr-HR/activation.ogg differ diff --git a/main/assets/locales/hr-HR/err_pin.ogg b/main/assets/locales/hr-HR/err_pin.ogg new file mode 100644 index 0000000..d3387bd Binary files /dev/null and b/main/assets/locales/hr-HR/err_pin.ogg differ diff --git a/main/assets/locales/hr-HR/err_reg.ogg b/main/assets/locales/hr-HR/err_reg.ogg new file mode 100644 index 0000000..a732ee9 Binary files /dev/null and b/main/assets/locales/hr-HR/err_reg.ogg differ diff --git a/main/assets/locales/hr-HR/language.json b/main/assets/locales/hr-HR/language.json new file mode 100644 index 0000000..25e5821 --- /dev/null +++ b/main/assets/locales/hr-HR/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "hr-HR" + }, + "strings": { + "WARNING": "Upozorenje", + "INFO": "Informacije", + "ERROR": "Greška", + "VERSION": "Verzija ", + "LOADING_PROTOCOL": "Prijava...", + "INITIALIZING": "Inicijalizacija...", + "PIN_ERROR": "Molimo umetnite SIM karticu", + "REG_ERROR": "Nije moguć pristup mreži, molimo provjerite status SIM kartice", + "DETECTING_MODULE": "Otkrivanje modula...", + "REGISTERING_NETWORK": "Čekanje mreže...", + "CHECKING_NEW_VERSION": "Provjera nove verzije...", + "CHECK_NEW_VERSION_FAILED": "Provjera nove verzije nije uspjela, ponovit će se za %d sekundi: %s", + "SWITCH_TO_WIFI_NETWORK": "Prebacivanje na Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Prebacivanje na 4G...", + "STANDBY": "Pripravnost", + "CONNECT_TO": "Poveži se s ", + "CONNECTING": "Povezivanje...", + "CONNECTION_SUCCESSFUL": "Uspješno povezivanje", + "CONNECTED_TO": "Povezano s ", + "LISTENING": "Slušanje...", + "SPEAKING": "Govorenje...", + "SERVER_NOT_FOUND": "Traženje dostupne usluge", + "SERVER_NOT_CONNECTED": "Nije moguće povezati se s uslugom, molimo pokušajte kasnije", + "SERVER_TIMEOUT": "Istek vremena čekanja odgovora", + "SERVER_ERROR": "Slanje nije uspjelo, molimo provjerite mrežu", + "CONNECT_TO_HOTSPOT": "Pristupna točka: ", + "ACCESS_VIA_BROWSER": " URL konfiguracije: ", + "WIFI_CONFIG_MODE": "Način konfiguracije Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "Ulazak u način konfiguracije Wi-Fi...", + "SCANNING_WIFI": "Skeniranje Wi-Fi...", + "NEW_VERSION": "Nova verzija ", + "OTA_UPGRADE": "OTA nadogradnja", + "UPGRADING": "Sustav se nadograđuje...", + "UPGRADE_FAILED": "Nadogradnja nije uspjela", + "ACTIVATION": "Aktivacija", + "BATTERY_LOW": "Niska baterija", + "BATTERY_CHARGING": "Punjenje", + "BATTERY_FULL": "Baterija puna", + "BATTERY_NEED_CHARGE": "Niska baterija, molimo napunite", + "VOLUME": "Glasnoća ", + "MUTED": "Utišano", + "MAX_VOLUME": "Maksimalna glasnoća", + "RTC_MODE_OFF": "AEC isključen", + "RTC_MODE_ON": "AEC uključen", + "PLEASE_WAIT": "Molimo pričekajte...", + "FOUND_NEW_ASSETS": "Pronađeni novi resursi: %s", + "DOWNLOAD_ASSETS_FAILED": "Preuzimanje resursa nije uspjelo", + "LOADING_ASSETS": "Učitavanje resursa...", + "HELLO_MY_FRIEND": "Bok, moj prijatelju!", + "FLIGHT_MODE_OFF": "Način rada u zrakoplovu je isključen", + "FLIGHT_MODE_ON": "Način rada u zrakoplovu je uključen", + "MODEM_INIT_ERROR": "Neuspjela inicijalizacija modema" + } +} \ No newline at end of file diff --git a/main/assets/locales/hr-HR/upgrade.ogg b/main/assets/locales/hr-HR/upgrade.ogg new file mode 100644 index 0000000..0aa6134 Binary files /dev/null and b/main/assets/locales/hr-HR/upgrade.ogg differ diff --git a/main/assets/locales/hr-HR/welcome.ogg b/main/assets/locales/hr-HR/welcome.ogg new file mode 100644 index 0000000..8a41e8d Binary files /dev/null and b/main/assets/locales/hr-HR/welcome.ogg differ diff --git a/main/assets/locales/hr-HR/wificonfig.ogg b/main/assets/locales/hr-HR/wificonfig.ogg new file mode 100644 index 0000000..8949622 Binary files /dev/null and b/main/assets/locales/hr-HR/wificonfig.ogg differ diff --git a/main/assets/locales/hu-HU/0.ogg b/main/assets/locales/hu-HU/0.ogg new file mode 100644 index 0000000..0fabaf1 Binary files /dev/null and b/main/assets/locales/hu-HU/0.ogg differ diff --git a/main/assets/locales/hu-HU/1.ogg b/main/assets/locales/hu-HU/1.ogg new file mode 100644 index 0000000..8187037 Binary files /dev/null and b/main/assets/locales/hu-HU/1.ogg differ diff --git a/main/assets/locales/hu-HU/2.ogg b/main/assets/locales/hu-HU/2.ogg new file mode 100644 index 0000000..bf417f3 Binary files /dev/null and b/main/assets/locales/hu-HU/2.ogg differ diff --git a/main/assets/locales/hu-HU/3.ogg b/main/assets/locales/hu-HU/3.ogg new file mode 100644 index 0000000..78192eb Binary files /dev/null and b/main/assets/locales/hu-HU/3.ogg differ diff --git a/main/assets/locales/hu-HU/4.ogg b/main/assets/locales/hu-HU/4.ogg new file mode 100644 index 0000000..e93ccbf Binary files /dev/null and b/main/assets/locales/hu-HU/4.ogg differ diff --git a/main/assets/locales/hu-HU/5.ogg b/main/assets/locales/hu-HU/5.ogg new file mode 100644 index 0000000..d43909d Binary files /dev/null and b/main/assets/locales/hu-HU/5.ogg differ diff --git a/main/assets/locales/hu-HU/6.ogg b/main/assets/locales/hu-HU/6.ogg new file mode 100644 index 0000000..17344e3 Binary files /dev/null and b/main/assets/locales/hu-HU/6.ogg differ diff --git a/main/assets/locales/hu-HU/7.ogg b/main/assets/locales/hu-HU/7.ogg new file mode 100644 index 0000000..5167c43 Binary files /dev/null and b/main/assets/locales/hu-HU/7.ogg differ diff --git a/main/assets/locales/hu-HU/8.ogg b/main/assets/locales/hu-HU/8.ogg new file mode 100644 index 0000000..ba82c28 Binary files /dev/null and b/main/assets/locales/hu-HU/8.ogg differ diff --git a/main/assets/locales/hu-HU/9.ogg b/main/assets/locales/hu-HU/9.ogg new file mode 100644 index 0000000..4c1e62a Binary files /dev/null and b/main/assets/locales/hu-HU/9.ogg differ diff --git a/main/assets/locales/hu-HU/activation.ogg b/main/assets/locales/hu-HU/activation.ogg new file mode 100644 index 0000000..7e85192 Binary files /dev/null and b/main/assets/locales/hu-HU/activation.ogg differ diff --git a/main/assets/locales/hu-HU/err_pin.ogg b/main/assets/locales/hu-HU/err_pin.ogg new file mode 100644 index 0000000..fa7d637 Binary files /dev/null and b/main/assets/locales/hu-HU/err_pin.ogg differ diff --git a/main/assets/locales/hu-HU/err_reg.ogg b/main/assets/locales/hu-HU/err_reg.ogg new file mode 100644 index 0000000..d8db153 Binary files /dev/null and b/main/assets/locales/hu-HU/err_reg.ogg differ diff --git a/main/assets/locales/hu-HU/language.json b/main/assets/locales/hu-HU/language.json new file mode 100644 index 0000000..e6594ef --- /dev/null +++ b/main/assets/locales/hu-HU/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "hu-HU" + }, + "strings": { + "WARNING": "Figyelmeztetés", + "INFO": "Információ", + "ERROR": "Hiba", + "VERSION": "Verzió ", + "LOADING_PROTOCOL": "Bejelentkezés...", + "INITIALIZING": "Inicializálás...", + "PIN_ERROR": "Kérjük, helyezze be a SIM kártyát", + "REG_ERROR": "A hálózat nem érhető el, kérjük, ellenőrizze a SIM kártya állapotát", + "DETECTING_MODULE": "Modul észlelése...", + "REGISTERING_NETWORK": "Várakozás a hálózatra...", + "CHECKING_NEW_VERSION": "Új verzió keresése...", + "CHECK_NEW_VERSION_FAILED": "Az új verzió keresése sikertelen, újrapróbálás %d másodperc múlva: %s", + "SWITCH_TO_WIFI_NETWORK": "Váltás Wi-Fi-re...", + "SWITCH_TO_4G_NETWORK": "Váltás 4G-re...", + "STANDBY": "Készenlét", + "CONNECT_TO": "Csatlakozás: ", + "CONNECTING": "Csatlakozás...", + "CONNECTION_SUCCESSFUL": "Sikeres csatlakozás", + "CONNECTED_TO": "Csatlakozva: ", + "LISTENING": "Figyelés...", + "SPEAKING": "Beszéd...", + "SERVER_NOT_FOUND": "Elérhető szolgáltatás keresése", + "SERVER_NOT_CONNECTED": "A szolgáltatáshoz nem sikerült csatlakozni, kérjük, próbálja újra később", + "SERVER_TIMEOUT": "A várakozási idő lejárt", + "SERVER_ERROR": "A küldés sikertelen, kérjük, ellenőrizze a hálózatot", + "CONNECT_TO_HOTSPOT": "Hotspot: ", + "ACCESS_VIA_BROWSER": " Konfigurációs URL: ", + "WIFI_CONFIG_MODE": "Wi-Fi konfigurációs mód", + "ENTERING_WIFI_CONFIG_MODE": "Belépés Wi-Fi konfigurációs módba...", + "SCANNING_WIFI": "Wi-Fi keresése...", + "NEW_VERSION": "Új verzió ", + "OTA_UPGRADE": "OTA frissítés", + "UPGRADING": "A rendszer frissítése folyamatban...", + "UPGRADE_FAILED": "A frissítés sikertelen", + "ACTIVATION": "Aktiválás", + "BATTERY_LOW": "Alacsony akkumulátor", + "BATTERY_CHARGING": "Töltés", + "BATTERY_FULL": "Akkumulátor tele", + "BATTERY_NEED_CHARGE": "Alacsony akkumulátor, kérjük, töltse fel", + "VOLUME": "Hangerő ", + "MUTED": "Némítva", + "MAX_VOLUME": "Maximális hangerő", + "RTC_MODE_OFF": "AEC kikapcsolva", + "RTC_MODE_ON": "AEC bekapcsolva", + "PLEASE_WAIT": "Kérjük, várjon...", + "FOUND_NEW_ASSETS": "Új erőforrások találva: %s", + "DOWNLOAD_ASSETS_FAILED": "Az erőforrások letöltése sikertelen", + "LOADING_ASSETS": "Erőforrások betöltése...", + "HELLO_MY_FRIEND": "Helló, barátom!", + "FLIGHT_MODE_OFF": "A repülési mód ki van kapcsolva", + "FLIGHT_MODE_ON": "A repülési mód be van kapcsolva", + "MODEM_INIT_ERROR": "A modem inicializálása sikertelen" + } +} \ No newline at end of file diff --git a/main/assets/locales/hu-HU/upgrade.ogg b/main/assets/locales/hu-HU/upgrade.ogg new file mode 100644 index 0000000..d894582 Binary files /dev/null and b/main/assets/locales/hu-HU/upgrade.ogg differ diff --git a/main/assets/locales/hu-HU/welcome.ogg b/main/assets/locales/hu-HU/welcome.ogg new file mode 100644 index 0000000..51d257b Binary files /dev/null and b/main/assets/locales/hu-HU/welcome.ogg differ diff --git a/main/assets/locales/hu-HU/wificonfig.ogg b/main/assets/locales/hu-HU/wificonfig.ogg new file mode 100644 index 0000000..c0e6119 Binary files /dev/null and b/main/assets/locales/hu-HU/wificonfig.ogg differ diff --git a/main/assets/locales/id-ID/0.ogg b/main/assets/locales/id-ID/0.ogg new file mode 100644 index 0000000..1d8dea3 Binary files /dev/null and b/main/assets/locales/id-ID/0.ogg differ diff --git a/main/assets/locales/id-ID/1.ogg b/main/assets/locales/id-ID/1.ogg new file mode 100644 index 0000000..cd8d7cc Binary files /dev/null and b/main/assets/locales/id-ID/1.ogg differ diff --git a/main/assets/locales/id-ID/2.ogg b/main/assets/locales/id-ID/2.ogg new file mode 100644 index 0000000..69cdce8 Binary files /dev/null and b/main/assets/locales/id-ID/2.ogg differ diff --git a/main/assets/locales/id-ID/3.ogg b/main/assets/locales/id-ID/3.ogg new file mode 100644 index 0000000..2730364 Binary files /dev/null and b/main/assets/locales/id-ID/3.ogg differ diff --git a/main/assets/locales/id-ID/4.ogg b/main/assets/locales/id-ID/4.ogg new file mode 100644 index 0000000..f26010f Binary files /dev/null and b/main/assets/locales/id-ID/4.ogg differ diff --git a/main/assets/locales/id-ID/5.ogg b/main/assets/locales/id-ID/5.ogg new file mode 100644 index 0000000..67b41f7 Binary files /dev/null and b/main/assets/locales/id-ID/5.ogg differ diff --git a/main/assets/locales/id-ID/6.ogg b/main/assets/locales/id-ID/6.ogg new file mode 100644 index 0000000..48f0343 Binary files /dev/null and b/main/assets/locales/id-ID/6.ogg differ diff --git a/main/assets/locales/id-ID/7.ogg b/main/assets/locales/id-ID/7.ogg new file mode 100644 index 0000000..c56df9f Binary files /dev/null and b/main/assets/locales/id-ID/7.ogg differ diff --git a/main/assets/locales/id-ID/8.ogg b/main/assets/locales/id-ID/8.ogg new file mode 100644 index 0000000..cb9e15b Binary files /dev/null and b/main/assets/locales/id-ID/8.ogg differ diff --git a/main/assets/locales/id-ID/9.ogg b/main/assets/locales/id-ID/9.ogg new file mode 100644 index 0000000..e088c86 Binary files /dev/null and b/main/assets/locales/id-ID/9.ogg differ diff --git a/main/assets/locales/id-ID/activation.ogg b/main/assets/locales/id-ID/activation.ogg new file mode 100644 index 0000000..a468767 Binary files /dev/null and b/main/assets/locales/id-ID/activation.ogg differ diff --git a/main/assets/locales/id-ID/err_pin.ogg b/main/assets/locales/id-ID/err_pin.ogg new file mode 100644 index 0000000..419ff8a Binary files /dev/null and b/main/assets/locales/id-ID/err_pin.ogg differ diff --git a/main/assets/locales/id-ID/err_reg.ogg b/main/assets/locales/id-ID/err_reg.ogg new file mode 100644 index 0000000..d756ddf Binary files /dev/null and b/main/assets/locales/id-ID/err_reg.ogg differ diff --git a/main/assets/locales/id-ID/language.json b/main/assets/locales/id-ID/language.json new file mode 100644 index 0000000..012b1f5 --- /dev/null +++ b/main/assets/locales/id-ID/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "id-ID" + }, + "strings": { + "WARNING": "Peringatan", + "INFO": "Informasi", + "ERROR": "Kesalahan", + "VERSION": "Versi ", + "LOADING_PROTOCOL": "Menghubungkan ke server...", + "INITIALIZING": "Menginisialisasi...", + "PIN_ERROR": "Silakan masukkan kartu SIM", + "REG_ERROR": "Tidak dapat mengakses jaringan, periksa status kartu data", + "DETECTING_MODULE": "Mendeteksi modul...", + "REGISTERING_NETWORK": "Menunggu jaringan...", + "CHECKING_NEW_VERSION": "Memeriksa versi baru...", + "CHECK_NEW_VERSION_FAILED": "Pemeriksaan versi baru gagal, mencoba lagi dalam %d detik: %s", + "SWITCH_TO_WIFI_NETWORK": "Beralih ke Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Beralih ke 4G...", + "STANDBY": "Siaga", + "CONNECT_TO": "Hubungkan ke ", + "CONNECTING": "Menghubungkan...", + "CONNECTED_TO": "Terhubung ke ", + "LISTENING": "Mendengarkan...", + "SPEAKING": "Berbicara...", + "SERVER_NOT_FOUND": "Mencari layanan yang tersedia", + "SERVER_NOT_CONNECTED": "Tidak dapat terhubung ke layanan, coba lagi nanti", + "SERVER_TIMEOUT": "Waktu respons habis", + "SERVER_ERROR": "Pengiriman gagal, periksa jaringan", + "CONNECT_TO_HOTSPOT": "Hubungkan ponsel ke hotspot ", + "ACCESS_VIA_BROWSER": ",akses melalui browser ", + "WIFI_CONFIG_MODE": "Mode konfigurasi jaringan", + "ENTERING_WIFI_CONFIG_MODE": "Memasuki mode konfigurasi jaringan...", + "SCANNING_WIFI": "Memindai Wi-Fi...", + "NEW_VERSION": "Versi baru ", + "OTA_UPGRADE": "Pembaruan OTA", + "UPGRADING": "Memperbarui sistem...", + "UPGRADE_FAILED": "Pembaruan gagal", + "ACTIVATION": "Aktivasi perangkat", + "BATTERY_LOW": "Baterai lemah", + "BATTERY_CHARGING": "Mengisi", + "BATTERY_FULL": "Baterai penuh", + "BATTERY_NEED_CHARGE": "Baterai lemah, silakan isi", + "VOLUME": "Volume ", + "MUTED": "Bisu", + "MAX_VOLUME": "Volume maksimum", + "RTC_MODE_OFF": "AEC mati", + "RTC_MODE_ON": "AEC nyala", + "DOWNLOAD_ASSETS_FAILED": "Gagal mengunduh aset", + "LOADING_ASSETS": "Memuat aset...", + "PLEASE_WAIT": "Mohon tunggu...", + "FOUND_NEW_ASSETS": "Ditemukan aset baru: %s", + "HELLO_MY_FRIEND": "Halo, teman saya!", + "CONNECTION_SUCCESSFUL": "Koneksi berhasil", + "FLIGHT_MODE_OFF": "Mode pesawat nonaktif", + "FLIGHT_MODE_ON": "Mode pesawat aktif", + "MODEM_INIT_ERROR": "Gagal menginisialisasi modem" + } +} \ No newline at end of file diff --git a/main/assets/locales/id-ID/upgrade.ogg b/main/assets/locales/id-ID/upgrade.ogg new file mode 100644 index 0000000..cc2557a Binary files /dev/null and b/main/assets/locales/id-ID/upgrade.ogg differ diff --git a/main/assets/locales/id-ID/welcome.ogg b/main/assets/locales/id-ID/welcome.ogg new file mode 100644 index 0000000..57beac6 Binary files /dev/null and b/main/assets/locales/id-ID/welcome.ogg differ diff --git a/main/assets/locales/id-ID/wificonfig.ogg b/main/assets/locales/id-ID/wificonfig.ogg new file mode 100644 index 0000000..477acdd Binary files /dev/null and b/main/assets/locales/id-ID/wificonfig.ogg differ diff --git a/main/assets/locales/it-IT/0.ogg b/main/assets/locales/it-IT/0.ogg new file mode 100644 index 0000000..ae426e3 Binary files /dev/null and b/main/assets/locales/it-IT/0.ogg differ diff --git a/main/assets/locales/it-IT/1.ogg b/main/assets/locales/it-IT/1.ogg new file mode 100644 index 0000000..7c97b52 Binary files /dev/null and b/main/assets/locales/it-IT/1.ogg differ diff --git a/main/assets/locales/it-IT/2.ogg b/main/assets/locales/it-IT/2.ogg new file mode 100644 index 0000000..3d7995a Binary files /dev/null and b/main/assets/locales/it-IT/2.ogg differ diff --git a/main/assets/locales/it-IT/3.ogg b/main/assets/locales/it-IT/3.ogg new file mode 100644 index 0000000..b22ad1e Binary files /dev/null and b/main/assets/locales/it-IT/3.ogg differ diff --git a/main/assets/locales/it-IT/4.ogg b/main/assets/locales/it-IT/4.ogg new file mode 100644 index 0000000..ef01a46 Binary files /dev/null and b/main/assets/locales/it-IT/4.ogg differ diff --git a/main/assets/locales/it-IT/5.ogg b/main/assets/locales/it-IT/5.ogg new file mode 100644 index 0000000..d240b88 Binary files /dev/null and b/main/assets/locales/it-IT/5.ogg differ diff --git a/main/assets/locales/it-IT/6.ogg b/main/assets/locales/it-IT/6.ogg new file mode 100644 index 0000000..c52a2d7 Binary files /dev/null and b/main/assets/locales/it-IT/6.ogg differ diff --git a/main/assets/locales/it-IT/7.ogg b/main/assets/locales/it-IT/7.ogg new file mode 100644 index 0000000..dce632c Binary files /dev/null and b/main/assets/locales/it-IT/7.ogg differ diff --git a/main/assets/locales/it-IT/8.ogg b/main/assets/locales/it-IT/8.ogg new file mode 100644 index 0000000..acd19e8 Binary files /dev/null and b/main/assets/locales/it-IT/8.ogg differ diff --git a/main/assets/locales/it-IT/9.ogg b/main/assets/locales/it-IT/9.ogg new file mode 100644 index 0000000..807c226 Binary files /dev/null and b/main/assets/locales/it-IT/9.ogg differ diff --git a/main/assets/locales/it-IT/activation.ogg b/main/assets/locales/it-IT/activation.ogg new file mode 100644 index 0000000..6381325 Binary files /dev/null and b/main/assets/locales/it-IT/activation.ogg differ diff --git a/main/assets/locales/it-IT/err_pin.ogg b/main/assets/locales/it-IT/err_pin.ogg new file mode 100644 index 0000000..3fb7107 Binary files /dev/null and b/main/assets/locales/it-IT/err_pin.ogg differ diff --git a/main/assets/locales/it-IT/err_reg.ogg b/main/assets/locales/it-IT/err_reg.ogg new file mode 100644 index 0000000..3bbe56e Binary files /dev/null and b/main/assets/locales/it-IT/err_reg.ogg differ diff --git a/main/assets/locales/it-IT/language.json b/main/assets/locales/it-IT/language.json new file mode 100644 index 0000000..f67c7ea --- /dev/null +++ b/main/assets/locales/it-IT/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "it-IT" + }, + "strings": { + "WARNING": "Avviso", + "INFO": "Informazione", + "ERROR": "Errore", + "VERSION": "Versione ", + "LOADING_PROTOCOL": "Connessione al server...", + "INITIALIZING": "Inizializzazione...", + "PIN_ERROR": "Inserire la scheda SIM", + "REG_ERROR": "Impossibile accedere alla rete, controllare lo stato della scheda dati", + "DETECTING_MODULE": "Rilevamento modulo...", + "REGISTERING_NETWORK": "In attesa della rete...", + "CHECKING_NEW_VERSION": "Controllo nuova versione...", + "CHECK_NEW_VERSION_FAILED": "Controllo nuova versione fallito, riprovo tra %d secondi: %s", + "SWITCH_TO_WIFI_NETWORK": "Passaggio a Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Passaggio a 4G...", + "STANDBY": "In attesa", + "CONNECT_TO": "Connetti a ", + "CONNECTING": "Connessione...", + "CONNECTED_TO": "Connesso a ", + "LISTENING": "In ascolto...", + "SPEAKING": "Parlando...", + "SERVER_NOT_FOUND": "Ricerca servizio disponibile", + "SERVER_NOT_CONNECTED": "Impossibile connettersi al servizio, riprovare più tardi", + "SERVER_TIMEOUT": "Timeout risposta", + "SERVER_ERROR": "Invio fallito, controllare la rete", + "CONNECT_TO_HOTSPOT": "Connetti telefono al hotspot ", + "ACCESS_VIA_BROWSER": ",accedi tramite browser ", + "WIFI_CONFIG_MODE": "Modalità configurazione rete", + "ENTERING_WIFI_CONFIG_MODE": "Entrata in modalità configurazione rete...", + "SCANNING_WIFI": "Scansione Wi-Fi...", + "NEW_VERSION": "Nuova versione ", + "OTA_UPGRADE": "Aggiornamento OTA", + "UPGRADING": "Aggiornamento sistema...", + "UPGRADE_FAILED": "Aggiornamento fallito", + "ACTIVATION": "Attivazione dispositivo", + "BATTERY_LOW": "Batteria scarica", + "BATTERY_CHARGING": "In carica", + "BATTERY_FULL": "Batteria piena", + "BATTERY_NEED_CHARGE": "Batteria scarica, ricaricare", + "VOLUME": "Volume ", + "MUTED": "Silenziato", + "MAX_VOLUME": "Volume massimo", + "RTC_MODE_OFF": "AEC disattivato", + "RTC_MODE_ON": "AEC attivato", + "DOWNLOAD_ASSETS_FAILED": "Impossibile scaricare le risorse", + "LOADING_ASSETS": "Caricamento risorse...", + "PLEASE_WAIT": "Attendere prego...", + "FOUND_NEW_ASSETS": "Trovate nuove risorse: %s", + "HELLO_MY_FRIEND": "Ciao, amico mio!", + "CONNECTION_SUCCESSFUL": "Connessione riuscita", + "FLIGHT_MODE_OFF": "La modalità aereo è disattivata", + "FLIGHT_MODE_ON": "La modalità aereo è attiva", + "MODEM_INIT_ERROR": "Inizializzazione modem non riuscita" + } +} \ No newline at end of file diff --git a/main/assets/locales/it-IT/upgrade.ogg b/main/assets/locales/it-IT/upgrade.ogg new file mode 100644 index 0000000..79c0048 Binary files /dev/null and b/main/assets/locales/it-IT/upgrade.ogg differ diff --git a/main/assets/locales/it-IT/welcome.ogg b/main/assets/locales/it-IT/welcome.ogg new file mode 100644 index 0000000..702def3 Binary files /dev/null and b/main/assets/locales/it-IT/welcome.ogg differ diff --git a/main/assets/locales/it-IT/wificonfig.ogg b/main/assets/locales/it-IT/wificonfig.ogg new file mode 100644 index 0000000..7aae624 Binary files /dev/null and b/main/assets/locales/it-IT/wificonfig.ogg differ diff --git a/main/assets/locales/ja-JP/0.ogg b/main/assets/locales/ja-JP/0.ogg new file mode 100644 index 0000000..5b24d6a Binary files /dev/null and b/main/assets/locales/ja-JP/0.ogg differ diff --git a/main/assets/locales/ja-JP/1.ogg b/main/assets/locales/ja-JP/1.ogg new file mode 100644 index 0000000..e31933d Binary files /dev/null and b/main/assets/locales/ja-JP/1.ogg differ diff --git a/main/assets/locales/ja-JP/2.ogg b/main/assets/locales/ja-JP/2.ogg new file mode 100644 index 0000000..f75a934 Binary files /dev/null and b/main/assets/locales/ja-JP/2.ogg differ diff --git a/main/assets/locales/ja-JP/3.ogg b/main/assets/locales/ja-JP/3.ogg new file mode 100644 index 0000000..e414b59 Binary files /dev/null and b/main/assets/locales/ja-JP/3.ogg differ diff --git a/main/assets/locales/ja-JP/4.ogg b/main/assets/locales/ja-JP/4.ogg new file mode 100644 index 0000000..a977c97 Binary files /dev/null and b/main/assets/locales/ja-JP/4.ogg differ diff --git a/main/assets/locales/ja-JP/5.ogg b/main/assets/locales/ja-JP/5.ogg new file mode 100644 index 0000000..52ccf1b Binary files /dev/null and b/main/assets/locales/ja-JP/5.ogg differ diff --git a/main/assets/locales/ja-JP/6.ogg b/main/assets/locales/ja-JP/6.ogg new file mode 100644 index 0000000..361f2e6 Binary files /dev/null and b/main/assets/locales/ja-JP/6.ogg differ diff --git a/main/assets/locales/ja-JP/7.ogg b/main/assets/locales/ja-JP/7.ogg new file mode 100644 index 0000000..e98be57 Binary files /dev/null and b/main/assets/locales/ja-JP/7.ogg differ diff --git a/main/assets/locales/ja-JP/8.ogg b/main/assets/locales/ja-JP/8.ogg new file mode 100644 index 0000000..2f33874 Binary files /dev/null and b/main/assets/locales/ja-JP/8.ogg differ diff --git a/main/assets/locales/ja-JP/9.ogg b/main/assets/locales/ja-JP/9.ogg new file mode 100644 index 0000000..c150a18 Binary files /dev/null and b/main/assets/locales/ja-JP/9.ogg differ diff --git a/main/assets/locales/ja-JP/activation.ogg b/main/assets/locales/ja-JP/activation.ogg new file mode 100644 index 0000000..995a489 Binary files /dev/null and b/main/assets/locales/ja-JP/activation.ogg differ diff --git a/main/assets/locales/ja-JP/err_pin.ogg b/main/assets/locales/ja-JP/err_pin.ogg new file mode 100644 index 0000000..120bd6c Binary files /dev/null and b/main/assets/locales/ja-JP/err_pin.ogg differ diff --git a/main/assets/locales/ja-JP/err_reg.ogg b/main/assets/locales/ja-JP/err_reg.ogg new file mode 100644 index 0000000..27ccc93 Binary files /dev/null and b/main/assets/locales/ja-JP/err_reg.ogg differ diff --git a/main/assets/locales/ja-JP/language.json b/main/assets/locales/ja-JP/language.json new file mode 100644 index 0000000..20dbe5d --- /dev/null +++ b/main/assets/locales/ja-JP/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "ja-JP" + }, + "strings": { + "WARNING": "警告", + "INFO": "情報", + "ERROR": "エラー", + "VERSION": "バージョン ", + "LOADING_PROTOCOL": "サーバーにログイン中...", + "INITIALIZING": "初期化中...", + "PIN_ERROR": "SIMカードを挿入してください", + "REG_ERROR": "ネットワークに接続できません。ネットワーク状態を確認してください", + "DETECTING_MODULE": "モジュールを検出中...", + "REGISTERING_NETWORK": "ネットワーク接続待機中...", + "CHECKING_NEW_VERSION": "新しいバージョンを確認中...", + "CHECK_NEW_VERSION_FAILED": "更新確認に失敗しました。%d 秒後に再試行します: %s", + "SWITCH_TO_WIFI_NETWORK": "Wi-Fiに切り替え中...", + "SWITCH_TO_4G_NETWORK": "4Gに切り替え中...", + "STANDBY": "待機中", + "CONNECT_TO": "接続先 ", + "CONNECTING": "接続中...", + "CONNECTED_TO": "接続完了 ", + "LISTENING": "リスニング中...", + "SPEAKING": "話しています...", + "SERVER_NOT_FOUND": "利用可能なサーバーを探しています", + "SERVER_NOT_CONNECTED": "サーバーに接続できません。後でもう一度お試しください", + "SERVER_TIMEOUT": "応答待機時間が終了しました", + "SERVER_ERROR": "送信に失敗しました。ネットワークを確認してください", + "CONNECT_TO_HOTSPOT": "スマートフォンをWi-Fi ", + "ACCESS_VIA_BROWSER": " に接続し、ブラウザでアクセスしてください ", + "WIFI_CONFIG_MODE": "ネットワーク設定モード", + "ENTERING_WIFI_CONFIG_MODE": "ネットワーク設定中...", + "SCANNING_WIFI": "Wi-Fiをスキャン中...", + "NEW_VERSION": "新しいバージョン ", + "OTA_UPGRADE": "OTAアップグレード", + "UPGRADING": "システムをアップグレード中...", + "UPGRADE_FAILED": "アップグレード失敗", + "ACTIVATION": "デバイスをアクティベート", + "BATTERY_LOW": "バッテリーが少なくなっています", + "BATTERY_CHARGING": "充電中", + "BATTERY_FULL": "バッテリー満タン", + "BATTERY_NEED_CHARGE": "バッテリーが低下しています。充電してください", + "VOLUME": "音量 ", + "MUTED": "ミュートされています", + "MAX_VOLUME": "最大音量", + "RTC_MODE_OFF": "AEC 無効", + "RTC_MODE_ON": "AEC 有効", + "DOWNLOAD_ASSETS_FAILED": "アセットのダウンロードに失敗しました", + "LOADING_ASSETS": "アセットを読み込み中...", + "PLEASE_WAIT": "お待ちください...", + "FOUND_NEW_ASSETS": "新しいアセットが見つかりました: %s", + "HELLO_MY_FRIEND": "こんにちは、友達!", + "CONNECTION_SUCCESSFUL": "接続成功", + "FLIGHT_MODE_OFF": "機内モードがオフです", + "FLIGHT_MODE_ON": "機内モードがオンです", + "MODEM_INIT_ERROR": "モデムの初期化に失敗しました" + } +} \ No newline at end of file diff --git a/main/assets/locales/ja-JP/upgrade.ogg b/main/assets/locales/ja-JP/upgrade.ogg new file mode 100644 index 0000000..1994026 Binary files /dev/null and b/main/assets/locales/ja-JP/upgrade.ogg differ diff --git a/main/assets/locales/ja-JP/welcome.ogg b/main/assets/locales/ja-JP/welcome.ogg new file mode 100644 index 0000000..f2a4f17 Binary files /dev/null and b/main/assets/locales/ja-JP/welcome.ogg differ diff --git a/main/assets/locales/ja-JP/wificonfig.ogg b/main/assets/locales/ja-JP/wificonfig.ogg new file mode 100644 index 0000000..16fbf94 Binary files /dev/null and b/main/assets/locales/ja-JP/wificonfig.ogg differ diff --git a/main/assets/locales/ko-KR/0.ogg b/main/assets/locales/ko-KR/0.ogg new file mode 100644 index 0000000..7c29fe5 Binary files /dev/null and b/main/assets/locales/ko-KR/0.ogg differ diff --git a/main/assets/locales/ko-KR/1.ogg b/main/assets/locales/ko-KR/1.ogg new file mode 100644 index 0000000..031d5d8 Binary files /dev/null and b/main/assets/locales/ko-KR/1.ogg differ diff --git a/main/assets/locales/ko-KR/2.ogg b/main/assets/locales/ko-KR/2.ogg new file mode 100644 index 0000000..7a12499 Binary files /dev/null and b/main/assets/locales/ko-KR/2.ogg differ diff --git a/main/assets/locales/ko-KR/3.ogg b/main/assets/locales/ko-KR/3.ogg new file mode 100644 index 0000000..c3b7a52 Binary files /dev/null and b/main/assets/locales/ko-KR/3.ogg differ diff --git a/main/assets/locales/ko-KR/4.ogg b/main/assets/locales/ko-KR/4.ogg new file mode 100644 index 0000000..79f515e Binary files /dev/null and b/main/assets/locales/ko-KR/4.ogg differ diff --git a/main/assets/locales/ko-KR/5.ogg b/main/assets/locales/ko-KR/5.ogg new file mode 100644 index 0000000..2cc2c65 Binary files /dev/null and b/main/assets/locales/ko-KR/5.ogg differ diff --git a/main/assets/locales/ko-KR/6.ogg b/main/assets/locales/ko-KR/6.ogg new file mode 100644 index 0000000..84653eb Binary files /dev/null and b/main/assets/locales/ko-KR/6.ogg differ diff --git a/main/assets/locales/ko-KR/7.ogg b/main/assets/locales/ko-KR/7.ogg new file mode 100644 index 0000000..e3e6515 Binary files /dev/null and b/main/assets/locales/ko-KR/7.ogg differ diff --git a/main/assets/locales/ko-KR/8.ogg b/main/assets/locales/ko-KR/8.ogg new file mode 100644 index 0000000..b9b7607 Binary files /dev/null and b/main/assets/locales/ko-KR/8.ogg differ diff --git a/main/assets/locales/ko-KR/9.ogg b/main/assets/locales/ko-KR/9.ogg new file mode 100644 index 0000000..9060b21 Binary files /dev/null and b/main/assets/locales/ko-KR/9.ogg differ diff --git a/main/assets/locales/ko-KR/activation.ogg b/main/assets/locales/ko-KR/activation.ogg new file mode 100644 index 0000000..1af58af Binary files /dev/null and b/main/assets/locales/ko-KR/activation.ogg differ diff --git a/main/assets/locales/ko-KR/err_pin.ogg b/main/assets/locales/ko-KR/err_pin.ogg new file mode 100644 index 0000000..508e926 Binary files /dev/null and b/main/assets/locales/ko-KR/err_pin.ogg differ diff --git a/main/assets/locales/ko-KR/err_reg.ogg b/main/assets/locales/ko-KR/err_reg.ogg new file mode 100644 index 0000000..68561fe Binary files /dev/null and b/main/assets/locales/ko-KR/err_reg.ogg differ diff --git a/main/assets/locales/ko-KR/language.json b/main/assets/locales/ko-KR/language.json new file mode 100644 index 0000000..4ccc066 --- /dev/null +++ b/main/assets/locales/ko-KR/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "ko-KR" + }, + "strings": { + "WARNING": "경고", + "INFO": "정보", + "ERROR": "오류", + "VERSION": "버전 ", + "LOADING_PROTOCOL": "로그인 중...", + "INITIALIZING": "초기화 중...", + "PIN_ERROR": "SIM 카드를 삽입하세요", + "REG_ERROR": "네트워크에 접속할 수 없습니다. SIM 카드 상태를 확인하세요", + "DETECTING_MODULE": "모듈 감지 중...", + "REGISTERING_NETWORK": "네트워크 대기 중...", + "CHECKING_NEW_VERSION": "새 버전 확인 중...", + "CHECK_NEW_VERSION_FAILED": "새 버전 확인에 실패했습니다. %d초 후에 다시 시도합니다: %s", + "SWITCH_TO_WIFI_NETWORK": "Wi-Fi로 전환 중...", + "SWITCH_TO_4G_NETWORK": "4G로 전환 중...", + "STANDBY": "대기", + "CONNECT_TO": "연결 대상: ", + "CONNECTING": "연결 중...", + "CONNECTION_SUCCESSFUL": "연결 성공", + "CONNECTED_TO": "연결됨: ", + "LISTENING": "듣는 중...", + "SPEAKING": "말하는 중...", + "SERVER_NOT_FOUND": "사용 가능한 서비스를 찾는 중", + "SERVER_NOT_CONNECTED": "서비스에 연결할 수 없습니다. 나중에 다시 시도하세요", + "SERVER_TIMEOUT": "응답 대기 시간 초과", + "SERVER_ERROR": "전송 실패, 네트워크를 확인하세요", + "CONNECT_TO_HOTSPOT": "핫스팟: ", + "ACCESS_VIA_BROWSER": " 설정 URL: ", + "WIFI_CONFIG_MODE": "Wi-Fi 설정 모드", + "ENTERING_WIFI_CONFIG_MODE": "Wi-Fi 설정 모드 진입 중...", + "SCANNING_WIFI": "Wi-Fi 스캔 중...", + "NEW_VERSION": "새 버전 ", + "OTA_UPGRADE": "OTA 업그레이드", + "UPGRADING": "시스템 업그레이드 중...", + "UPGRADE_FAILED": "업그레이드 실패", + "ACTIVATION": "활성화", + "BATTERY_LOW": "배터리 부족", + "BATTERY_CHARGING": "충전 중", + "BATTERY_FULL": "배터리 완충", + "BATTERY_NEED_CHARGE": "배터리 부족, 충전하세요", + "VOLUME": "볼륨 ", + "MUTED": "음소거", + "MAX_VOLUME": "최대 볼륨", + "RTC_MODE_OFF": "AEC 끄기", + "RTC_MODE_ON": "AEC 켜기", + "DOWNLOAD_ASSETS_FAILED": "에셋 다운로드 실패", + "LOADING_ASSETS": "에셋 로딩 중...", + "PLEASE_WAIT": "잠시 기다려 주세요...", + "FOUND_NEW_ASSETS": "새로운 에셋을 발견했습니다: %s", + "HELLO_MY_FRIEND": "안녕하세요, 친구!", + "FLIGHT_MODE_OFF": "비행기 모드가 꺼져 있습니다", + "FLIGHT_MODE_ON": "비행기 모드가 켜져 있습니다", + "MODEM_INIT_ERROR": "모뎀 초기화 실패" + } +} \ No newline at end of file diff --git a/main/assets/locales/ko-KR/upgrade.ogg b/main/assets/locales/ko-KR/upgrade.ogg new file mode 100644 index 0000000..0656a78 Binary files /dev/null and b/main/assets/locales/ko-KR/upgrade.ogg differ diff --git a/main/assets/locales/ko-KR/welcome.ogg b/main/assets/locales/ko-KR/welcome.ogg new file mode 100644 index 0000000..59621f4 Binary files /dev/null and b/main/assets/locales/ko-KR/welcome.ogg differ diff --git a/main/assets/locales/ko-KR/wificonfig.ogg b/main/assets/locales/ko-KR/wificonfig.ogg new file mode 100644 index 0000000..e1610af Binary files /dev/null and b/main/assets/locales/ko-KR/wificonfig.ogg differ diff --git a/main/assets/locales/ms-MY/0.ogg b/main/assets/locales/ms-MY/0.ogg new file mode 100644 index 0000000..2a6fc02 Binary files /dev/null and b/main/assets/locales/ms-MY/0.ogg differ diff --git a/main/assets/locales/ms-MY/1.ogg b/main/assets/locales/ms-MY/1.ogg new file mode 100644 index 0000000..01103be Binary files /dev/null and b/main/assets/locales/ms-MY/1.ogg differ diff --git a/main/assets/locales/ms-MY/2.ogg b/main/assets/locales/ms-MY/2.ogg new file mode 100644 index 0000000..52e8ac6 Binary files /dev/null and b/main/assets/locales/ms-MY/2.ogg differ diff --git a/main/assets/locales/ms-MY/3.ogg b/main/assets/locales/ms-MY/3.ogg new file mode 100644 index 0000000..16e3d76 Binary files /dev/null and b/main/assets/locales/ms-MY/3.ogg differ diff --git a/main/assets/locales/ms-MY/4.ogg b/main/assets/locales/ms-MY/4.ogg new file mode 100644 index 0000000..3a3ec0a Binary files /dev/null and b/main/assets/locales/ms-MY/4.ogg differ diff --git a/main/assets/locales/ms-MY/5.ogg b/main/assets/locales/ms-MY/5.ogg new file mode 100644 index 0000000..3dd6c06 Binary files /dev/null and b/main/assets/locales/ms-MY/5.ogg differ diff --git a/main/assets/locales/ms-MY/6.ogg b/main/assets/locales/ms-MY/6.ogg new file mode 100644 index 0000000..a8e3aed Binary files /dev/null and b/main/assets/locales/ms-MY/6.ogg differ diff --git a/main/assets/locales/ms-MY/7.ogg b/main/assets/locales/ms-MY/7.ogg new file mode 100644 index 0000000..d2fc950 Binary files /dev/null and b/main/assets/locales/ms-MY/7.ogg differ diff --git a/main/assets/locales/ms-MY/8.ogg b/main/assets/locales/ms-MY/8.ogg new file mode 100644 index 0000000..20e94da Binary files /dev/null and b/main/assets/locales/ms-MY/8.ogg differ diff --git a/main/assets/locales/ms-MY/9.ogg b/main/assets/locales/ms-MY/9.ogg new file mode 100644 index 0000000..61f7b96 Binary files /dev/null and b/main/assets/locales/ms-MY/9.ogg differ diff --git a/main/assets/locales/ms-MY/activation.ogg b/main/assets/locales/ms-MY/activation.ogg new file mode 100644 index 0000000..8fea3ae Binary files /dev/null and b/main/assets/locales/ms-MY/activation.ogg differ diff --git a/main/assets/locales/ms-MY/err_pin.ogg b/main/assets/locales/ms-MY/err_pin.ogg new file mode 100644 index 0000000..8221763 Binary files /dev/null and b/main/assets/locales/ms-MY/err_pin.ogg differ diff --git a/main/assets/locales/ms-MY/err_reg.ogg b/main/assets/locales/ms-MY/err_reg.ogg new file mode 100644 index 0000000..51547b6 Binary files /dev/null and b/main/assets/locales/ms-MY/err_reg.ogg differ diff --git a/main/assets/locales/ms-MY/language.json b/main/assets/locales/ms-MY/language.json new file mode 100644 index 0000000..4797cfc --- /dev/null +++ b/main/assets/locales/ms-MY/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "ms-MY" + }, + "strings": { + "WARNING": "Amaran", + "INFO": "Maklumat", + "ERROR": "Ralat", + "VERSION": "Versi ", + "LOADING_PROTOCOL": "Log masuk...", + "INITIALIZING": "Memulakan...", + "PIN_ERROR": "Sila masukkan kad SIM", + "REG_ERROR": "Tidak dapat mengakses rangkaian, sila semak status kad SIM", + "DETECTING_MODULE": "Mengesan modul...", + "REGISTERING_NETWORK": "Menunggu rangkaian...", + "CHECKING_NEW_VERSION": "Memeriksa versi baharu...", + "CHECK_NEW_VERSION_FAILED": "Pemeriksaan versi baharu gagal, akan cuba semula dalam %d saat: %s", + "SWITCH_TO_WIFI_NETWORK": "Bertukar ke Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Bertukar ke 4G...", + "STANDBY": "Bersedia", + "CONNECT_TO": "Sambung ke ", + "CONNECTING": "Menyambung...", + "CONNECTION_SUCCESSFUL": "Sambungan berjaya", + "CONNECTED_TO": "Disambungkan ke ", + "LISTENING": "Mendengar...", + "SPEAKING": "Bercakap...", + "SERVER_NOT_FOUND": "Mencari perkhidmatan yang tersedia", + "SERVER_NOT_CONNECTED": "Tidak dapat menyambung ke perkhidmatan, sila cuba lagi kemudian", + "SERVER_TIMEOUT": "Masa tamat menunggu respons", + "SERVER_ERROR": "Penghantaran gagal, sila semak rangkaian", + "CONNECT_TO_HOTSPOT": "Hotspot: ", + "ACCESS_VIA_BROWSER": " URL konfigurasi: ", + "WIFI_CONFIG_MODE": "Mod Konfigurasi Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "Memasuki mod konfigurasi Wi-Fi...", + "SCANNING_WIFI": "Mengimbas Wi-Fi...", + "NEW_VERSION": "Versi baharu ", + "OTA_UPGRADE": "Peningkatan OTA", + "UPGRADING": "Sistem sedang dinaik taraf...", + "UPGRADE_FAILED": "Peningkatan gagal", + "ACTIVATION": "Pengaktifan", + "BATTERY_LOW": "Bateri lemah", + "BATTERY_CHARGING": "Mengecas", + "BATTERY_FULL": "Bateri penuh", + "BATTERY_NEED_CHARGE": "Bateri lemah, sila cas", + "VOLUME": "Kelantangan ", + "MUTED": "Dibisukan", + "MAX_VOLUME": "Kelantangan maksimum", + "RTC_MODE_OFF": "AEC Dimatikan", + "RTC_MODE_ON": "AEC Dihidupkan", + "PLEASE_WAIT": "Sila tunggu...", + "FOUND_NEW_ASSETS": "Menemui aset baharu: %s", + "DOWNLOAD_ASSETS_FAILED": "Gagal memuat turun aset", + "LOADING_ASSETS": "Memuatkan aset...", + "HELLO_MY_FRIEND": "Hai, kawan saya!", + "FLIGHT_MODE_OFF": "Mod penerbangan dimatikan", + "FLIGHT_MODE_ON": "Mod penerbangan dihidupkan", + "MODEM_INIT_ERROR": "Modem gagal dimulakan" + } +} \ No newline at end of file diff --git a/main/assets/locales/ms-MY/upgrade.ogg b/main/assets/locales/ms-MY/upgrade.ogg new file mode 100644 index 0000000..0bfcdcc Binary files /dev/null and b/main/assets/locales/ms-MY/upgrade.ogg differ diff --git a/main/assets/locales/ms-MY/welcome.ogg b/main/assets/locales/ms-MY/welcome.ogg new file mode 100644 index 0000000..7cd32e0 Binary files /dev/null and b/main/assets/locales/ms-MY/welcome.ogg differ diff --git a/main/assets/locales/ms-MY/wificonfig.ogg b/main/assets/locales/ms-MY/wificonfig.ogg new file mode 100644 index 0000000..a257b1d Binary files /dev/null and b/main/assets/locales/ms-MY/wificonfig.ogg differ diff --git a/main/assets/locales/nb-NO/0.ogg b/main/assets/locales/nb-NO/0.ogg new file mode 100644 index 0000000..cc14677 Binary files /dev/null and b/main/assets/locales/nb-NO/0.ogg differ diff --git a/main/assets/locales/nb-NO/1.ogg b/main/assets/locales/nb-NO/1.ogg new file mode 100644 index 0000000..1d32c29 Binary files /dev/null and b/main/assets/locales/nb-NO/1.ogg differ diff --git a/main/assets/locales/nb-NO/2.ogg b/main/assets/locales/nb-NO/2.ogg new file mode 100644 index 0000000..00d3536 Binary files /dev/null and b/main/assets/locales/nb-NO/2.ogg differ diff --git a/main/assets/locales/nb-NO/3.ogg b/main/assets/locales/nb-NO/3.ogg new file mode 100644 index 0000000..d8d4711 Binary files /dev/null and b/main/assets/locales/nb-NO/3.ogg differ diff --git a/main/assets/locales/nb-NO/4.ogg b/main/assets/locales/nb-NO/4.ogg new file mode 100644 index 0000000..eafc4ea Binary files /dev/null and b/main/assets/locales/nb-NO/4.ogg differ diff --git a/main/assets/locales/nb-NO/5.ogg b/main/assets/locales/nb-NO/5.ogg new file mode 100644 index 0000000..61d36bc Binary files /dev/null and b/main/assets/locales/nb-NO/5.ogg differ diff --git a/main/assets/locales/nb-NO/6.ogg b/main/assets/locales/nb-NO/6.ogg new file mode 100644 index 0000000..cfa848e Binary files /dev/null and b/main/assets/locales/nb-NO/6.ogg differ diff --git a/main/assets/locales/nb-NO/7.ogg b/main/assets/locales/nb-NO/7.ogg new file mode 100644 index 0000000..9391485 Binary files /dev/null and b/main/assets/locales/nb-NO/7.ogg differ diff --git a/main/assets/locales/nb-NO/8.ogg b/main/assets/locales/nb-NO/8.ogg new file mode 100644 index 0000000..946b7ac Binary files /dev/null and b/main/assets/locales/nb-NO/8.ogg differ diff --git a/main/assets/locales/nb-NO/9.ogg b/main/assets/locales/nb-NO/9.ogg new file mode 100644 index 0000000..8311b58 Binary files /dev/null and b/main/assets/locales/nb-NO/9.ogg differ diff --git a/main/assets/locales/nb-NO/activation.ogg b/main/assets/locales/nb-NO/activation.ogg new file mode 100644 index 0000000..8d7be02 Binary files /dev/null and b/main/assets/locales/nb-NO/activation.ogg differ diff --git a/main/assets/locales/nb-NO/err_pin.ogg b/main/assets/locales/nb-NO/err_pin.ogg new file mode 100644 index 0000000..1b6c81c Binary files /dev/null and b/main/assets/locales/nb-NO/err_pin.ogg differ diff --git a/main/assets/locales/nb-NO/err_reg.ogg b/main/assets/locales/nb-NO/err_reg.ogg new file mode 100644 index 0000000..c505fff Binary files /dev/null and b/main/assets/locales/nb-NO/err_reg.ogg differ diff --git a/main/assets/locales/nb-NO/language.json b/main/assets/locales/nb-NO/language.json new file mode 100644 index 0000000..4540e63 --- /dev/null +++ b/main/assets/locales/nb-NO/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "nb-NO" + }, + "strings": { + "WARNING": "Advarsel", + "INFO": "Informasjon", + "ERROR": "Feil", + "VERSION": "Versjon ", + "LOADING_PROTOCOL": "Logger inn...", + "INITIALIZING": "Initialiserer...", + "PIN_ERROR": "Vennligst sett inn SIM-kort", + "REG_ERROR": "Kan ikke få tilgang til nettverket, vennligst sjekk SIM-kortets status", + "DETECTING_MODULE": "Oppdager modul...", + "REGISTERING_NETWORK": "Venter på nettverk...", + "CHECKING_NEW_VERSION": "Sjekker for ny versjon...", + "CHECK_NEW_VERSION_FAILED": "Sjekk for ny versjon mislyktes, prøver på nytt om %d sekunder: %s", + "SWITCH_TO_WIFI_NETWORK": "Bytter til Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Bytter til 4G...", + "STANDBY": "Standby", + "CONNECT_TO": "Koble til ", + "CONNECTING": "Kobler til...", + "CONNECTION_SUCCESSFUL": "Tilkobling vellykket", + "CONNECTED_TO": "Koblet til ", + "LISTENING": "Lytter...", + "SPEAKING": "Snakker...", + "SERVER_NOT_FOUND": "Søker etter tilgjengelig tjeneste", + "SERVER_NOT_CONNECTED": "Kan ikke koble til tjeneste, vennligst prøv igjen senere", + "SERVER_TIMEOUT": "Tidsavbrudd for å vente på svar", + "SERVER_ERROR": "Sending mislyktes, vennligst sjekk nettverket", + "CONNECT_TO_HOTSPOT": "Hotspot: ", + "ACCESS_VIA_BROWSER": " Konfigurasjon-URL: ", + "WIFI_CONFIG_MODE": "Wi-Fi konfigurasjonsmodus", + "ENTERING_WIFI_CONFIG_MODE": "Går inn i Wi-Fi konfigurasjonsmodus...", + "SCANNING_WIFI": "Skanner Wi-Fi...", + "NEW_VERSION": "Ny versjon ", + "OTA_UPGRADE": "OTA-oppgradering", + "UPGRADING": "Systemet oppgraderes...", + "UPGRADE_FAILED": "Oppgradering mislyktes", + "ACTIVATION": "Aktivering", + "BATTERY_LOW": "Lavt batteri", + "BATTERY_CHARGING": "Lader", + "BATTERY_FULL": "Batteriet er fullt", + "BATTERY_NEED_CHARGE": "Lavt batteri, vennligst lad", + "VOLUME": "Volum ", + "MUTED": "Dempet", + "MAX_VOLUME": "Maksimalt volum", + "RTC_MODE_OFF": "AEC av", + "RTC_MODE_ON": "AEC på", + "PLEASE_WAIT": "Vennligst vent...", + "FOUND_NEW_ASSETS": "Fant nye ressurser: %s", + "DOWNLOAD_ASSETS_FAILED": "Nedlasting av ressurser mislyktes", + "LOADING_ASSETS": "Laster ressurser...", + "HELLO_MY_FRIEND": "Hei, min venn!", + "FLIGHT_MODE_OFF": "Flymodus er av", + "FLIGHT_MODE_ON": "Flymodus er på", + "MODEM_INIT_ERROR": "Modeminitialisering mislyktes" + } +} \ No newline at end of file diff --git a/main/assets/locales/nb-NO/upgrade.ogg b/main/assets/locales/nb-NO/upgrade.ogg new file mode 100644 index 0000000..dc914d5 Binary files /dev/null and b/main/assets/locales/nb-NO/upgrade.ogg differ diff --git a/main/assets/locales/nb-NO/welcome.ogg b/main/assets/locales/nb-NO/welcome.ogg new file mode 100644 index 0000000..0e25dc9 Binary files /dev/null and b/main/assets/locales/nb-NO/welcome.ogg differ diff --git a/main/assets/locales/nb-NO/wificonfig.ogg b/main/assets/locales/nb-NO/wificonfig.ogg new file mode 100644 index 0000000..2d0b4a4 Binary files /dev/null and b/main/assets/locales/nb-NO/wificonfig.ogg differ diff --git a/main/assets/locales/nl-NL/0.ogg b/main/assets/locales/nl-NL/0.ogg new file mode 100644 index 0000000..aba888e Binary files /dev/null and b/main/assets/locales/nl-NL/0.ogg differ diff --git a/main/assets/locales/nl-NL/1.ogg b/main/assets/locales/nl-NL/1.ogg new file mode 100644 index 0000000..31b8273 Binary files /dev/null and b/main/assets/locales/nl-NL/1.ogg differ diff --git a/main/assets/locales/nl-NL/2.ogg b/main/assets/locales/nl-NL/2.ogg new file mode 100644 index 0000000..a4e5457 Binary files /dev/null and b/main/assets/locales/nl-NL/2.ogg differ diff --git a/main/assets/locales/nl-NL/3.ogg b/main/assets/locales/nl-NL/3.ogg new file mode 100644 index 0000000..95a59cd Binary files /dev/null and b/main/assets/locales/nl-NL/3.ogg differ diff --git a/main/assets/locales/nl-NL/4.ogg b/main/assets/locales/nl-NL/4.ogg new file mode 100644 index 0000000..8d21287 Binary files /dev/null and b/main/assets/locales/nl-NL/4.ogg differ diff --git a/main/assets/locales/nl-NL/5.ogg b/main/assets/locales/nl-NL/5.ogg new file mode 100644 index 0000000..307fbfb Binary files /dev/null and b/main/assets/locales/nl-NL/5.ogg differ diff --git a/main/assets/locales/nl-NL/6.ogg b/main/assets/locales/nl-NL/6.ogg new file mode 100644 index 0000000..f03555b Binary files /dev/null and b/main/assets/locales/nl-NL/6.ogg differ diff --git a/main/assets/locales/nl-NL/7.ogg b/main/assets/locales/nl-NL/7.ogg new file mode 100644 index 0000000..759a596 Binary files /dev/null and b/main/assets/locales/nl-NL/7.ogg differ diff --git a/main/assets/locales/nl-NL/8.ogg b/main/assets/locales/nl-NL/8.ogg new file mode 100644 index 0000000..3d4183a Binary files /dev/null and b/main/assets/locales/nl-NL/8.ogg differ diff --git a/main/assets/locales/nl-NL/9.ogg b/main/assets/locales/nl-NL/9.ogg new file mode 100644 index 0000000..af53ac4 Binary files /dev/null and b/main/assets/locales/nl-NL/9.ogg differ diff --git a/main/assets/locales/nl-NL/activation.ogg b/main/assets/locales/nl-NL/activation.ogg new file mode 100644 index 0000000..273d65a Binary files /dev/null and b/main/assets/locales/nl-NL/activation.ogg differ diff --git a/main/assets/locales/nl-NL/err_pin.ogg b/main/assets/locales/nl-NL/err_pin.ogg new file mode 100644 index 0000000..a2f83c4 Binary files /dev/null and b/main/assets/locales/nl-NL/err_pin.ogg differ diff --git a/main/assets/locales/nl-NL/err_reg.ogg b/main/assets/locales/nl-NL/err_reg.ogg new file mode 100644 index 0000000..1f61dfd Binary files /dev/null and b/main/assets/locales/nl-NL/err_reg.ogg differ diff --git a/main/assets/locales/nl-NL/language.json b/main/assets/locales/nl-NL/language.json new file mode 100644 index 0000000..1863b71 --- /dev/null +++ b/main/assets/locales/nl-NL/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "nl-NL" + }, + "strings": { + "WARNING": "Waarschuwing", + "INFO": "Informatie", + "ERROR": "Fout", + "VERSION": "Versie ", + "LOADING_PROTOCOL": "Inloggen...", + "INITIALIZING": "Initialiseren...", + "PIN_ERROR": "Plaats een SIM-kaart", + "REG_ERROR": "Geen toegang tot netwerk, controleer de SIM-kaartstatus", + "DETECTING_MODULE": "Module detecteren...", + "REGISTERING_NETWORK": "Wachten op netwerk...", + "CHECKING_NEW_VERSION": "Controleren op nieuwe versie...", + "CHECK_NEW_VERSION_FAILED": "Controle op nieuwe versie mislukt, opnieuw proberen over %d seconden: %s", + "SWITCH_TO_WIFI_NETWORK": "Overschakelen naar Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Overschakelen naar 4G...", + "STANDBY": "Stand-by", + "CONNECT_TO": "Verbinden met ", + "CONNECTING": "Verbinden...", + "CONNECTION_SUCCESSFUL": "Verbinding geslaagd", + "CONNECTED_TO": "Verbonden met ", + "LISTENING": "Luisteren...", + "SPEAKING": "Spreken...", + "SERVER_NOT_FOUND": "Zoeken naar beschikbare service", + "SERVER_NOT_CONNECTED": "Kan niet verbinden met service, probeer later opnieuw", + "SERVER_TIMEOUT": "Timeout bij wachten op reactie", + "SERVER_ERROR": "Verzenden mislukt, controleer het netwerk", + "CONNECT_TO_HOTSPOT": "Hotspot: ", + "ACCESS_VIA_BROWSER": " Configuratie-URL: ", + "WIFI_CONFIG_MODE": "Wi-Fi-configuratiemodus", + "ENTERING_WIFI_CONFIG_MODE": "Wi-Fi-configuratiemodus openen...", + "SCANNING_WIFI": "Wi-Fi scannen...", + "NEW_VERSION": "Nieuwe versie ", + "OTA_UPGRADE": "OTA-upgrade", + "UPGRADING": "Systeem wordt geüpgraded...", + "UPGRADE_FAILED": "Upgrade mislukt", + "ACTIVATION": "Activering", + "BATTERY_LOW": "Batterij bijna leeg", + "BATTERY_CHARGING": "Opladen", + "BATTERY_FULL": "Batterij vol", + "BATTERY_NEED_CHARGE": "Batterij bijna leeg, laad op", + "VOLUME": "Volume ", + "MUTED": "Gedempt", + "MAX_VOLUME": "Maximaal volume", + "RTC_MODE_OFF": "AEC uit", + "RTC_MODE_ON": "AEC aan", + "PLEASE_WAIT": "Een ogenblik geduld...", + "FOUND_NEW_ASSETS": "Nieuwe bronnen gevonden: %s", + "DOWNLOAD_ASSETS_FAILED": "Downloaden van bronnen mislukt", + "LOADING_ASSETS": "Bronnen laden...", + "HELLO_MY_FRIEND": "Hallo, mijn vriend!", + "FLIGHT_MODE_OFF": "Vliegtuigmodus is uitgeschakeld", + "FLIGHT_MODE_ON": "Vliegtuigmodus is ingeschakeld", + "MODEM_INIT_ERROR": "Modeminitialisatie mislukt" + } +} \ No newline at end of file diff --git a/main/assets/locales/nl-NL/upgrade.ogg b/main/assets/locales/nl-NL/upgrade.ogg new file mode 100644 index 0000000..8879792 Binary files /dev/null and b/main/assets/locales/nl-NL/upgrade.ogg differ diff --git a/main/assets/locales/nl-NL/welcome.ogg b/main/assets/locales/nl-NL/welcome.ogg new file mode 100644 index 0000000..6fcc6dd Binary files /dev/null and b/main/assets/locales/nl-NL/welcome.ogg differ diff --git a/main/assets/locales/nl-NL/wificonfig.ogg b/main/assets/locales/nl-NL/wificonfig.ogg new file mode 100644 index 0000000..ada5f98 Binary files /dev/null and b/main/assets/locales/nl-NL/wificonfig.ogg differ diff --git a/main/assets/locales/pl-PL/0.ogg b/main/assets/locales/pl-PL/0.ogg new file mode 100644 index 0000000..7351d96 Binary files /dev/null and b/main/assets/locales/pl-PL/0.ogg differ diff --git a/main/assets/locales/pl-PL/1.ogg b/main/assets/locales/pl-PL/1.ogg new file mode 100644 index 0000000..342c9cc Binary files /dev/null and b/main/assets/locales/pl-PL/1.ogg differ diff --git a/main/assets/locales/pl-PL/2.ogg b/main/assets/locales/pl-PL/2.ogg new file mode 100644 index 0000000..8fa7c87 Binary files /dev/null and b/main/assets/locales/pl-PL/2.ogg differ diff --git a/main/assets/locales/pl-PL/3.ogg b/main/assets/locales/pl-PL/3.ogg new file mode 100644 index 0000000..b656411 Binary files /dev/null and b/main/assets/locales/pl-PL/3.ogg differ diff --git a/main/assets/locales/pl-PL/4.ogg b/main/assets/locales/pl-PL/4.ogg new file mode 100644 index 0000000..ea49088 Binary files /dev/null and b/main/assets/locales/pl-PL/4.ogg differ diff --git a/main/assets/locales/pl-PL/5.ogg b/main/assets/locales/pl-PL/5.ogg new file mode 100644 index 0000000..8d4b3b9 Binary files /dev/null and b/main/assets/locales/pl-PL/5.ogg differ diff --git a/main/assets/locales/pl-PL/6.ogg b/main/assets/locales/pl-PL/6.ogg new file mode 100644 index 0000000..7c8fefd Binary files /dev/null and b/main/assets/locales/pl-PL/6.ogg differ diff --git a/main/assets/locales/pl-PL/7.ogg b/main/assets/locales/pl-PL/7.ogg new file mode 100644 index 0000000..7139591 Binary files /dev/null and b/main/assets/locales/pl-PL/7.ogg differ diff --git a/main/assets/locales/pl-PL/8.ogg b/main/assets/locales/pl-PL/8.ogg new file mode 100644 index 0000000..8e2dd56 Binary files /dev/null and b/main/assets/locales/pl-PL/8.ogg differ diff --git a/main/assets/locales/pl-PL/9.ogg b/main/assets/locales/pl-PL/9.ogg new file mode 100644 index 0000000..b88ab87 Binary files /dev/null and b/main/assets/locales/pl-PL/9.ogg differ diff --git a/main/assets/locales/pl-PL/activation.ogg b/main/assets/locales/pl-PL/activation.ogg new file mode 100644 index 0000000..7c1ff9d Binary files /dev/null and b/main/assets/locales/pl-PL/activation.ogg differ diff --git a/main/assets/locales/pl-PL/err_pin.ogg b/main/assets/locales/pl-PL/err_pin.ogg new file mode 100644 index 0000000..4d35f3c Binary files /dev/null and b/main/assets/locales/pl-PL/err_pin.ogg differ diff --git a/main/assets/locales/pl-PL/err_reg.ogg b/main/assets/locales/pl-PL/err_reg.ogg new file mode 100644 index 0000000..35d1452 Binary files /dev/null and b/main/assets/locales/pl-PL/err_reg.ogg differ diff --git a/main/assets/locales/pl-PL/language.json b/main/assets/locales/pl-PL/language.json new file mode 100644 index 0000000..ba02fb9 --- /dev/null +++ b/main/assets/locales/pl-PL/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "pl-PL" + }, + "strings": { + "WARNING": "Ostrzeżenie", + "INFO": "Informacja", + "ERROR": "Błąd", + "VERSION": "Wersja ", + "LOADING_PROTOCOL": "Łączenie z serwerem...", + "INITIALIZING": "Inicjalizacja...", + "PIN_ERROR": "Proszę włożyć kartę SIM", + "REG_ERROR": "Nie można uzyskać dostępu do sieci, sprawdź stan karty danych", + "DETECTING_MODULE": "Wykrywanie modułu...", + "REGISTERING_NETWORK": "Oczekiwanie na sieć...", + "CHECKING_NEW_VERSION": "Sprawdzanie nowej wersji...", + "CHECK_NEW_VERSION_FAILED": "Sprawdzanie nowej wersji nie powiodło się, ponowna próba za %d sekund: %s", + "SWITCH_TO_WIFI_NETWORK": "Przełączanie na Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Przełączanie na 4G...", + "STANDBY": "Gotowość", + "CONNECT_TO": "Połącz z ", + "CONNECTING": "Łączenie...", + "CONNECTED_TO": "Połączono z ", + "LISTENING": "Słuchanie...", + "SPEAKING": "Mówienie...", + "SERVER_NOT_FOUND": "Szukanie dostępnej usługi", + "SERVER_NOT_CONNECTED": "Nie można połączyć się z usługą, spróbuj ponownie później", + "SERVER_TIMEOUT": "Przekroczono czas oczekiwania na odpowiedź", + "SERVER_ERROR": "Wysyłanie nie powiodło się, sprawdź sieć", + "CONNECT_TO_HOTSPOT": "Podłącz telefon do hotspotu ", + "ACCESS_VIA_BROWSER": ",dostęp przez przeglądarkę ", + "WIFI_CONFIG_MODE": "Tryb konfiguracji sieci", + "ENTERING_WIFI_CONFIG_MODE": "Wchodzenie w tryb konfiguracji sieci...", + "SCANNING_WIFI": "Skanowanie Wi-Fi...", + "NEW_VERSION": "Nowa wersja ", + "OTA_UPGRADE": "Aktualizacja OTA", + "UPGRADING": "Aktualizacja systemu...", + "UPGRADE_FAILED": "Aktualizacja nie powiodła się", + "ACTIVATION": "Aktywacja urządzenia", + "BATTERY_LOW": "Niski poziom baterii", + "BATTERY_CHARGING": "Ładowanie", + "BATTERY_FULL": "Bateria pełna", + "BATTERY_NEED_CHARGE": "Niski poziom baterii, proszę naładować", + "VOLUME": "Głośność ", + "MUTED": "Wyciszony", + "MAX_VOLUME": "Maksymalna głośność", + "RTC_MODE_OFF": "AEC wyłączony", + "RTC_MODE_ON": "AEC włączony", + "DOWNLOAD_ASSETS_FAILED": "Nie udało się pobrać zasobów", + "LOADING_ASSETS": "Ładowanie zasobów...", + "PLEASE_WAIT": "Proszę czekać...", + "FOUND_NEW_ASSETS": "Znaleziono nowe zasoby: %s", + "HELLO_MY_FRIEND": "Cześć, mój przyjacielu!", + "CONNECTION_SUCCESSFUL": "Połączenie udane", + "FLIGHT_MODE_OFF": "Tryb samolotowy jest wyłączony", + "FLIGHT_MODE_ON": "Tryb samolotowy jest włączony", + "MODEM_INIT_ERROR": "Inicjalizacja modemu nie powiodła się" + } +} \ No newline at end of file diff --git a/main/assets/locales/pl-PL/upgrade.ogg b/main/assets/locales/pl-PL/upgrade.ogg new file mode 100644 index 0000000..041eee8 Binary files /dev/null and b/main/assets/locales/pl-PL/upgrade.ogg differ diff --git a/main/assets/locales/pl-PL/welcome.ogg b/main/assets/locales/pl-PL/welcome.ogg new file mode 100644 index 0000000..140db7d Binary files /dev/null and b/main/assets/locales/pl-PL/welcome.ogg differ diff --git a/main/assets/locales/pl-PL/wificonfig.ogg b/main/assets/locales/pl-PL/wificonfig.ogg new file mode 100644 index 0000000..0d2222f Binary files /dev/null and b/main/assets/locales/pl-PL/wificonfig.ogg differ diff --git a/main/assets/locales/pt-PT/0.ogg b/main/assets/locales/pt-PT/0.ogg new file mode 100644 index 0000000..80c9f8b Binary files /dev/null and b/main/assets/locales/pt-PT/0.ogg differ diff --git a/main/assets/locales/pt-PT/1.ogg b/main/assets/locales/pt-PT/1.ogg new file mode 100644 index 0000000..4299bc8 Binary files /dev/null and b/main/assets/locales/pt-PT/1.ogg differ diff --git a/main/assets/locales/pt-PT/2.ogg b/main/assets/locales/pt-PT/2.ogg new file mode 100644 index 0000000..fa67197 Binary files /dev/null and b/main/assets/locales/pt-PT/2.ogg differ diff --git a/main/assets/locales/pt-PT/3.ogg b/main/assets/locales/pt-PT/3.ogg new file mode 100644 index 0000000..da2b131 Binary files /dev/null and b/main/assets/locales/pt-PT/3.ogg differ diff --git a/main/assets/locales/pt-PT/4.ogg b/main/assets/locales/pt-PT/4.ogg new file mode 100644 index 0000000..2c593f6 Binary files /dev/null and b/main/assets/locales/pt-PT/4.ogg differ diff --git a/main/assets/locales/pt-PT/5.ogg b/main/assets/locales/pt-PT/5.ogg new file mode 100644 index 0000000..62d670d Binary files /dev/null and b/main/assets/locales/pt-PT/5.ogg differ diff --git a/main/assets/locales/pt-PT/6.ogg b/main/assets/locales/pt-PT/6.ogg new file mode 100644 index 0000000..32ea27b Binary files /dev/null and b/main/assets/locales/pt-PT/6.ogg differ diff --git a/main/assets/locales/pt-PT/7.ogg b/main/assets/locales/pt-PT/7.ogg new file mode 100644 index 0000000..c8542ed Binary files /dev/null and b/main/assets/locales/pt-PT/7.ogg differ diff --git a/main/assets/locales/pt-PT/8.ogg b/main/assets/locales/pt-PT/8.ogg new file mode 100644 index 0000000..8e54738 Binary files /dev/null and b/main/assets/locales/pt-PT/8.ogg differ diff --git a/main/assets/locales/pt-PT/9.ogg b/main/assets/locales/pt-PT/9.ogg new file mode 100644 index 0000000..e97c8fc Binary files /dev/null and b/main/assets/locales/pt-PT/9.ogg differ diff --git a/main/assets/locales/pt-PT/activation.ogg b/main/assets/locales/pt-PT/activation.ogg new file mode 100644 index 0000000..1c980ac Binary files /dev/null and b/main/assets/locales/pt-PT/activation.ogg differ diff --git a/main/assets/locales/pt-PT/err_pin.ogg b/main/assets/locales/pt-PT/err_pin.ogg new file mode 100644 index 0000000..ceb2223 Binary files /dev/null and b/main/assets/locales/pt-PT/err_pin.ogg differ diff --git a/main/assets/locales/pt-PT/err_reg.ogg b/main/assets/locales/pt-PT/err_reg.ogg new file mode 100644 index 0000000..2fa4350 Binary files /dev/null and b/main/assets/locales/pt-PT/err_reg.ogg differ diff --git a/main/assets/locales/pt-PT/language.json b/main/assets/locales/pt-PT/language.json new file mode 100644 index 0000000..d2c125a --- /dev/null +++ b/main/assets/locales/pt-PT/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "pt-PT" + }, + "strings": { + "WARNING": "Aviso", + "INFO": "Informação", + "ERROR": "Erro", + "VERSION": "Versão ", + "LOADING_PROTOCOL": "Ligando ao servidor...", + "INITIALIZING": "A inicializar...", + "PIN_ERROR": "Por favor insira o cartão SIM", + "REG_ERROR": "Não é possível aceder à rede, verifique o estado do cartão de dados", + "DETECTING_MODULE": "A detectar módulo...", + "REGISTERING_NETWORK": "À espera da rede...", + "CHECKING_NEW_VERSION": "A verificar nova versão...", + "CHECK_NEW_VERSION_FAILED": "Falha na verificação de nova versão, nova tentativa em %d segundos: %s", + "SWITCH_TO_WIFI_NETWORK": "A mudar para Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "A mudar para 4G...", + "STANDBY": "Em espera", + "CONNECT_TO": "Ligar a ", + "CONNECTING": "A ligar...", + "CONNECTED_TO": "Ligado a ", + "LISTENING": "A escutar...", + "SPEAKING": "A falar...", + "SERVER_NOT_FOUND": "A procurar serviço disponível", + "SERVER_NOT_CONNECTED": "Não é possível ligar ao serviço, tente mais tarde", + "SERVER_TIMEOUT": "Tempo limite de resposta", + "SERVER_ERROR": "Falha no envio, verifique a rede", + "CONNECT_TO_HOTSPOT": "Ligue o telefone ao hotspot ", + "ACCESS_VIA_BROWSER": ",aceder através do navegador ", + "WIFI_CONFIG_MODE": "Modo de configuração de rede", + "ENTERING_WIFI_CONFIG_MODE": "A entrar no modo de configuração de rede...", + "SCANNING_WIFI": "A procurar Wi-Fi...", + "NEW_VERSION": "Nova versão ", + "OTA_UPGRADE": "Atualização OTA", + "UPGRADING": "A atualizar sistema...", + "UPGRADE_FAILED": "Atualização falhada", + "ACTIVATION": "Ativação do dispositivo", + "BATTERY_LOW": "Bateria fraca", + "BATTERY_CHARGING": "A carregar", + "BATTERY_FULL": "Bateria cheia", + "BATTERY_NEED_CHARGE": "Bateria fraca, por favor carregue", + "VOLUME": "Volume ", + "MUTED": "Silenciado", + "MAX_VOLUME": "Volume máximo", + "RTC_MODE_OFF": "AEC desligado", + "RTC_MODE_ON": "AEC ligado", + "DOWNLOAD_ASSETS_FAILED": "Falha ao descarregar recursos", + "LOADING_ASSETS": "A carregar recursos...", + "PLEASE_WAIT": "Por favor aguarde...", + "FOUND_NEW_ASSETS": "Encontrados novos recursos: %s", + "HELLO_MY_FRIEND": "Olá, meu amigo!", + "CONNECTION_SUCCESSFUL": "Ligação bem-sucedida", + "FLIGHT_MODE_OFF": "O modo avião está desativado", + "FLIGHT_MODE_ON": "O modo avião está ativado", + "MODEM_INIT_ERROR": "Falha na inicialização do modem" + } +} \ No newline at end of file diff --git a/main/assets/locales/pt-PT/upgrade.ogg b/main/assets/locales/pt-PT/upgrade.ogg new file mode 100644 index 0000000..76bac60 Binary files /dev/null and b/main/assets/locales/pt-PT/upgrade.ogg differ diff --git a/main/assets/locales/pt-PT/welcome.ogg b/main/assets/locales/pt-PT/welcome.ogg new file mode 100644 index 0000000..74daf23 Binary files /dev/null and b/main/assets/locales/pt-PT/welcome.ogg differ diff --git a/main/assets/locales/pt-PT/wificonfig.ogg b/main/assets/locales/pt-PT/wificonfig.ogg new file mode 100644 index 0000000..784939f Binary files /dev/null and b/main/assets/locales/pt-PT/wificonfig.ogg differ diff --git a/main/assets/locales/ro-RO/0.ogg b/main/assets/locales/ro-RO/0.ogg new file mode 100644 index 0000000..67fd181 Binary files /dev/null and b/main/assets/locales/ro-RO/0.ogg differ diff --git a/main/assets/locales/ro-RO/1.ogg b/main/assets/locales/ro-RO/1.ogg new file mode 100644 index 0000000..291be66 Binary files /dev/null and b/main/assets/locales/ro-RO/1.ogg differ diff --git a/main/assets/locales/ro-RO/2.ogg b/main/assets/locales/ro-RO/2.ogg new file mode 100644 index 0000000..54caf95 Binary files /dev/null and b/main/assets/locales/ro-RO/2.ogg differ diff --git a/main/assets/locales/ro-RO/3.ogg b/main/assets/locales/ro-RO/3.ogg new file mode 100644 index 0000000..b93d5c1 Binary files /dev/null and b/main/assets/locales/ro-RO/3.ogg differ diff --git a/main/assets/locales/ro-RO/4.ogg b/main/assets/locales/ro-RO/4.ogg new file mode 100644 index 0000000..3feff21 Binary files /dev/null and b/main/assets/locales/ro-RO/4.ogg differ diff --git a/main/assets/locales/ro-RO/5.ogg b/main/assets/locales/ro-RO/5.ogg new file mode 100644 index 0000000..fe56930 Binary files /dev/null and b/main/assets/locales/ro-RO/5.ogg differ diff --git a/main/assets/locales/ro-RO/6.ogg b/main/assets/locales/ro-RO/6.ogg new file mode 100644 index 0000000..ed8aee9 Binary files /dev/null and b/main/assets/locales/ro-RO/6.ogg differ diff --git a/main/assets/locales/ro-RO/7.ogg b/main/assets/locales/ro-RO/7.ogg new file mode 100644 index 0000000..9ce02c0 Binary files /dev/null and b/main/assets/locales/ro-RO/7.ogg differ diff --git a/main/assets/locales/ro-RO/8.ogg b/main/assets/locales/ro-RO/8.ogg new file mode 100644 index 0000000..c5d3184 Binary files /dev/null and b/main/assets/locales/ro-RO/8.ogg differ diff --git a/main/assets/locales/ro-RO/9.ogg b/main/assets/locales/ro-RO/9.ogg new file mode 100644 index 0000000..1d156c7 Binary files /dev/null and b/main/assets/locales/ro-RO/9.ogg differ diff --git a/main/assets/locales/ro-RO/activation.ogg b/main/assets/locales/ro-RO/activation.ogg new file mode 100644 index 0000000..d2d91fb Binary files /dev/null and b/main/assets/locales/ro-RO/activation.ogg differ diff --git a/main/assets/locales/ro-RO/err_pin.ogg b/main/assets/locales/ro-RO/err_pin.ogg new file mode 100644 index 0000000..202000c Binary files /dev/null and b/main/assets/locales/ro-RO/err_pin.ogg differ diff --git a/main/assets/locales/ro-RO/err_reg.ogg b/main/assets/locales/ro-RO/err_reg.ogg new file mode 100644 index 0000000..eafcabc Binary files /dev/null and b/main/assets/locales/ro-RO/err_reg.ogg differ diff --git a/main/assets/locales/ro-RO/language.json b/main/assets/locales/ro-RO/language.json new file mode 100644 index 0000000..65ac52b --- /dev/null +++ b/main/assets/locales/ro-RO/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "ro-RO" + }, + "strings": { + "WARNING": "Avertisment", + "INFO": "Informație", + "ERROR": "Eroare", + "VERSION": "Versiune ", + "LOADING_PROTOCOL": "Se conectează la server...", + "INITIALIZING": "Se inițializează...", + "PIN_ERROR": "Vă rugăm să introduceți cardul SIM", + "REG_ERROR": "Nu se poate accesa rețeaua, verificați starea cardului de date", + "DETECTING_MODULE": "Se detectează modulul...", + "REGISTERING_NETWORK": "Se așteaptă rețeaua...", + "CHECKING_NEW_VERSION": "Se verifică versiunea nouă...", + "CHECK_NEW_VERSION_FAILED": "Verificarea versiunii noi a eșuat, se reîncearcă în %d secunde: %s", + "SWITCH_TO_WIFI_NETWORK": "Se comută la Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Se comută la 4G...", + "STANDBY": "În așteptare", + "CONNECT_TO": "Conectare la ", + "CONNECTING": "Se conectează...", + "CONNECTED_TO": "Conectat la ", + "LISTENING": "Se ascultă...", + "SPEAKING": "Se vorbește...", + "SERVER_NOT_FOUND": "Se caută serviciul disponibil", + "SERVER_NOT_CONNECTED": "Nu se poate conecta la serviciu, încercați mai târziu", + "SERVER_TIMEOUT": "Timpul de răspuns a expirat", + "SERVER_ERROR": "Trimiterea a eșuat, verificați rețeaua", + "CONNECT_TO_HOTSPOT": "Conectați telefonul la hotspot ", + "ACCESS_VIA_BROWSER": ",accesați prin browser ", + "WIFI_CONFIG_MODE": "Modul de configurare rețea", + "ENTERING_WIFI_CONFIG_MODE": "Se intră în modul de configurare rețea...", + "SCANNING_WIFI": "Se scanează Wi-Fi...", + "NEW_VERSION": "Versiune nouă ", + "OTA_UPGRADE": "Actualizare OTA", + "UPGRADING": "Se actualizează sistemul...", + "UPGRADE_FAILED": "Actualizarea a eșuat", + "ACTIVATION": "Activarea dispozitivului", + "BATTERY_LOW": "Baterie scăzută", + "BATTERY_CHARGING": "Se încarcă", + "BATTERY_FULL": "Baterie plină", + "BATTERY_NEED_CHARGE": "Baterie scăzută, vă rugăm să încărcați", + "VOLUME": "Volum ", + "MUTED": "Silențios", + "MAX_VOLUME": "Volum maxim", + "RTC_MODE_OFF": "AEC oprit", + "RTC_MODE_ON": "AEC pornit", + "DOWNLOAD_ASSETS_FAILED": "Eșec la descărcarea resurselor", + "LOADING_ASSETS": "Se încarcă resursele...", + "PLEASE_WAIT": "Vă rugăm să așteptați...", + "FOUND_NEW_ASSETS": "S-au găsit resurse noi: %s", + "HELLO_MY_FRIEND": "Salut, prietenul meu!", + "CONNECTION_SUCCESSFUL": "Conexiune reușită", + "FLIGHT_MODE_OFF": "Modul avion este dezactivat", + "FLIGHT_MODE_ON": "Modul avion este activat", + "MODEM_INIT_ERROR": "Inițializarea modemului a eșuat" + } +} \ No newline at end of file diff --git a/main/assets/locales/ro-RO/upgrade.ogg b/main/assets/locales/ro-RO/upgrade.ogg new file mode 100644 index 0000000..f4b5618 Binary files /dev/null and b/main/assets/locales/ro-RO/upgrade.ogg differ diff --git a/main/assets/locales/ro-RO/welcome.ogg b/main/assets/locales/ro-RO/welcome.ogg new file mode 100644 index 0000000..673f3d8 Binary files /dev/null and b/main/assets/locales/ro-RO/welcome.ogg differ diff --git a/main/assets/locales/ro-RO/wificonfig.ogg b/main/assets/locales/ro-RO/wificonfig.ogg new file mode 100644 index 0000000..d536cdc Binary files /dev/null and b/main/assets/locales/ro-RO/wificonfig.ogg differ diff --git a/main/assets/locales/ru-RU/0.ogg b/main/assets/locales/ru-RU/0.ogg new file mode 100644 index 0000000..8b3d47b Binary files /dev/null and b/main/assets/locales/ru-RU/0.ogg differ diff --git a/main/assets/locales/ru-RU/1.ogg b/main/assets/locales/ru-RU/1.ogg new file mode 100644 index 0000000..a9a3b23 Binary files /dev/null and b/main/assets/locales/ru-RU/1.ogg differ diff --git a/main/assets/locales/ru-RU/2.ogg b/main/assets/locales/ru-RU/2.ogg new file mode 100644 index 0000000..d2f6b4d Binary files /dev/null and b/main/assets/locales/ru-RU/2.ogg differ diff --git a/main/assets/locales/ru-RU/3.ogg b/main/assets/locales/ru-RU/3.ogg new file mode 100644 index 0000000..744ae4e Binary files /dev/null and b/main/assets/locales/ru-RU/3.ogg differ diff --git a/main/assets/locales/ru-RU/4.ogg b/main/assets/locales/ru-RU/4.ogg new file mode 100644 index 0000000..f82ee5d Binary files /dev/null and b/main/assets/locales/ru-RU/4.ogg differ diff --git a/main/assets/locales/ru-RU/5.ogg b/main/assets/locales/ru-RU/5.ogg new file mode 100644 index 0000000..9c976d7 Binary files /dev/null and b/main/assets/locales/ru-RU/5.ogg differ diff --git a/main/assets/locales/ru-RU/6.ogg b/main/assets/locales/ru-RU/6.ogg new file mode 100644 index 0000000..61d7192 Binary files /dev/null and b/main/assets/locales/ru-RU/6.ogg differ diff --git a/main/assets/locales/ru-RU/7.ogg b/main/assets/locales/ru-RU/7.ogg new file mode 100644 index 0000000..bbb3cf3 Binary files /dev/null and b/main/assets/locales/ru-RU/7.ogg differ diff --git a/main/assets/locales/ru-RU/8.ogg b/main/assets/locales/ru-RU/8.ogg new file mode 100644 index 0000000..f73c590 Binary files /dev/null and b/main/assets/locales/ru-RU/8.ogg differ diff --git a/main/assets/locales/ru-RU/9.ogg b/main/assets/locales/ru-RU/9.ogg new file mode 100644 index 0000000..3863b89 Binary files /dev/null and b/main/assets/locales/ru-RU/9.ogg differ diff --git a/main/assets/locales/ru-RU/activation.ogg b/main/assets/locales/ru-RU/activation.ogg new file mode 100644 index 0000000..c3ff8e4 Binary files /dev/null and b/main/assets/locales/ru-RU/activation.ogg differ diff --git a/main/assets/locales/ru-RU/err_pin.ogg b/main/assets/locales/ru-RU/err_pin.ogg new file mode 100644 index 0000000..1fea3f6 Binary files /dev/null and b/main/assets/locales/ru-RU/err_pin.ogg differ diff --git a/main/assets/locales/ru-RU/err_reg.ogg b/main/assets/locales/ru-RU/err_reg.ogg new file mode 100644 index 0000000..3d585f9 Binary files /dev/null and b/main/assets/locales/ru-RU/err_reg.ogg differ diff --git a/main/assets/locales/ru-RU/language.json b/main/assets/locales/ru-RU/language.json new file mode 100644 index 0000000..4fb278d --- /dev/null +++ b/main/assets/locales/ru-RU/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "ru-RU" + }, + "strings": { + "WARNING": "Предупреждение", + "INFO": "Информация", + "ERROR": "Ошибка", + "VERSION": "Версия ", + "LOADING_PROTOCOL": "Подключение к серверу...", + "INITIALIZING": "Инициализация...", + "PIN_ERROR": "Пожалуйста, вставьте SIM-карту", + "REG_ERROR": "Невозможно подключиться к сети, проверьте состояние карты данных", + "DETECTING_MODULE": "Обнаружение модуля...", + "REGISTERING_NETWORK": "Ожидание сети...", + "CHECKING_NEW_VERSION": "Проверка новой версии...", + "CHECK_NEW_VERSION_FAILED": "Ошибка проверки новой версии, повтор через %d секунд: %s", + "SWITCH_TO_WIFI_NETWORK": "Переключение на Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Переключение на 4G...", + "STANDBY": "Ожидание", + "CONNECT_TO": "Подключение к ", + "CONNECTING": "Подключение...", + "CONNECTED_TO": "Подключено к ", + "LISTENING": "Прослушивание...", + "SPEAKING": "Говорение...", + "SERVER_NOT_FOUND": "Поиск доступного сервиса", + "SERVER_NOT_CONNECTED": "Невозможно подключиться к сервису, попробуйте позже", + "SERVER_TIMEOUT": "Тайм-аут ответа", + "SERVER_ERROR": "Ошибка отправки, проверьте сеть", + "CONNECT_TO_HOTSPOT": "Подключите телефон к точке доступа ", + "ACCESS_VIA_BROWSER": ",доступ через браузер ", + "WIFI_CONFIG_MODE": "Режим настройки сети", + "ENTERING_WIFI_CONFIG_MODE": "Вход в режим настройки сети...", + "SCANNING_WIFI": "Сканирование Wi-Fi...", + "NEW_VERSION": "Новая версия ", + "OTA_UPGRADE": "Обновление OTA", + "UPGRADING": "Обновление системы...", + "UPGRADE_FAILED": "Обновление не удалось", + "ACTIVATION": "Активация устройства", + "BATTERY_LOW": "Низкий заряд батареи", + "BATTERY_CHARGING": "Зарядка", + "BATTERY_FULL": "Батарея полная", + "BATTERY_NEED_CHARGE": "Низкий заряд, пожалуйста, зарядите", + "VOLUME": "Громкость ", + "MUTED": "Звук отключен", + "MAX_VOLUME": "Максимальная громкость", + "RTC_MODE_OFF": "AEC выключен", + "RTC_MODE_ON": "AEC включен", + "DOWNLOAD_ASSETS_FAILED": "Не удалось загрузить ресурсы", + "LOADING_ASSETS": "Загрузка ресурсов...", + "PLEASE_WAIT": "Пожалуйста, подождите...", + "FOUND_NEW_ASSETS": "Найдены новые ресурсы: %s", + "HELLO_MY_FRIEND": "Привет, мой друг!", + "CONNECTION_SUCCESSFUL": "Подключение успешно", + "FLIGHT_MODE_OFF": "Режим полета выключен", + "FLIGHT_MODE_ON": "Режим полета включен", + "MODEM_INIT_ERROR": "Ошибка инициализации модема" + } +} \ No newline at end of file diff --git a/main/assets/locales/ru-RU/upgrade.ogg b/main/assets/locales/ru-RU/upgrade.ogg new file mode 100644 index 0000000..69fbbdc Binary files /dev/null and b/main/assets/locales/ru-RU/upgrade.ogg differ diff --git a/main/assets/locales/ru-RU/welcome.ogg b/main/assets/locales/ru-RU/welcome.ogg new file mode 100644 index 0000000..bb5f05c Binary files /dev/null and b/main/assets/locales/ru-RU/welcome.ogg differ diff --git a/main/assets/locales/ru-RU/wificonfig.ogg b/main/assets/locales/ru-RU/wificonfig.ogg new file mode 100644 index 0000000..0a26e2b Binary files /dev/null and b/main/assets/locales/ru-RU/wificonfig.ogg differ diff --git a/main/assets/locales/sk-SK/0.ogg b/main/assets/locales/sk-SK/0.ogg new file mode 100644 index 0000000..adef2c4 Binary files /dev/null and b/main/assets/locales/sk-SK/0.ogg differ diff --git a/main/assets/locales/sk-SK/1.ogg b/main/assets/locales/sk-SK/1.ogg new file mode 100644 index 0000000..8974741 Binary files /dev/null and b/main/assets/locales/sk-SK/1.ogg differ diff --git a/main/assets/locales/sk-SK/2.ogg b/main/assets/locales/sk-SK/2.ogg new file mode 100644 index 0000000..7336f93 Binary files /dev/null and b/main/assets/locales/sk-SK/2.ogg differ diff --git a/main/assets/locales/sk-SK/3.ogg b/main/assets/locales/sk-SK/3.ogg new file mode 100644 index 0000000..ce9635e Binary files /dev/null and b/main/assets/locales/sk-SK/3.ogg differ diff --git a/main/assets/locales/sk-SK/4.ogg b/main/assets/locales/sk-SK/4.ogg new file mode 100644 index 0000000..3f8026d Binary files /dev/null and b/main/assets/locales/sk-SK/4.ogg differ diff --git a/main/assets/locales/sk-SK/5.ogg b/main/assets/locales/sk-SK/5.ogg new file mode 100644 index 0000000..dd50dd3 Binary files /dev/null and b/main/assets/locales/sk-SK/5.ogg differ diff --git a/main/assets/locales/sk-SK/6.ogg b/main/assets/locales/sk-SK/6.ogg new file mode 100644 index 0000000..8ef182a Binary files /dev/null and b/main/assets/locales/sk-SK/6.ogg differ diff --git a/main/assets/locales/sk-SK/7.ogg b/main/assets/locales/sk-SK/7.ogg new file mode 100644 index 0000000..6c7f4d5 Binary files /dev/null and b/main/assets/locales/sk-SK/7.ogg differ diff --git a/main/assets/locales/sk-SK/8.ogg b/main/assets/locales/sk-SK/8.ogg new file mode 100644 index 0000000..4533f50 Binary files /dev/null and b/main/assets/locales/sk-SK/8.ogg differ diff --git a/main/assets/locales/sk-SK/9.ogg b/main/assets/locales/sk-SK/9.ogg new file mode 100644 index 0000000..045d8f6 Binary files /dev/null and b/main/assets/locales/sk-SK/9.ogg differ diff --git a/main/assets/locales/sk-SK/activation.ogg b/main/assets/locales/sk-SK/activation.ogg new file mode 100644 index 0000000..4c780fa Binary files /dev/null and b/main/assets/locales/sk-SK/activation.ogg differ diff --git a/main/assets/locales/sk-SK/err_pin.ogg b/main/assets/locales/sk-SK/err_pin.ogg new file mode 100644 index 0000000..ee2dd69 Binary files /dev/null and b/main/assets/locales/sk-SK/err_pin.ogg differ diff --git a/main/assets/locales/sk-SK/err_reg.ogg b/main/assets/locales/sk-SK/err_reg.ogg new file mode 100644 index 0000000..0c9237f Binary files /dev/null and b/main/assets/locales/sk-SK/err_reg.ogg differ diff --git a/main/assets/locales/sk-SK/language.json b/main/assets/locales/sk-SK/language.json new file mode 100644 index 0000000..e7d029c --- /dev/null +++ b/main/assets/locales/sk-SK/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "sk-SK" + }, + "strings": { + "WARNING": "Upozornenie", + "INFO": "Informácie", + "ERROR": "Chyba", + "VERSION": "Verzia ", + "LOADING_PROTOCOL": "Prihlasovanie...", + "INITIALIZING": "Inicializácia...", + "PIN_ERROR": "Vložte SIM kartu", + "REG_ERROR": "Nie je možné pristúpiť k sieti, skontrolujte stav SIM karty", + "DETECTING_MODULE": "Zisťovanie modulu...", + "REGISTERING_NETWORK": "Čakanie na sieť...", + "CHECKING_NEW_VERSION": "Kontrola novej verzie...", + "CHECK_NEW_VERSION_FAILED": "Kontrola novej verzie zlyhala, opakuje sa o %d sekúnd: %s", + "SWITCH_TO_WIFI_NETWORK": "Prepínanie na Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Prepínanie na 4G...", + "STANDBY": "Pohotovostný režim", + "CONNECT_TO": "Pripojenie k ", + "CONNECTING": "Pripája sa...", + "CONNECTION_SUCCESSFUL": "Pripojenie úspešné", + "CONNECTED_TO": "Pripojené k ", + "LISTENING": "Načúvanie...", + "SPEAKING": "Hovorenie...", + "SERVER_NOT_FOUND": "Hľadanie dostupnej služby", + "SERVER_NOT_CONNECTED": "Nie je možné pripojiť sa k službe, skúste to neskôr", + "SERVER_TIMEOUT": "Časový limit čakania na odpoveď vypršal", + "SERVER_ERROR": "Odosielanie zlyhalo, skontrolujte sieť", + "CONNECT_TO_HOTSPOT": "Hotspot: ", + "ACCESS_VIA_BROWSER": " Konfiguračná URL: ", + "WIFI_CONFIG_MODE": "Režim konfigurácie Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "Vstup do režimu konfigurácie Wi-Fi...", + "SCANNING_WIFI": "Skenovanie Wi-Fi...", + "NEW_VERSION": "Nová verzia ", + "OTA_UPGRADE": "OTA aktualizácia", + "UPGRADING": "Systém sa aktualizuje...", + "UPGRADE_FAILED": "Aktualizácia zlyhala", + "ACTIVATION": "Aktivácia", + "BATTERY_LOW": "Slabá batéria", + "BATTERY_CHARGING": "Nabíjanie", + "BATTERY_FULL": "Batéria plná", + "BATTERY_NEED_CHARGE": "Slabá batéria, nabite ju", + "VOLUME": "Hlasitosť ", + "MUTED": "Stíšené", + "MAX_VOLUME": "Maximálna hlasitosť", + "RTC_MODE_OFF": "AEC vypnuté", + "RTC_MODE_ON": "AEC zapnuté", + "PLEASE_WAIT": "Počkajte prosím...", + "FOUND_NEW_ASSETS": "Nájdené nové zdroje: %s", + "DOWNLOAD_ASSETS_FAILED": "Sťahovanie zdrojov zlyhalo", + "LOADING_ASSETS": "Načítavanie zdrojov...", + "HELLO_MY_FRIEND": "Ahoj, môj priateľ!", + "FLIGHT_MODE_OFF": "Letecký režim je vypnutý", + "FLIGHT_MODE_ON": "Letecký režim je zapnutý", + "MODEM_INIT_ERROR": "Chyba inicializácie modemu" + } +} \ No newline at end of file diff --git a/main/assets/locales/sk-SK/upgrade.ogg b/main/assets/locales/sk-SK/upgrade.ogg new file mode 100644 index 0000000..ce12dbf Binary files /dev/null and b/main/assets/locales/sk-SK/upgrade.ogg differ diff --git a/main/assets/locales/sk-SK/welcome.ogg b/main/assets/locales/sk-SK/welcome.ogg new file mode 100644 index 0000000..05b8cad Binary files /dev/null and b/main/assets/locales/sk-SK/welcome.ogg differ diff --git a/main/assets/locales/sk-SK/wificonfig.ogg b/main/assets/locales/sk-SK/wificonfig.ogg new file mode 100644 index 0000000..89906c6 Binary files /dev/null and b/main/assets/locales/sk-SK/wificonfig.ogg differ diff --git a/main/assets/locales/sl-SI/0.ogg b/main/assets/locales/sl-SI/0.ogg new file mode 100644 index 0000000..48baa9a Binary files /dev/null and b/main/assets/locales/sl-SI/0.ogg differ diff --git a/main/assets/locales/sl-SI/1.ogg b/main/assets/locales/sl-SI/1.ogg new file mode 100644 index 0000000..fcd0f70 Binary files /dev/null and b/main/assets/locales/sl-SI/1.ogg differ diff --git a/main/assets/locales/sl-SI/2.ogg b/main/assets/locales/sl-SI/2.ogg new file mode 100644 index 0000000..d5c6360 Binary files /dev/null and b/main/assets/locales/sl-SI/2.ogg differ diff --git a/main/assets/locales/sl-SI/3.ogg b/main/assets/locales/sl-SI/3.ogg new file mode 100644 index 0000000..fa0774a Binary files /dev/null and b/main/assets/locales/sl-SI/3.ogg differ diff --git a/main/assets/locales/sl-SI/4.ogg b/main/assets/locales/sl-SI/4.ogg new file mode 100644 index 0000000..95fa91f Binary files /dev/null and b/main/assets/locales/sl-SI/4.ogg differ diff --git a/main/assets/locales/sl-SI/5.ogg b/main/assets/locales/sl-SI/5.ogg new file mode 100644 index 0000000..d06e1de Binary files /dev/null and b/main/assets/locales/sl-SI/5.ogg differ diff --git a/main/assets/locales/sl-SI/6.ogg b/main/assets/locales/sl-SI/6.ogg new file mode 100644 index 0000000..d3de0e1 Binary files /dev/null and b/main/assets/locales/sl-SI/6.ogg differ diff --git a/main/assets/locales/sl-SI/7.ogg b/main/assets/locales/sl-SI/7.ogg new file mode 100644 index 0000000..36b701f Binary files /dev/null and b/main/assets/locales/sl-SI/7.ogg differ diff --git a/main/assets/locales/sl-SI/8.ogg b/main/assets/locales/sl-SI/8.ogg new file mode 100644 index 0000000..8dbdc07 Binary files /dev/null and b/main/assets/locales/sl-SI/8.ogg differ diff --git a/main/assets/locales/sl-SI/9.ogg b/main/assets/locales/sl-SI/9.ogg new file mode 100644 index 0000000..944f922 Binary files /dev/null and b/main/assets/locales/sl-SI/9.ogg differ diff --git a/main/assets/locales/sl-SI/activation.ogg b/main/assets/locales/sl-SI/activation.ogg new file mode 100644 index 0000000..73d10f3 Binary files /dev/null and b/main/assets/locales/sl-SI/activation.ogg differ diff --git a/main/assets/locales/sl-SI/err_pin.ogg b/main/assets/locales/sl-SI/err_pin.ogg new file mode 100644 index 0000000..dc692f0 Binary files /dev/null and b/main/assets/locales/sl-SI/err_pin.ogg differ diff --git a/main/assets/locales/sl-SI/err_reg.ogg b/main/assets/locales/sl-SI/err_reg.ogg new file mode 100644 index 0000000..e0ca35a Binary files /dev/null and b/main/assets/locales/sl-SI/err_reg.ogg differ diff --git a/main/assets/locales/sl-SI/language.json b/main/assets/locales/sl-SI/language.json new file mode 100644 index 0000000..8e5a45b --- /dev/null +++ b/main/assets/locales/sl-SI/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "sl-SI" + }, + "strings": { + "WARNING": "Opozorilo", + "INFO": "Informacije", + "ERROR": "Napaka", + "VERSION": "Različica ", + "LOADING_PROTOCOL": "Prijava...", + "INITIALIZING": "Inicializacija...", + "PIN_ERROR": "Vstavite SIM kartico", + "REG_ERROR": "Ni mogoče dostopati do omrežja, preverite stanje SIM kartice", + "DETECTING_MODULE": "Zaznavanje modula...", + "REGISTERING_NETWORK": "Čakanje na omrežje...", + "CHECKING_NEW_VERSION": "Preverjanje nove različice...", + "CHECK_NEW_VERSION_FAILED": "Preverjanje nove različice ni uspelo, ponovni poskus čez %d sekund: %s", + "SWITCH_TO_WIFI_NETWORK": "Preklop na Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Preklop na 4G...", + "STANDBY": "Pripravljenost", + "CONNECT_TO": "Poveži se z ", + "CONNECTING": "Povezovanje...", + "CONNECTION_SUCCESSFUL": "Povezava uspešna", + "CONNECTED_TO": "Povezano z ", + "LISTENING": "Poslušanje...", + "SPEAKING": "Govorjenje...", + "SERVER_NOT_FOUND": "Iskanje razpoložljive storitve", + "SERVER_NOT_CONNECTED": "Ni se mogoče povezati s storitvijo, poskusite kasneje", + "SERVER_TIMEOUT": "Časovna omejitev čakanja na odgovor", + "SERVER_ERROR": "Pošiljanje ni uspelo, preverite omrežje", + "CONNECT_TO_HOTSPOT": "Dostopna točka: ", + "ACCESS_VIA_BROWSER": " Konfiguracijski URL: ", + "WIFI_CONFIG_MODE": "Način konfiguracije Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "Vstop v način konfiguracije Wi-Fi...", + "SCANNING_WIFI": "Skeniranje Wi-Fi...", + "NEW_VERSION": "Nova različica ", + "OTA_UPGRADE": "OTA nadgradnja", + "UPGRADING": "Sistem se nadgrajuje...", + "UPGRADE_FAILED": "Nadgradnja ni uspela", + "ACTIVATION": "Aktivacija", + "BATTERY_LOW": "Nizka baterija", + "BATTERY_CHARGING": "Polnjenje", + "BATTERY_FULL": "Baterija polna", + "BATTERY_NEED_CHARGE": "Nizka baterija, napolnite", + "VOLUME": "Glasnost ", + "MUTED": "Utišano", + "MAX_VOLUME": "Največja glasnost", + "RTC_MODE_OFF": "AEC izklopljen", + "RTC_MODE_ON": "AEC vklopljen", + "PLEASE_WAIT": "Počakajte...", + "FOUND_NEW_ASSETS": "Najdeni novi viri: %s", + "DOWNLOAD_ASSETS_FAILED": "Prenos virov ni uspel", + "LOADING_ASSETS": "Nalaganje virov...", + "HELLO_MY_FRIEND": "Pozdravljeni, moj prijatelj!", + "FLIGHT_MODE_OFF": "Način leta je izklopljen", + "FLIGHT_MODE_ON": "Način leta je vklopljen", + "MODEM_INIT_ERROR": "Inicializacija modema ni uspela" + } +} \ No newline at end of file diff --git a/main/assets/locales/sl-SI/upgrade.ogg b/main/assets/locales/sl-SI/upgrade.ogg new file mode 100644 index 0000000..9c307df Binary files /dev/null and b/main/assets/locales/sl-SI/upgrade.ogg differ diff --git a/main/assets/locales/sl-SI/welcome.ogg b/main/assets/locales/sl-SI/welcome.ogg new file mode 100644 index 0000000..bab8476 Binary files /dev/null and b/main/assets/locales/sl-SI/welcome.ogg differ diff --git a/main/assets/locales/sl-SI/wificonfig.ogg b/main/assets/locales/sl-SI/wificonfig.ogg new file mode 100644 index 0000000..e29f077 Binary files /dev/null and b/main/assets/locales/sl-SI/wificonfig.ogg differ diff --git a/main/assets/locales/sr-RS/language.json b/main/assets/locales/sr-RS/language.json new file mode 100644 index 0000000..172d3fe --- /dev/null +++ b/main/assets/locales/sr-RS/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "sr-RS" + }, + "strings": { + "WARNING": "Упозорење", + "INFO": "Информације", + "ERROR": "Грешка", + "VERSION": "Верзија ", + "LOADING_PROTOCOL": "Пријављивање...", + "INITIALIZING": "Иницијализација...", + "PIN_ERROR": "Молимо убаците SIM картицу", + "REG_ERROR": "Није могуће приступити мрежи, проверите статус SIM картице", + "DETECTING_MODULE": "Откривање модула...", + "REGISTERING_NETWORK": "Чекање мреже...", + "CHECKING_NEW_VERSION": "Провера нове верзије...", + "CHECK_NEW_VERSION_FAILED": "Провера нове верзије није успела, покушаће се поново за %d секунди: %s", + "SWITCH_TO_WIFI_NETWORK": "Пребацивање на Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Пребацивање на 4G...", + "STANDBY": "Спремност", + "CONNECT_TO": "Повежи се са ", + "CONNECTING": "Повезивање...", + "CONNECTION_SUCCESSFUL": "Успешно повезивање", + "CONNECTED_TO": "Повезано са ", + "LISTENING": "Слушање...", + "SPEAKING": "Говорење...", + "SERVER_NOT_FOUND": "Тражење доступне услуге", + "SERVER_NOT_CONNECTED": "Није могуће повезати се са услугом, покушајте касније", + "SERVER_TIMEOUT": "Истекло време чекања одговора", + "SERVER_ERROR": "Слање није успело, проверите мрежу", + "CONNECT_TO_HOTSPOT": "Приступна тачка: ", + "ACCESS_VIA_BROWSER": " URL за конфигурацију: ", + "WIFI_CONFIG_MODE": "Режим конфигурације Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "Улазак у режим конфигурације Wi-Fi...", + "SCANNING_WIFI": "Скенирање Wi-Fi...", + "NEW_VERSION": "Нова верзија ", + "OTA_UPGRADE": "OTA надоградња", + "UPGRADING": "Систем се надограђује...", + "UPGRADE_FAILED": "Надоградња није успела", + "ACTIVATION": "Активација", + "BATTERY_LOW": "Слаба батерија", + "BATTERY_CHARGING": "Пуњење", + "BATTERY_FULL": "Батерија пуна", + "BATTERY_NEED_CHARGE": "Слаба батерија, молимо напуните", + "VOLUME": "Јачина звука ", + "MUTED": "Утишано", + "MAX_VOLUME": "Максимална јачина звука", + "RTC_MODE_OFF": "AEC искључен", + "RTC_MODE_ON": "AEC укључен", + "PLEASE_WAIT": "Молимо сачекајте...", + "FOUND_NEW_ASSETS": "Пронађени нови ресурси: %s", + "DOWNLOAD_ASSETS_FAILED": "Преузимање ресурса није успело", + "LOADING_ASSETS": "Учитавање ресурса...", + "HELLO_MY_FRIEND": "Здраво, пријатељу!", + "FLIGHT_MODE_OFF": "Режим лета је искључен", + "FLIGHT_MODE_ON": "Режим лета је укључен", + "MODEM_INIT_ERROR": "Иницијализација модема није успела" + } +} \ No newline at end of file diff --git a/main/assets/locales/sv-SE/0.ogg b/main/assets/locales/sv-SE/0.ogg new file mode 100644 index 0000000..af94e5b Binary files /dev/null and b/main/assets/locales/sv-SE/0.ogg differ diff --git a/main/assets/locales/sv-SE/1.ogg b/main/assets/locales/sv-SE/1.ogg new file mode 100644 index 0000000..3246d37 Binary files /dev/null and b/main/assets/locales/sv-SE/1.ogg differ diff --git a/main/assets/locales/sv-SE/2.ogg b/main/assets/locales/sv-SE/2.ogg new file mode 100644 index 0000000..db2a251 Binary files /dev/null and b/main/assets/locales/sv-SE/2.ogg differ diff --git a/main/assets/locales/sv-SE/3.ogg b/main/assets/locales/sv-SE/3.ogg new file mode 100644 index 0000000..5bfb237 Binary files /dev/null and b/main/assets/locales/sv-SE/3.ogg differ diff --git a/main/assets/locales/sv-SE/4.ogg b/main/assets/locales/sv-SE/4.ogg new file mode 100644 index 0000000..4ebccba Binary files /dev/null and b/main/assets/locales/sv-SE/4.ogg differ diff --git a/main/assets/locales/sv-SE/5.ogg b/main/assets/locales/sv-SE/5.ogg new file mode 100644 index 0000000..d45e216 Binary files /dev/null and b/main/assets/locales/sv-SE/5.ogg differ diff --git a/main/assets/locales/sv-SE/6.ogg b/main/assets/locales/sv-SE/6.ogg new file mode 100644 index 0000000..3da60f5 Binary files /dev/null and b/main/assets/locales/sv-SE/6.ogg differ diff --git a/main/assets/locales/sv-SE/7.ogg b/main/assets/locales/sv-SE/7.ogg new file mode 100644 index 0000000..85ddfab Binary files /dev/null and b/main/assets/locales/sv-SE/7.ogg differ diff --git a/main/assets/locales/sv-SE/8.ogg b/main/assets/locales/sv-SE/8.ogg new file mode 100644 index 0000000..318735f Binary files /dev/null and b/main/assets/locales/sv-SE/8.ogg differ diff --git a/main/assets/locales/sv-SE/9.ogg b/main/assets/locales/sv-SE/9.ogg new file mode 100644 index 0000000..0f60d53 Binary files /dev/null and b/main/assets/locales/sv-SE/9.ogg differ diff --git a/main/assets/locales/sv-SE/activation.ogg b/main/assets/locales/sv-SE/activation.ogg new file mode 100644 index 0000000..08a507d Binary files /dev/null and b/main/assets/locales/sv-SE/activation.ogg differ diff --git a/main/assets/locales/sv-SE/err_pin.ogg b/main/assets/locales/sv-SE/err_pin.ogg new file mode 100644 index 0000000..c3772c9 Binary files /dev/null and b/main/assets/locales/sv-SE/err_pin.ogg differ diff --git a/main/assets/locales/sv-SE/err_reg.ogg b/main/assets/locales/sv-SE/err_reg.ogg new file mode 100644 index 0000000..07fc197 Binary files /dev/null and b/main/assets/locales/sv-SE/err_reg.ogg differ diff --git a/main/assets/locales/sv-SE/language.json b/main/assets/locales/sv-SE/language.json new file mode 100644 index 0000000..d85fe5b --- /dev/null +++ b/main/assets/locales/sv-SE/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "sv-SE" + }, + "strings": { + "WARNING": "Varning", + "INFO": "Information", + "ERROR": "Fel", + "VERSION": "Version ", + "LOADING_PROTOCOL": "Loggar in...", + "INITIALIZING": "Initierar...", + "PIN_ERROR": "Vänligen sätt in SIM-kort", + "REG_ERROR": "Kan inte komma åt nätverket, kontrollera SIM-kortets status", + "DETECTING_MODULE": "Detekterar modul...", + "REGISTERING_NETWORK": "Väntar på nätverk...", + "CHECKING_NEW_VERSION": "Kontrollerar ny version...", + "CHECK_NEW_VERSION_FAILED": "Kontroll av ny version misslyckades, försöker igen om %d sekunder: %s", + "SWITCH_TO_WIFI_NETWORK": "Byter till Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Byter till 4G...", + "STANDBY": "Standby", + "CONNECT_TO": "Anslut till ", + "CONNECTING": "Ansluter...", + "CONNECTION_SUCCESSFUL": "Anslutningen lyckades", + "CONNECTED_TO": "Ansluten till ", + "LISTENING": "Lyssnar...", + "SPEAKING": "Talar...", + "SERVER_NOT_FOUND": "Söker efter tillgänglig tjänst", + "SERVER_NOT_CONNECTED": "Kan inte ansluta till tjänsten, försök igen senare", + "SERVER_TIMEOUT": "Timeout vid väntan på svar", + "SERVER_ERROR": "Skickandet misslyckades, kontrollera nätverket", + "CONNECT_TO_HOTSPOT": "Hotspot: ", + "ACCESS_VIA_BROWSER": " Konfigurations-URL: ", + "WIFI_CONFIG_MODE": "Wi-Fi-konfigurationsläge", + "ENTERING_WIFI_CONFIG_MODE": "Går in i Wi-Fi-konfigurationsläge...", + "SCANNING_WIFI": "Skannar Wi-Fi...", + "NEW_VERSION": "Ny version ", + "OTA_UPGRADE": "OTA-uppgradering", + "UPGRADING": "Systemet uppgraderas...", + "UPGRADE_FAILED": "Uppgraderingen misslyckades", + "ACTIVATION": "Aktivering", + "BATTERY_LOW": "Lågt batteri", + "BATTERY_CHARGING": "Laddar", + "BATTERY_FULL": "Batteriet fullt", + "BATTERY_NEED_CHARGE": "Lågt batteri, vänligen ladda", + "VOLUME": "Volym ", + "MUTED": "Tystad", + "MAX_VOLUME": "Maximal volym", + "RTC_MODE_OFF": "AEC av", + "RTC_MODE_ON": "AEC på", + "PLEASE_WAIT": "Vänligen vänta...", + "FOUND_NEW_ASSETS": "Hittade nya resurser: %s", + "DOWNLOAD_ASSETS_FAILED": "Nedladdning av resurser misslyckades", + "LOADING_ASSETS": "Laddar resurser...", + "HELLO_MY_FRIEND": "Hej, min vän!", + "FLIGHT_MODE_OFF": "Flygläge är av", + "FLIGHT_MODE_ON": "Flygläge är på", + "MODEM_INIT_ERROR": "Modeminitiering misslyckades" + } +} \ No newline at end of file diff --git a/main/assets/locales/sv-SE/upgrade.ogg b/main/assets/locales/sv-SE/upgrade.ogg new file mode 100644 index 0000000..db4d02d Binary files /dev/null and b/main/assets/locales/sv-SE/upgrade.ogg differ diff --git a/main/assets/locales/sv-SE/welcome.ogg b/main/assets/locales/sv-SE/welcome.ogg new file mode 100644 index 0000000..9407896 Binary files /dev/null and b/main/assets/locales/sv-SE/welcome.ogg differ diff --git a/main/assets/locales/sv-SE/wificonfig.ogg b/main/assets/locales/sv-SE/wificonfig.ogg new file mode 100644 index 0000000..f232108 Binary files /dev/null and b/main/assets/locales/sv-SE/wificonfig.ogg differ diff --git a/main/assets/locales/th-TH/0.ogg b/main/assets/locales/th-TH/0.ogg new file mode 100644 index 0000000..15e2544 Binary files /dev/null and b/main/assets/locales/th-TH/0.ogg differ diff --git a/main/assets/locales/th-TH/1.ogg b/main/assets/locales/th-TH/1.ogg new file mode 100644 index 0000000..87753b3 Binary files /dev/null and b/main/assets/locales/th-TH/1.ogg differ diff --git a/main/assets/locales/th-TH/2.ogg b/main/assets/locales/th-TH/2.ogg new file mode 100644 index 0000000..057a6f3 Binary files /dev/null and b/main/assets/locales/th-TH/2.ogg differ diff --git a/main/assets/locales/th-TH/3.ogg b/main/assets/locales/th-TH/3.ogg new file mode 100644 index 0000000..104d51b Binary files /dev/null and b/main/assets/locales/th-TH/3.ogg differ diff --git a/main/assets/locales/th-TH/4.ogg b/main/assets/locales/th-TH/4.ogg new file mode 100644 index 0000000..a35befc Binary files /dev/null and b/main/assets/locales/th-TH/4.ogg differ diff --git a/main/assets/locales/th-TH/5.ogg b/main/assets/locales/th-TH/5.ogg new file mode 100644 index 0000000..8eb3b5b Binary files /dev/null and b/main/assets/locales/th-TH/5.ogg differ diff --git a/main/assets/locales/th-TH/6.ogg b/main/assets/locales/th-TH/6.ogg new file mode 100644 index 0000000..b8cbbe0 Binary files /dev/null and b/main/assets/locales/th-TH/6.ogg differ diff --git a/main/assets/locales/th-TH/7.ogg b/main/assets/locales/th-TH/7.ogg new file mode 100644 index 0000000..e93c9a4 Binary files /dev/null and b/main/assets/locales/th-TH/7.ogg differ diff --git a/main/assets/locales/th-TH/8.ogg b/main/assets/locales/th-TH/8.ogg new file mode 100644 index 0000000..becde0e Binary files /dev/null and b/main/assets/locales/th-TH/8.ogg differ diff --git a/main/assets/locales/th-TH/9.ogg b/main/assets/locales/th-TH/9.ogg new file mode 100644 index 0000000..319ef67 Binary files /dev/null and b/main/assets/locales/th-TH/9.ogg differ diff --git a/main/assets/locales/th-TH/activation.ogg b/main/assets/locales/th-TH/activation.ogg new file mode 100644 index 0000000..983e4d8 Binary files /dev/null and b/main/assets/locales/th-TH/activation.ogg differ diff --git a/main/assets/locales/th-TH/err_pin.ogg b/main/assets/locales/th-TH/err_pin.ogg new file mode 100644 index 0000000..059cc8f Binary files /dev/null and b/main/assets/locales/th-TH/err_pin.ogg differ diff --git a/main/assets/locales/th-TH/err_reg.ogg b/main/assets/locales/th-TH/err_reg.ogg new file mode 100644 index 0000000..042c47f Binary files /dev/null and b/main/assets/locales/th-TH/err_reg.ogg differ diff --git a/main/assets/locales/th-TH/language.json b/main/assets/locales/th-TH/language.json new file mode 100644 index 0000000..6c05468 --- /dev/null +++ b/main/assets/locales/th-TH/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "th-TH" + }, + "strings": { + "WARNING": "คำเตือน", + "INFO": "ข้อมูล", + "ERROR": "ข้อผิดพลาด", + "VERSION": "เวอร์ชัน ", + "LOADING_PROTOCOL": "กำลังเข้าสู่ระบบ...", + "INITIALIZING": "กำลังเริ่มต้นระบบ...", + "PIN_ERROR": "กรุณาใส่ซิมการ์ด", + "REG_ERROR": "ไม่สามารถเข้าถึงเครือข่ายได้ กรุณาตรวจสอบสถานะซิมการ์ด", + "DETECTING_MODULE": "กำลังตรวจจับโมดูล...", + "REGISTERING_NETWORK": "กำลังรอเครือข่าย...", + "CHECKING_NEW_VERSION": "กำลังตรวจสอบเวอร์ชันใหม่...", + "CHECK_NEW_VERSION_FAILED": "การตรวจสอบเวอร์ชันใหม่ล้มเหลว จะลองใหม่ใน %d วินาที: %s", + "SWITCH_TO_WIFI_NETWORK": "กำลังเปลี่ยนเป็น Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "กำลังเปลี่ยนเป็น 4G...", + "STANDBY": "พร้อม", + "CONNECT_TO": "เชื่อมต่อกับ ", + "CONNECTING": "กำลังเชื่อมต่อ...", + "CONNECTION_SUCCESSFUL": "เชื่อมต่อสำเร็จ", + "CONNECTED_TO": "เชื่อมต่อกับ ", + "LISTENING": "กำลังฟัง...", + "SPEAKING": "กำลังพูด...", + "SERVER_NOT_FOUND": "กำลังค้นหาบริการที่ใช้งานได้", + "SERVER_NOT_CONNECTED": "ไม่สามารถเชื่อมต่อกับบริการได้ กรุณาลองใหม่ในภายหลัง", + "SERVER_TIMEOUT": "หมดเวลารอการตอบกลับ", + "SERVER_ERROR": "การส่งข้อมูลล้มเหลว กรุณาตรวจสอบเครือข่าย", + "CONNECT_TO_HOTSPOT": "ฮอตสปอต: ", + "ACCESS_VIA_BROWSER": " URL การตั้งค่า: ", + "WIFI_CONFIG_MODE": "โหมดการตั้งค่า Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "กำลังเข้าสู่โหมดการตั้งค่า Wi-Fi...", + "SCANNING_WIFI": "กำลังสแกน Wi-Fi...", + "NEW_VERSION": "เวอร์ชันใหม่ ", + "OTA_UPGRADE": "การอัปเกรด OTA", + "UPGRADING": "ระบบกำลังอัปเกรด...", + "UPGRADE_FAILED": "การอัปเกรดล้มเหลว", + "ACTIVATION": "การเปิดใช้งาน", + "BATTERY_LOW": "แบตเตอรี่ต่ำ", + "BATTERY_CHARGING": "กำลังชาร์จ", + "BATTERY_FULL": "แบตเตอรี่เต็ม", + "BATTERY_NEED_CHARGE": "แบตเตอรี่ต่ำ กรุณาชาร์จ", + "VOLUME": "เสียง ", + "MUTED": "ปิดเสียง", + "MAX_VOLUME": "เสียงสูงสุด", + "RTC_MODE_OFF": "ปิด AEC", + "RTC_MODE_ON": "เปิด AEC", + "DOWNLOAD_ASSETS_FAILED": "ดาวน์โหลดทรัพยากรล้มเหลว", + "LOADING_ASSETS": "กำลังโหลดทรัพยากร...", + "PLEASE_WAIT": "กรุณารอสักครู่...", + "FOUND_NEW_ASSETS": "พบทรัพยากรใหม่: %s", + "HELLO_MY_FRIEND": "สวัสดี เพื่อนของฉัน!", + "FLIGHT_MODE_OFF": "โหมดเครื่องบินปิดอยู่", + "FLIGHT_MODE_ON": "โหมดเครื่องบินเปิดอยู่", + "MODEM_INIT_ERROR": "การเริ่มต้นโมเด็มล้มเหลว" + } +} \ No newline at end of file diff --git a/main/assets/locales/th-TH/upgrade.ogg b/main/assets/locales/th-TH/upgrade.ogg new file mode 100644 index 0000000..e024638 Binary files /dev/null and b/main/assets/locales/th-TH/upgrade.ogg differ diff --git a/main/assets/locales/th-TH/welcome.ogg b/main/assets/locales/th-TH/welcome.ogg new file mode 100644 index 0000000..0b32b80 Binary files /dev/null and b/main/assets/locales/th-TH/welcome.ogg differ diff --git a/main/assets/locales/th-TH/wificonfig.ogg b/main/assets/locales/th-TH/wificonfig.ogg new file mode 100644 index 0000000..984d217 Binary files /dev/null and b/main/assets/locales/th-TH/wificonfig.ogg differ diff --git a/main/assets/locales/tr-TR/0.ogg b/main/assets/locales/tr-TR/0.ogg new file mode 100644 index 0000000..9ab9285 Binary files /dev/null and b/main/assets/locales/tr-TR/0.ogg differ diff --git a/main/assets/locales/tr-TR/1.ogg b/main/assets/locales/tr-TR/1.ogg new file mode 100644 index 0000000..131d774 Binary files /dev/null and b/main/assets/locales/tr-TR/1.ogg differ diff --git a/main/assets/locales/tr-TR/2.ogg b/main/assets/locales/tr-TR/2.ogg new file mode 100644 index 0000000..5e80f6e Binary files /dev/null and b/main/assets/locales/tr-TR/2.ogg differ diff --git a/main/assets/locales/tr-TR/3.ogg b/main/assets/locales/tr-TR/3.ogg new file mode 100644 index 0000000..bdc0550 Binary files /dev/null and b/main/assets/locales/tr-TR/3.ogg differ diff --git a/main/assets/locales/tr-TR/4.ogg b/main/assets/locales/tr-TR/4.ogg new file mode 100644 index 0000000..fd39d2d Binary files /dev/null and b/main/assets/locales/tr-TR/4.ogg differ diff --git a/main/assets/locales/tr-TR/5.ogg b/main/assets/locales/tr-TR/5.ogg new file mode 100644 index 0000000..30d093e Binary files /dev/null and b/main/assets/locales/tr-TR/5.ogg differ diff --git a/main/assets/locales/tr-TR/6.ogg b/main/assets/locales/tr-TR/6.ogg new file mode 100644 index 0000000..95a6f6d Binary files /dev/null and b/main/assets/locales/tr-TR/6.ogg differ diff --git a/main/assets/locales/tr-TR/7.ogg b/main/assets/locales/tr-TR/7.ogg new file mode 100644 index 0000000..9ba5ebc Binary files /dev/null and b/main/assets/locales/tr-TR/7.ogg differ diff --git a/main/assets/locales/tr-TR/8.ogg b/main/assets/locales/tr-TR/8.ogg new file mode 100644 index 0000000..9a4d8c8 Binary files /dev/null and b/main/assets/locales/tr-TR/8.ogg differ diff --git a/main/assets/locales/tr-TR/9.ogg b/main/assets/locales/tr-TR/9.ogg new file mode 100644 index 0000000..af30078 Binary files /dev/null and b/main/assets/locales/tr-TR/9.ogg differ diff --git a/main/assets/locales/tr-TR/activation.ogg b/main/assets/locales/tr-TR/activation.ogg new file mode 100644 index 0000000..f8bf34e Binary files /dev/null and b/main/assets/locales/tr-TR/activation.ogg differ diff --git a/main/assets/locales/tr-TR/err_pin.ogg b/main/assets/locales/tr-TR/err_pin.ogg new file mode 100644 index 0000000..6e5831e Binary files /dev/null and b/main/assets/locales/tr-TR/err_pin.ogg differ diff --git a/main/assets/locales/tr-TR/err_reg.ogg b/main/assets/locales/tr-TR/err_reg.ogg new file mode 100644 index 0000000..91fc7ff Binary files /dev/null and b/main/assets/locales/tr-TR/err_reg.ogg differ diff --git a/main/assets/locales/tr-TR/language.json b/main/assets/locales/tr-TR/language.json new file mode 100644 index 0000000..dacfa76 --- /dev/null +++ b/main/assets/locales/tr-TR/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "tr-TR" + }, + "strings": { + "WARNING": "Uyarı", + "INFO": "Bilgi", + "ERROR": "Hata", + "VERSION": "Sürüm ", + "LOADING_PROTOCOL": "Sunucuya bağlanıyor...", + "INITIALIZING": "Başlatılıyor...", + "PIN_ERROR": "Lütfen SIM kartı takın", + "REG_ERROR": "Ağa erişilemiyor, veri kartı durumunu kontrol edin", + "DETECTING_MODULE": "Modül algılanıyor...", + "REGISTERING_NETWORK": "Ağ bekleniyor...", + "CHECKING_NEW_VERSION": "Yeni sürüm kontrol ediliyor...", + "CHECK_NEW_VERSION_FAILED": "Yeni sürüm kontrolü başarısız, %d saniye sonra tekrar denenecek: %s", + "SWITCH_TO_WIFI_NETWORK": "Wi-Fi'ye geçiliyor...", + "SWITCH_TO_4G_NETWORK": "4G'ye geçiliyor...", + "STANDBY": "Bekleme", + "CONNECT_TO": "Bağlan ", + "CONNECTING": "Bağlanıyor...", + "CONNECTED_TO": "Bağlandı ", + "LISTENING": "Dinleniyor...", + "SPEAKING": "Konuşuluyor...", + "SERVER_NOT_FOUND": "Mevcut hizmet aranıyor", + "SERVER_NOT_CONNECTED": "Hizmete bağlanılamıyor, lütfen daha sonra deneyin", + "SERVER_TIMEOUT": "Yanıt zaman aşımı", + "SERVER_ERROR": "Gönderme başarısız, ağı kontrol edin", + "CONNECT_TO_HOTSPOT": "Telefonu hotspot'a bağlayın ", + "ACCESS_VIA_BROWSER": ",tarayıcı üzerinden erişin ", + "WIFI_CONFIG_MODE": "Ağ yapılandırma modu", + "ENTERING_WIFI_CONFIG_MODE": "Ağ yapılandırma moduna giriliyor...", + "SCANNING_WIFI": "Wi-Fi taranıyor...", + "NEW_VERSION": "Yeni sürüm ", + "OTA_UPGRADE": "OTA güncelleme", + "UPGRADING": "Sistem güncelleniyor...", + "UPGRADE_FAILED": "Güncelleme başarısız", + "ACTIVATION": "Cihaz aktivasyonu", + "BATTERY_LOW": "Pil düşük", + "BATTERY_CHARGING": "Şarj oluyor", + "BATTERY_FULL": "Pil dolu", + "BATTERY_NEED_CHARGE": "Pil düşük, lütfen şarj edin", + "VOLUME": "Ses ", + "MUTED": "Sessiz", + "MAX_VOLUME": "Maksimum ses", + "RTC_MODE_OFF": "AEC kapalı", + "RTC_MODE_ON": "AEC açık", + "DOWNLOAD_ASSETS_FAILED": "Varlıklar indirilemedi", + "LOADING_ASSETS": "Varlıklar yükleniyor...", + "PLEASE_WAIT": "Lütfen bekleyin...", + "FOUND_NEW_ASSETS": "Yeni varlıklar bulundu: %s", + "HELLO_MY_FRIEND": "Merhaba, arkadaşım!", + "CONNECTION_SUCCESSFUL": "Bağlantı başarılı", + "FLIGHT_MODE_OFF": "Uçak modu kapalı", + "FLIGHT_MODE_ON": "Uçak modu açık", + "MODEM_INIT_ERROR": "Modem başlatma hatası" + } +} \ No newline at end of file diff --git a/main/assets/locales/tr-TR/upgrade.ogg b/main/assets/locales/tr-TR/upgrade.ogg new file mode 100644 index 0000000..57215bd Binary files /dev/null and b/main/assets/locales/tr-TR/upgrade.ogg differ diff --git a/main/assets/locales/tr-TR/welcome.ogg b/main/assets/locales/tr-TR/welcome.ogg new file mode 100644 index 0000000..fedd21c Binary files /dev/null and b/main/assets/locales/tr-TR/welcome.ogg differ diff --git a/main/assets/locales/tr-TR/wificonfig.ogg b/main/assets/locales/tr-TR/wificonfig.ogg new file mode 100644 index 0000000..c898adc Binary files /dev/null and b/main/assets/locales/tr-TR/wificonfig.ogg differ diff --git a/main/assets/locales/uk-UA/0.ogg b/main/assets/locales/uk-UA/0.ogg new file mode 100644 index 0000000..f4ea62c Binary files /dev/null and b/main/assets/locales/uk-UA/0.ogg differ diff --git a/main/assets/locales/uk-UA/1.ogg b/main/assets/locales/uk-UA/1.ogg new file mode 100644 index 0000000..94bfed5 Binary files /dev/null and b/main/assets/locales/uk-UA/1.ogg differ diff --git a/main/assets/locales/uk-UA/2.ogg b/main/assets/locales/uk-UA/2.ogg new file mode 100644 index 0000000..e6226d5 Binary files /dev/null and b/main/assets/locales/uk-UA/2.ogg differ diff --git a/main/assets/locales/uk-UA/3.ogg b/main/assets/locales/uk-UA/3.ogg new file mode 100644 index 0000000..50f7faa Binary files /dev/null and b/main/assets/locales/uk-UA/3.ogg differ diff --git a/main/assets/locales/uk-UA/4.ogg b/main/assets/locales/uk-UA/4.ogg new file mode 100644 index 0000000..d3f9d7f Binary files /dev/null and b/main/assets/locales/uk-UA/4.ogg differ diff --git a/main/assets/locales/uk-UA/5.ogg b/main/assets/locales/uk-UA/5.ogg new file mode 100644 index 0000000..712b0a0 Binary files /dev/null and b/main/assets/locales/uk-UA/5.ogg differ diff --git a/main/assets/locales/uk-UA/6.ogg b/main/assets/locales/uk-UA/6.ogg new file mode 100644 index 0000000..7a01d5f Binary files /dev/null and b/main/assets/locales/uk-UA/6.ogg differ diff --git a/main/assets/locales/uk-UA/7.ogg b/main/assets/locales/uk-UA/7.ogg new file mode 100644 index 0000000..4f3d5b2 Binary files /dev/null and b/main/assets/locales/uk-UA/7.ogg differ diff --git a/main/assets/locales/uk-UA/8.ogg b/main/assets/locales/uk-UA/8.ogg new file mode 100644 index 0000000..9577bc7 Binary files /dev/null and b/main/assets/locales/uk-UA/8.ogg differ diff --git a/main/assets/locales/uk-UA/9.ogg b/main/assets/locales/uk-UA/9.ogg new file mode 100644 index 0000000..dbaf9d6 Binary files /dev/null and b/main/assets/locales/uk-UA/9.ogg differ diff --git a/main/assets/locales/uk-UA/activation.ogg b/main/assets/locales/uk-UA/activation.ogg new file mode 100644 index 0000000..446e020 Binary files /dev/null and b/main/assets/locales/uk-UA/activation.ogg differ diff --git a/main/assets/locales/uk-UA/err_pin.ogg b/main/assets/locales/uk-UA/err_pin.ogg new file mode 100644 index 0000000..dd7d402 Binary files /dev/null and b/main/assets/locales/uk-UA/err_pin.ogg differ diff --git a/main/assets/locales/uk-UA/err_reg.ogg b/main/assets/locales/uk-UA/err_reg.ogg new file mode 100644 index 0000000..ffe1a4c Binary files /dev/null and b/main/assets/locales/uk-UA/err_reg.ogg differ diff --git a/main/assets/locales/uk-UA/language.json b/main/assets/locales/uk-UA/language.json new file mode 100644 index 0000000..3f07c2a --- /dev/null +++ b/main/assets/locales/uk-UA/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "uk-UA" + }, + "strings": { + "WARNING": "Попередження", + "INFO": "Інформація", + "ERROR": "Помилка", + "VERSION": "Версія ", + "LOADING_PROTOCOL": "Підключення до сервера...", + "INITIALIZING": "Ініціалізація...", + "PIN_ERROR": "Будь ласка, вставте SIM-карту", + "REG_ERROR": "Неможливо отримати доступ до мережі, перевірте стан карти даних", + "DETECTING_MODULE": "Виявлення модуля...", + "REGISTERING_NETWORK": "Очікування мережі...", + "CHECKING_NEW_VERSION": "Перевірка нової версії...", + "CHECK_NEW_VERSION_FAILED": "Перевірка нової версії не вдалася, повтор через %d секунд: %s", + "SWITCH_TO_WIFI_NETWORK": "Перемикання на Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Перемикання на 4G...", + "STANDBY": "Очікування", + "CONNECT_TO": "Підключитися до ", + "CONNECTING": "Підключення...", + "CONNECTED_TO": "Підключено до ", + "LISTENING": "Прослуховування...", + "SPEAKING": "Говоріння...", + "SERVER_NOT_FOUND": "Пошук доступного сервісу", + "SERVER_NOT_CONNECTED": "Неможливо підключитися до сервісу, спробуйте пізніше", + "SERVER_TIMEOUT": "Час очікування відповіді", + "SERVER_ERROR": "Помилка відправки, перевірте мережу", + "CONNECT_TO_HOTSPOT": "Підключіть телефон до точки доступу ", + "ACCESS_VIA_BROWSER": ",доступ через браузер ", + "WIFI_CONFIG_MODE": "Режим налаштування мережі", + "ENTERING_WIFI_CONFIG_MODE": "Вхід у режим налаштування мережі...", + "SCANNING_WIFI": "Сканування Wi-Fi...", + "NEW_VERSION": "Нова версія ", + "OTA_UPGRADE": "Оновлення OTA", + "UPGRADING": "Оновлення системи...", + "UPGRADE_FAILED": "Оновлення не вдалося", + "ACTIVATION": "Активація пристрою", + "BATTERY_LOW": "Низький заряд батареї", + "BATTERY_CHARGING": "Зарядка", + "BATTERY_FULL": "Батарея повна", + "BATTERY_NEED_CHARGE": "Низький заряд, будь ласка, зарядіть", + "VOLUME": "Гучність ", + "MUTED": "Звук вимкнено", + "MAX_VOLUME": "Максимальна гучність", + "RTC_MODE_OFF": "AEC вимкнено", + "RTC_MODE_ON": "AEC увімкнено", + "DOWNLOAD_ASSETS_FAILED": "Не вдалося завантажити ресурси", + "LOADING_ASSETS": "Завантаження ресурсів...", + "PLEASE_WAIT": "Будь ласка, зачекайте...", + "FOUND_NEW_ASSETS": "Знайдено нові ресурси: %s", + "HELLO_MY_FRIEND": "Привіт, мій друже!", + "CONNECTION_SUCCESSFUL": "Підключення успішне", + "FLIGHT_MODE_OFF": "Режим польоту вимкнено", + "FLIGHT_MODE_ON": "Режим польоту увімкнено", + "MODEM_INIT_ERROR": "Помилка ініціалізації модему" + } +} \ No newline at end of file diff --git a/main/assets/locales/uk-UA/upgrade.ogg b/main/assets/locales/uk-UA/upgrade.ogg new file mode 100644 index 0000000..5a41be8 Binary files /dev/null and b/main/assets/locales/uk-UA/upgrade.ogg differ diff --git a/main/assets/locales/uk-UA/welcome.ogg b/main/assets/locales/uk-UA/welcome.ogg new file mode 100644 index 0000000..e04ca00 Binary files /dev/null and b/main/assets/locales/uk-UA/welcome.ogg differ diff --git a/main/assets/locales/uk-UA/wificonfig.ogg b/main/assets/locales/uk-UA/wificonfig.ogg new file mode 100644 index 0000000..c1d5d90 Binary files /dev/null and b/main/assets/locales/uk-UA/wificonfig.ogg differ diff --git a/main/assets/locales/vi-VN/0.ogg b/main/assets/locales/vi-VN/0.ogg new file mode 100644 index 0000000..c6c0418 Binary files /dev/null and b/main/assets/locales/vi-VN/0.ogg differ diff --git a/main/assets/locales/vi-VN/1.ogg b/main/assets/locales/vi-VN/1.ogg new file mode 100644 index 0000000..4774b06 Binary files /dev/null and b/main/assets/locales/vi-VN/1.ogg differ diff --git a/main/assets/locales/vi-VN/2.ogg b/main/assets/locales/vi-VN/2.ogg new file mode 100644 index 0000000..592e9a4 Binary files /dev/null and b/main/assets/locales/vi-VN/2.ogg differ diff --git a/main/assets/locales/vi-VN/3.ogg b/main/assets/locales/vi-VN/3.ogg new file mode 100644 index 0000000..6825973 Binary files /dev/null and b/main/assets/locales/vi-VN/3.ogg differ diff --git a/main/assets/locales/vi-VN/4.ogg b/main/assets/locales/vi-VN/4.ogg new file mode 100644 index 0000000..983e48a Binary files /dev/null and b/main/assets/locales/vi-VN/4.ogg differ diff --git a/main/assets/locales/vi-VN/5.ogg b/main/assets/locales/vi-VN/5.ogg new file mode 100644 index 0000000..fd950a0 Binary files /dev/null and b/main/assets/locales/vi-VN/5.ogg differ diff --git a/main/assets/locales/vi-VN/6.ogg b/main/assets/locales/vi-VN/6.ogg new file mode 100644 index 0000000..8158cfc Binary files /dev/null and b/main/assets/locales/vi-VN/6.ogg differ diff --git a/main/assets/locales/vi-VN/7.ogg b/main/assets/locales/vi-VN/7.ogg new file mode 100644 index 0000000..ca03b3e Binary files /dev/null and b/main/assets/locales/vi-VN/7.ogg differ diff --git a/main/assets/locales/vi-VN/8.ogg b/main/assets/locales/vi-VN/8.ogg new file mode 100644 index 0000000..d359674 Binary files /dev/null and b/main/assets/locales/vi-VN/8.ogg differ diff --git a/main/assets/locales/vi-VN/9.ogg b/main/assets/locales/vi-VN/9.ogg new file mode 100644 index 0000000..6c6dd0b Binary files /dev/null and b/main/assets/locales/vi-VN/9.ogg differ diff --git a/main/assets/locales/vi-VN/activation.ogg b/main/assets/locales/vi-VN/activation.ogg new file mode 100644 index 0000000..a3871b8 Binary files /dev/null and b/main/assets/locales/vi-VN/activation.ogg differ diff --git a/main/assets/locales/vi-VN/err_pin.ogg b/main/assets/locales/vi-VN/err_pin.ogg new file mode 100644 index 0000000..16e42a0 Binary files /dev/null and b/main/assets/locales/vi-VN/err_pin.ogg differ diff --git a/main/assets/locales/vi-VN/err_reg.ogg b/main/assets/locales/vi-VN/err_reg.ogg new file mode 100644 index 0000000..00b6bab Binary files /dev/null and b/main/assets/locales/vi-VN/err_reg.ogg differ diff --git a/main/assets/locales/vi-VN/language.json b/main/assets/locales/vi-VN/language.json new file mode 100644 index 0000000..ece31d3 --- /dev/null +++ b/main/assets/locales/vi-VN/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "vi-VN" + }, + "strings": { + "WARNING": "Cảnh báo", + "INFO": "Thông tin", + "ERROR": "Lỗi", + "VERSION": "Phiên bản ", + "LOADING_PROTOCOL": "Đang đăng nhập...", + "INITIALIZING": "Đang khởi tạo...", + "PIN_ERROR": "Vui lòng cắm thẻ SIM", + "REG_ERROR": "Không thể truy cập mạng, vui lòng kiểm tra trạng thái thẻ SIM", + "DETECTING_MODULE": "Đang phát hiện module...", + "REGISTERING_NETWORK": "Đang chờ mạng...", + "CHECKING_NEW_VERSION": "Đang kiểm tra phiên bản mới...", + "CHECK_NEW_VERSION_FAILED": "Kiểm tra phiên bản mới thất bại, sẽ thử lại sau %d giây: %s", + "SWITCH_TO_WIFI_NETWORK": "Đang chuyển sang Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "Đang chuyển sang 4G...", + "STANDBY": "Chờ", + "CONNECT_TO": "Kết nối đến ", + "CONNECTING": "Đang kết nối...", + "CONNECTION_SUCCESSFUL": "Kết nối thành công", + "CONNECTED_TO": "Đã kết nối đến ", + "LISTENING": "Đang lắng nghe...", + "SPEAKING": "Đang nói...", + "SERVER_NOT_FOUND": "Đang tìm dịch vụ khả dụng", + "SERVER_NOT_CONNECTED": "Không thể kết nối đến dịch vụ, vui lòng thử lại sau", + "SERVER_TIMEOUT": "Hết thời gian chờ phản hồi", + "SERVER_ERROR": "Gửi thất bại, vui lòng kiểm tra mạng", + "CONNECT_TO_HOTSPOT": "Điểm phát sóng: ", + "ACCESS_VIA_BROWSER": " URL cấu hình: ", + "WIFI_CONFIG_MODE": "Chế độ cấu hình Wi-Fi", + "ENTERING_WIFI_CONFIG_MODE": "Đang vào chế độ cấu hình Wi-Fi...", + "SCANNING_WIFI": "Đang quét Wi-Fi...", + "NEW_VERSION": "Phiên bản mới ", + "OTA_UPGRADE": "Nâng cấp OTA", + "UPGRADING": "Hệ thống đang nâng cấp...", + "UPGRADE_FAILED": "Nâng cấp thất bại", + "ACTIVATION": "Kích hoạt", + "BATTERY_LOW": "Pin yếu", + "BATTERY_CHARGING": "Đang sạc", + "BATTERY_FULL": "Pin đầy", + "BATTERY_NEED_CHARGE": "Pin yếu, vui lòng sạc", + "VOLUME": "Âm lượng ", + "MUTED": "Tắt tiếng", + "MAX_VOLUME": "Âm lượng tối đa", + "RTC_MODE_OFF": "Tắt AEC", + "RTC_MODE_ON": "Bật AEC", + "DOWNLOAD_ASSETS_FAILED": "Tải xuống tài nguyên thất bại", + "LOADING_ASSETS": "Đang tải tài nguyên...", + "PLEASE_WAIT": "Vui lòng đợi...", + "FOUND_NEW_ASSETS": "Tìm thấy tài nguyên mới: %s", + "HELLO_MY_FRIEND": "Xin chào, bạn của tôi!", + "FLIGHT_MODE_OFF": "Chế độ máy bay đang tắt", + "FLIGHT_MODE_ON": "Chế độ máy bay đang bật", + "MODEM_INIT_ERROR": "Khởi tạo modem thất bại" + } +} \ No newline at end of file diff --git a/main/assets/locales/vi-VN/upgrade.ogg b/main/assets/locales/vi-VN/upgrade.ogg new file mode 100644 index 0000000..1d41e98 Binary files /dev/null and b/main/assets/locales/vi-VN/upgrade.ogg differ diff --git a/main/assets/locales/vi-VN/welcome.ogg b/main/assets/locales/vi-VN/welcome.ogg new file mode 100644 index 0000000..ae12cc3 Binary files /dev/null and b/main/assets/locales/vi-VN/welcome.ogg differ diff --git a/main/assets/locales/vi-VN/wificonfig.ogg b/main/assets/locales/vi-VN/wificonfig.ogg new file mode 100644 index 0000000..08c3cb5 Binary files /dev/null and b/main/assets/locales/vi-VN/wificonfig.ogg differ diff --git a/main/assets/locales/zh-CN/0.ogg b/main/assets/locales/zh-CN/0.ogg new file mode 100644 index 0000000..0cb1247 Binary files /dev/null and b/main/assets/locales/zh-CN/0.ogg differ diff --git a/main/assets/locales/zh-CN/1.ogg b/main/assets/locales/zh-CN/1.ogg new file mode 100644 index 0000000..858af34 Binary files /dev/null and b/main/assets/locales/zh-CN/1.ogg differ diff --git a/main/assets/locales/zh-CN/2.ogg b/main/assets/locales/zh-CN/2.ogg new file mode 100644 index 0000000..72f53aa Binary files /dev/null and b/main/assets/locales/zh-CN/2.ogg differ diff --git a/main/assets/locales/zh-CN/3.ogg b/main/assets/locales/zh-CN/3.ogg new file mode 100644 index 0000000..848af11 Binary files /dev/null and b/main/assets/locales/zh-CN/3.ogg differ diff --git a/main/assets/locales/zh-CN/4.ogg b/main/assets/locales/zh-CN/4.ogg new file mode 100644 index 0000000..39b3eee Binary files /dev/null and b/main/assets/locales/zh-CN/4.ogg differ diff --git a/main/assets/locales/zh-CN/5.ogg b/main/assets/locales/zh-CN/5.ogg new file mode 100644 index 0000000..9230358 Binary files /dev/null and b/main/assets/locales/zh-CN/5.ogg differ diff --git a/main/assets/locales/zh-CN/6.ogg b/main/assets/locales/zh-CN/6.ogg new file mode 100644 index 0000000..9ecb574 Binary files /dev/null and b/main/assets/locales/zh-CN/6.ogg differ diff --git a/main/assets/locales/zh-CN/7.ogg b/main/assets/locales/zh-CN/7.ogg new file mode 100644 index 0000000..6348799 Binary files /dev/null and b/main/assets/locales/zh-CN/7.ogg differ diff --git a/main/assets/locales/zh-CN/8.ogg b/main/assets/locales/zh-CN/8.ogg new file mode 100644 index 0000000..67fc7a9 Binary files /dev/null and b/main/assets/locales/zh-CN/8.ogg differ diff --git a/main/assets/locales/zh-CN/9.ogg b/main/assets/locales/zh-CN/9.ogg new file mode 100644 index 0000000..a7769d9 Binary files /dev/null and b/main/assets/locales/zh-CN/9.ogg differ diff --git a/main/assets/locales/zh-CN/activation.ogg b/main/assets/locales/zh-CN/activation.ogg new file mode 100644 index 0000000..33291af Binary files /dev/null and b/main/assets/locales/zh-CN/activation.ogg differ diff --git a/main/assets/locales/zh-CN/err_pin.ogg b/main/assets/locales/zh-CN/err_pin.ogg new file mode 100644 index 0000000..e455244 Binary files /dev/null and b/main/assets/locales/zh-CN/err_pin.ogg differ diff --git a/main/assets/locales/zh-CN/err_reg.ogg b/main/assets/locales/zh-CN/err_reg.ogg new file mode 100644 index 0000000..a3456f1 Binary files /dev/null and b/main/assets/locales/zh-CN/err_reg.ogg differ diff --git a/main/assets/locales/zh-CN/language.json b/main/assets/locales/zh-CN/language.json new file mode 100644 index 0000000..a21f51a --- /dev/null +++ b/main/assets/locales/zh-CN/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "zh-CN" + }, + "strings": { + "WARNING": "警告", + "INFO": "信息", + "ERROR": "错误", + "VERSION": "版本 ", + "LOADING_PROTOCOL": "登录服务器...", + "INITIALIZING": "正在初始化...", + "PIN_ERROR": "请插入 SIM 卡", + "REG_ERROR": "无法接入网络,请检查流量卡状态", + "MODEM_INIT_ERROR": "模组初始化失败", + "DETECTING_MODULE": "检测模组...", + "REGISTERING_NETWORK": "等待网络...", + "CHECKING_NEW_VERSION": "检查新版本...", + "CHECK_NEW_VERSION_FAILED": "检查新版本失败,将在 %d 秒后重试:%s", + "SWITCH_TO_WIFI_NETWORK": "切换到 Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "切换到 4G...", + "STANDBY": "待命", + "CONNECT_TO": "连接 ", + "CONNECTING": "连接中...", + "CONNECTED_TO": "已连接 ", + "LISTENING": "聆听中...", + "SPEAKING": "说话中...", + "SERVER_NOT_FOUND": "正在寻找可用服务", + "SERVER_NOT_CONNECTED": "无法连接服务,请稍后再试", + "SERVER_TIMEOUT": "等待响应超时", + "SERVER_ERROR": "发送失败,请检查网络", + "CONNECT_TO_HOTSPOT": "手机连接热点 ", + "ACCESS_VIA_BROWSER": ",浏览器访问 ", + "WIFI_CONFIG_MODE": "配网模式", + "ENTERING_WIFI_CONFIG_MODE": "进入配网模式...", + "SCANNING_WIFI": "扫描 Wi-Fi...", + "NEW_VERSION": "新版本 ", + "OTA_UPGRADE": "OTA 升级", + "UPGRADING": "正在升级系统...", + "UPGRADE_FAILED": "升级失败", + "ACTIVATION": "激活设备", + "BATTERY_LOW": "电量不足", + "BATTERY_CHARGING": "正在充电", + "BATTERY_FULL": "电量已满", + "BATTERY_NEED_CHARGE": "电量低,请充电", + "VOLUME": "音量 ", + "MUTED": "已静音", + "MAX_VOLUME": "最大音量", + "RTC_MODE_OFF": "AEC 关闭", + "RTC_MODE_ON": "AEC 开启", + "DOWNLOAD_ASSETS_FAILED": "下载资源失败", + "LOADING_ASSETS": "加载资源...", + "PLEASE_WAIT": "请稍候...", + "FOUND_NEW_ASSETS": "发现新资源: %s", + "HELLO_MY_FRIEND": "你好,我的朋友!", + "CONNECTION_SUCCESSFUL": "连接成功", + "FLIGHT_MODE_OFF": "飞行模式已关闭", + "FLIGHT_MODE_ON": "飞行模式已开启" + } +} \ No newline at end of file diff --git a/main/assets/locales/zh-CN/upgrade.ogg b/main/assets/locales/zh-CN/upgrade.ogg new file mode 100644 index 0000000..1feb87b Binary files /dev/null and b/main/assets/locales/zh-CN/upgrade.ogg differ diff --git a/main/assets/locales/zh-CN/welcome.ogg b/main/assets/locales/zh-CN/welcome.ogg new file mode 100644 index 0000000..b2eeb4f Binary files /dev/null and b/main/assets/locales/zh-CN/welcome.ogg differ diff --git a/main/assets/locales/zh-CN/wificonfig.ogg b/main/assets/locales/zh-CN/wificonfig.ogg new file mode 100644 index 0000000..7ef88e2 Binary files /dev/null and b/main/assets/locales/zh-CN/wificonfig.ogg differ diff --git a/main/assets/locales/zh-TW/0.ogg b/main/assets/locales/zh-TW/0.ogg new file mode 100644 index 0000000..0cb1247 Binary files /dev/null and b/main/assets/locales/zh-TW/0.ogg differ diff --git a/main/assets/locales/zh-TW/1.ogg b/main/assets/locales/zh-TW/1.ogg new file mode 100644 index 0000000..858af34 Binary files /dev/null and b/main/assets/locales/zh-TW/1.ogg differ diff --git a/main/assets/locales/zh-TW/2.ogg b/main/assets/locales/zh-TW/2.ogg new file mode 100644 index 0000000..72f53aa Binary files /dev/null and b/main/assets/locales/zh-TW/2.ogg differ diff --git a/main/assets/locales/zh-TW/3.ogg b/main/assets/locales/zh-TW/3.ogg new file mode 100644 index 0000000..848af11 Binary files /dev/null and b/main/assets/locales/zh-TW/3.ogg differ diff --git a/main/assets/locales/zh-TW/4.ogg b/main/assets/locales/zh-TW/4.ogg new file mode 100644 index 0000000..39b3eee Binary files /dev/null and b/main/assets/locales/zh-TW/4.ogg differ diff --git a/main/assets/locales/zh-TW/5.ogg b/main/assets/locales/zh-TW/5.ogg new file mode 100644 index 0000000..9230358 Binary files /dev/null and b/main/assets/locales/zh-TW/5.ogg differ diff --git a/main/assets/locales/zh-TW/6.ogg b/main/assets/locales/zh-TW/6.ogg new file mode 100644 index 0000000..9ecb574 Binary files /dev/null and b/main/assets/locales/zh-TW/6.ogg differ diff --git a/main/assets/locales/zh-TW/7.ogg b/main/assets/locales/zh-TW/7.ogg new file mode 100644 index 0000000..6348799 Binary files /dev/null and b/main/assets/locales/zh-TW/7.ogg differ diff --git a/main/assets/locales/zh-TW/8.ogg b/main/assets/locales/zh-TW/8.ogg new file mode 100644 index 0000000..67fc7a9 Binary files /dev/null and b/main/assets/locales/zh-TW/8.ogg differ diff --git a/main/assets/locales/zh-TW/9.ogg b/main/assets/locales/zh-TW/9.ogg new file mode 100644 index 0000000..a7769d9 Binary files /dev/null and b/main/assets/locales/zh-TW/9.ogg differ diff --git a/main/assets/locales/zh-TW/activation.ogg b/main/assets/locales/zh-TW/activation.ogg new file mode 100644 index 0000000..33291af Binary files /dev/null and b/main/assets/locales/zh-TW/activation.ogg differ diff --git a/main/assets/locales/zh-TW/err_pin.ogg b/main/assets/locales/zh-TW/err_pin.ogg new file mode 100644 index 0000000..e455244 Binary files /dev/null and b/main/assets/locales/zh-TW/err_pin.ogg differ diff --git a/main/assets/locales/zh-TW/err_reg.ogg b/main/assets/locales/zh-TW/err_reg.ogg new file mode 100644 index 0000000..a3456f1 Binary files /dev/null and b/main/assets/locales/zh-TW/err_reg.ogg differ diff --git a/main/assets/locales/zh-TW/language.json b/main/assets/locales/zh-TW/language.json new file mode 100644 index 0000000..bbabcd9 --- /dev/null +++ b/main/assets/locales/zh-TW/language.json @@ -0,0 +1,59 @@ +{ + "language": { + "type": "zh-TW" + }, + "strings": { + "WARNING": "警告", + "INFO": "資訊", + "ERROR": "錯誤", + "VERSION": "版本 ", + "LOADING_PROTOCOL": "登入伺服器...", + "INITIALIZING": "正在初始化...", + "PIN_ERROR": "請插入 SIM 卡", + "REG_ERROR": "無法接入網絡,請檢查網路狀態", + "DETECTING_MODULE": "檢測模組...", + "REGISTERING_NETWORK": "等待網絡...", + "CHECKING_NEW_VERSION": "檢查新版本...", + "CHECK_NEW_VERSION_FAILED": "檢查新版本失敗,將在 %d 秒後重試:%s", + "SWITCH_TO_WIFI_NETWORK": "切換到 Wi-Fi...", + "SWITCH_TO_4G_NETWORK": "切換到 4G...", + "STANDBY": "待命", + "CONNECT_TO": "連接 ", + "CONNECTING": "連接中...", + "CONNECTED_TO": "已連接 ", + "LISTENING": "聆聽中...", + "SPEAKING": "說話中...", + "SERVER_NOT_FOUND": "正在尋找可用服務", + "SERVER_NOT_CONNECTED": "無法連接服務,請稍後再試", + "SERVER_TIMEOUT": "等待響應超時", + "SERVER_ERROR": "發送失敗,請檢查網絡", + "CONNECT_TO_HOTSPOT": "手機連接WiFi ", + "ACCESS_VIA_BROWSER": ",瀏覽器訪問 ", + "WIFI_CONFIG_MODE": "網路設定模式", + "ENTERING_WIFI_CONFIG_MODE": "正在設定網路...", + "SCANNING_WIFI": "掃描 Wi-Fi...", + "NEW_VERSION": "新版本 ", + "OTA_UPGRADE": "OTA 升級", + "UPGRADING": "正在升級系統...", + "UPGRADE_FAILED": "升級失敗", + "ACTIVATION": "啟用設備", + "BATTERY_LOW": "電量不足", + "BATTERY_CHARGING": "正在充電", + "BATTERY_FULL": "電量已滿", + "BATTERY_NEED_CHARGE": "電量低,請充電", + "VOLUME": "音量 ", + "MUTED": "已靜音", + "MAX_VOLUME": "最大音量", + "RTC_MODE_OFF": "AEC 關閉", + "RTC_MODE_ON": "AEC 開啟", + "DOWNLOAD_ASSETS_FAILED": "下載資源失敗", + "LOADING_ASSETS": "載入資源...", + "PLEASE_WAIT": "請稍候...", + "FOUND_NEW_ASSETS": "發現新資源: %s", + "HELLO_MY_FRIEND": "你好,我的朋友!", + "CONNECTION_SUCCESSFUL": "連線成功", + "FLIGHT_MODE_OFF": "飛航模式已關閉", + "FLIGHT_MODE_ON": "飛航模式已開啟", + "MODEM_INIT_ERROR": "模組初始化失敗" + } +} \ No newline at end of file diff --git a/main/assets/locales/zh-TW/upgrade.ogg b/main/assets/locales/zh-TW/upgrade.ogg new file mode 100644 index 0000000..1feb87b Binary files /dev/null and b/main/assets/locales/zh-TW/upgrade.ogg differ diff --git a/main/assets/locales/zh-TW/welcome.ogg b/main/assets/locales/zh-TW/welcome.ogg new file mode 100644 index 0000000..b2eeb4f Binary files /dev/null and b/main/assets/locales/zh-TW/welcome.ogg differ diff --git a/main/assets/locales/zh-TW/wificonfig.ogg b/main/assets/locales/zh-TW/wificonfig.ogg new file mode 100644 index 0000000..7ef88e2 Binary files /dev/null and b/main/assets/locales/zh-TW/wificonfig.ogg differ diff --git a/main/audio/README.md b/main/audio/README.md new file mode 100644 index 0000000..6159f9a --- /dev/null +++ b/main/audio/README.md @@ -0,0 +1,88 @@ +# Audio Service Architecture + +The audio service is a core component responsible for managing all audio-related functionalities, including capturing audio from the microphone, processing it, encoding/decoding, and playing back audio through the speaker. It is designed to be modular and efficient, running its main operations in dedicated FreeRTOS tasks to ensure real-time performance. + +## Key Components + +- **`AudioService`**: The central orchestrator. It initializes and manages all other audio components, tasks, and data queues. +- **`AudioCodec`**: A hardware abstraction layer (HAL) for the physical audio codec chip. It handles the raw I2S communication for audio input and output. +- **`AudioProcessor`**: Performs real-time audio processing on the microphone input stream. This typically includes Acoustic Echo Cancellation (AEC), noise suppression, and Voice Activity Detection (VAD). `AfeAudioProcessor` is the default implementation, utilizing the ESP-ADF Audio Front-End. +- **`WakeWord`**: Detects keywords (e.g., "你好,小智", "Hi, ESP") from the audio stream. It runs independently from the main audio processor until a wake word is detected. +- **`OpusEncoderWrapper` / `OpusDecoderWrapper`**: Manages the encoding of PCM audio to the Opus format and decoding Opus packets back to PCM. Opus is used for its high compression and low latency, making it ideal for voice streaming. +- **`OpusResampler`**: A utility to convert audio streams between different sample rates (e.g., resampling from the codec's native sample rate to the required 16kHz for processing). + +## Threading Model + +The service operates on three primary tasks to handle the different stages of the audio pipeline concurrently: + +1. **`AudioInputTask`**: Solely responsible for reading raw PCM data from the `AudioCodec`. It then feeds this data to either the `WakeWord` engine or the `AudioProcessor` based on the current state. +2. **`AudioOutputTask`**: Responsible for playing audio. It retrieves decoded PCM data from the `audio_playback_queue_` and sends it to the `AudioCodec` to be played on the speaker. +3. **`OpusCodecTask`**: A worker task that handles both encoding and decoding. It fetches raw audio from `audio_encode_queue_`, encodes it into Opus packets, and places them in the `audio_send_queue_`. Concurrently, it fetches Opus packets from `audio_decode_queue_`, decodes them into PCM, and places the result in the `audio_playback_queue_`. + +## Data Flow + +There are two primary data flows: audio input (uplink) and audio output (downlink). + +### 1. Audio Input (Uplink) Flow + +This flow captures audio from the microphone, processes it, encodes it, and prepares it for sending to a server. + +```mermaid +graph TD + subgraph Device + Mic[("Microphone")] -->|I2S| Codec(AudioCodec) + + subgraph AudioInputTask + Codec -->|Raw PCM| Read(ReadAudioData) + Read -->|16kHz PCM| Processor(AudioProcessor) + end + + subgraph OpusCodecTask + Processor -->|Clean PCM| EncodeQueue(audio_encode_queue_) + EncodeQueue --> Encoder(OpusEncoder) + Encoder -->|Opus Packet| SendQueue(audio_send_queue_) + end + + SendQueue --> |"PopPacketFromSendQueue()"| App(Application Layer) + end + + App -->|Network| Server((Cloud Server)) +``` + +- The `AudioInputTask` continuously reads raw PCM data from the `AudioCodec`. +- This data is fed into an `AudioProcessor` for cleaning (AEC, VAD). +- The processed PCM data is pushed into the `audio_encode_queue_`. +- The `OpusCodecTask` picks up the PCM data, encodes it into Opus format, and pushes the resulting packet to the `audio_send_queue_`. +- The application can then retrieve these Opus packets and send them over the network. + +### 2. Audio Output (Downlink) Flow + +This flow receives encoded audio data, decodes it, and plays it on the speaker. + +```mermaid +graph TD + Server((Cloud Server)) -->|Network| App(Application Layer) + + subgraph Device + App -->|"PushPacketToDecodeQueue()"| DecodeQueue(audio_decode_queue_) + + subgraph OpusCodecTask + DecodeQueue -->|Opus Packet| Decoder(OpusDecoder) + Decoder -->|PCM| PlaybackQueue(audio_playback_queue_) + end + + subgraph AudioOutputTask + PlaybackQueue -->|PCM| Codec(AudioCodec) + end + + Codec -->|I2S| Speaker[("Speaker")] + end +``` + +- The application receives Opus packets from the network and pushes them into the `audio_decode_queue_`. +- The `OpusCodecTask` retrieves these packets, decodes them back into PCM data, and pushes the data to the `audio_playback_queue_`. +- The `AudioOutputTask` takes the PCM data from the queue and sends it to the `AudioCodec` for playback. + +## Power Management + +To conserve energy, the audio codec's input (ADC) and output (DAC) channels are automatically disabled after a period of inactivity (`AUDIO_POWER_TIMEOUT_MS`). A timer (`audio_power_timer_`) periodically checks for activity and manages the power state. The channels are automatically re-enabled when new audio needs to be captured or played. \ No newline at end of file diff --git a/main/audio/audio_codec.cc b/main/audio/audio_codec.cc new file mode 100644 index 0000000..03e2baa --- /dev/null +++ b/main/audio/audio_codec.cc @@ -0,0 +1,67 @@ +#include "audio_codec.h" +#include "board.h" +#include "settings.h" + +#include +#include +#include + +#define TAG "AudioCodec" + +AudioCodec::AudioCodec() { +} + +AudioCodec::~AudioCodec() { +} + +void AudioCodec::OutputData(std::vector& data) { + Write(data.data(), data.size()); +} + +bool AudioCodec::InputData(std::vector& data) { + int samples = Read(data.data(), data.size()); + if (samples > 0) { + return true; + } + return false; +} + +void AudioCodec::Start() { + Settings settings("audio", false); + output_volume_ = settings.GetInt("output_volume", output_volume_); + if (output_volume_ <= 0) { + ESP_LOGW(TAG, "Output volume value (%d) is too small, setting to default (10)", output_volume_); + output_volume_ = 10; + } + + ESP_LOGI(TAG, "Audio codec started"); +} + +void AudioCodec::SetOutputVolume(int volume) { + output_volume_ = volume; + ESP_LOGI(TAG, "Set output volume to %d", output_volume_); + + Settings settings("audio", true); + settings.SetInt("output_volume", output_volume_); +} + +void AudioCodec::SetInputGain(float gain) { + input_gain_ = gain; + ESP_LOGI(TAG, "Set input gain to %.1f", input_gain_); +} + +void AudioCodec::EnableInput(bool enable) { + if (enable == input_enabled_) { + return; + } + input_enabled_ = enable; + ESP_LOGI(TAG, "Set input enable to %s", enable ? "true" : "false"); +} + +void AudioCodec::EnableOutput(bool enable) { + if (enable == output_enabled_) { + return; + } + output_enabled_ = enable; + ESP_LOGI(TAG, "Set output enable to %s", enable ? "true" : "false"); +} diff --git a/main/audio/audio_codec.h b/main/audio/audio_codec.h new file mode 100644 index 0000000..819e646 --- /dev/null +++ b/main/audio/audio_codec.h @@ -0,0 +1,61 @@ +#ifndef _AUDIO_CODEC_H +#define _AUDIO_CODEC_H + +#include +#include +#include + +#include +#include +#include + +#include "board.h" + +#define AUDIO_CODEC_DMA_DESC_NUM 6 +#define AUDIO_CODEC_DMA_FRAME_NUM 240 + +class AudioCodec { +public: + AudioCodec(); + virtual ~AudioCodec(); + + virtual void SetOutputVolume(int volume); + virtual void SetInputGain(float gain); + virtual void EnableInput(bool enable); + virtual void EnableOutput(bool enable); + + virtual void OutputData(std::vector& data); + virtual bool InputData(std::vector& data); + virtual void Start(); + + inline bool duplex() const { return duplex_; } + inline bool input_reference() const { return input_reference_; } + inline int input_sample_rate() const { return input_sample_rate_; } + inline int output_sample_rate() const { return output_sample_rate_; } + inline int input_channels() const { return input_channels_; } + inline int output_channels() const { return output_channels_; } + inline int output_volume() const { return output_volume_; } + inline float input_gain() const { return input_gain_; } + inline bool input_enabled() const { return input_enabled_; } + inline bool output_enabled() const { return output_enabled_; } + +protected: + i2s_chan_handle_t tx_handle_ = nullptr; + i2s_chan_handle_t rx_handle_ = nullptr; + + bool duplex_ = false; + bool input_reference_ = false; + bool input_enabled_ = false; + bool output_enabled_ = false; + int input_sample_rate_ = 0; + int output_sample_rate_ = 0; + int input_channels_ = 1; + int output_channels_ = 1; + int output_volume_ = 70; + float input_gain_ = 0.0; + + virtual int Read(int16_t* dest, int samples) = 0; + virtual int Write(const int16_t* data, int samples) = 0; +}; + +#endif // _AUDIO_CODEC_H diff --git a/main/audio/audio_processor.h b/main/audio/audio_processor.h new file mode 100644 index 0000000..543c7ae --- /dev/null +++ b/main/audio/audio_processor.h @@ -0,0 +1,26 @@ +#ifndef AUDIO_PROCESSOR_H +#define AUDIO_PROCESSOR_H + +#include +#include +#include + +#include +#include "audio_codec.h" + +class AudioProcessor { +public: + virtual ~AudioProcessor() = default; + + virtual void Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) = 0; + virtual void Feed(std::vector&& data) = 0; + virtual void Start() = 0; + virtual void Stop() = 0; + virtual bool IsRunning() = 0; + virtual void OnOutput(std::function&& data)> callback) = 0; + virtual void OnVadStateChange(std::function callback) = 0; + virtual size_t GetFeedSize() = 0; + virtual void EnableDeviceAec(bool enable) = 0; +}; + +#endif diff --git a/main/audio/audio_service.cc b/main/audio/audio_service.cc new file mode 100644 index 0000000..350ac58 --- /dev/null +++ b/main/audio/audio_service.cc @@ -0,0 +1,734 @@ +#include "audio_service.h" +#include +#include + +#define RATE_CVT_CFG(_src_rate, _dest_rate, _channel) \ + (esp_ae_rate_cvt_cfg_t) \ + { \ + .src_rate = (uint32_t)(_src_rate), \ + .dest_rate = (uint32_t)(_dest_rate), \ + .channel = (uint8_t)(_channel), \ + .bits_per_sample = ESP_AUDIO_BIT16, \ + .complexity = 2, \ + .perf_type = ESP_AE_RATE_CVT_PERF_TYPE_SPEED, \ + } + +#define OPUS_DEC_CFG(_sample_rate, _frame_duration_ms) \ + (esp_opus_dec_cfg_t) \ + { \ + .sample_rate = (uint32_t)(_sample_rate), \ + .channel = ESP_AUDIO_MONO, \ + .frame_duration = (esp_opus_dec_frame_duration_t)AS_OPUS_GET_FRAME_DRU_ENUM(_frame_duration_ms), \ + .self_delimited = false, \ + } + +#if CONFIG_USE_AUDIO_PROCESSOR +#include "processors/afe_audio_processor.h" +#else +#include "processors/no_audio_processor.h" +#endif + +#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32P4 +#include "wake_words/afe_wake_word.h" +#include "wake_words/custom_wake_word.h" +#else +#include "wake_words/esp_wake_word.h" +#endif + +#define TAG "AudioService" + +AudioService::AudioService() { + event_group_ = xEventGroupCreate(); +} + +AudioService::~AudioService() { + if (event_group_ != nullptr) { + vEventGroupDelete(event_group_); + } + if (opus_encoder_ != nullptr) { + esp_opus_enc_close(opus_encoder_); + } + if (opus_decoder_ != nullptr) { + esp_opus_dec_close(opus_decoder_); + } + if (input_resampler_ != nullptr) { + esp_ae_rate_cvt_close(input_resampler_); + } + if (output_resampler_ != nullptr) { + esp_ae_rate_cvt_close(output_resampler_); + } +} + +void AudioService::Initialize(AudioCodec* codec) { + codec_ = codec; + codec_->Start(); + + esp_opus_dec_cfg_t opus_dec_cfg = OPUS_DEC_CFG(codec->output_sample_rate(), OPUS_FRAME_DURATION_MS); + auto ret = esp_opus_dec_open(&opus_dec_cfg, sizeof(esp_opus_dec_cfg_t), &opus_decoder_); + if (opus_decoder_ == nullptr) { + ESP_LOGE(TAG, "Failed to create audio decoder, error code: %d", ret); + } else { + decoder_sample_rate_ = codec->output_sample_rate(); + decoder_duration_ms_ = OPUS_FRAME_DURATION_MS; + decoder_frame_size_ = decoder_sample_rate_ / 1000 * OPUS_FRAME_DURATION_MS; + } + esp_opus_enc_config_t opus_enc_cfg = AS_OPUS_ENC_CONFIG(); + ret = esp_opus_enc_open(&opus_enc_cfg, sizeof(esp_opus_enc_config_t), &opus_encoder_); + if (opus_encoder_ == nullptr) { + ESP_LOGE(TAG, "Failed to create audio encoder, error code: %d", ret); + } else { + encoder_sample_rate_ = 16000; + encoder_duration_ms_ = OPUS_FRAME_DURATION_MS; + esp_opus_enc_get_frame_size(opus_encoder_, &encoder_frame_size_, &encoder_outbuf_size_); + encoder_frame_size_ = encoder_frame_size_ / sizeof(int16_t); + } + + if (codec->input_sample_rate() != 16000) { + esp_ae_rate_cvt_cfg_t input_resampler_cfg = RATE_CVT_CFG( + codec->input_sample_rate(), ESP_AUDIO_SAMPLE_RATE_16K, codec->input_channels()); + auto resampler_ret = esp_ae_rate_cvt_open(&input_resampler_cfg, &input_resampler_); + if (input_resampler_ == nullptr) { + ESP_LOGE(TAG, "Failed to create input resampler, error code: %d", resampler_ret); + } + } + +#if CONFIG_USE_AUDIO_PROCESSOR + audio_processor_ = std::make_unique(); +#else + audio_processor_ = std::make_unique(); +#endif + + audio_processor_->OnOutput([this](std::vector&& data) { + PushTaskToEncodeQueue(kAudioTaskTypeEncodeToSendQueue, std::move(data)); + }); + + audio_processor_->OnVadStateChange([this](bool speaking) { + voice_detected_ = speaking; + if (callbacks_.on_vad_change) { + callbacks_.on_vad_change(speaking); + } + }); + + esp_timer_create_args_t audio_power_timer_args = { + .callback = [](void* arg) { + AudioService* audio_service = (AudioService*)arg; + audio_service->CheckAndUpdateAudioPowerState(); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "audio_power_timer", + .skip_unhandled_events = true, + }; + esp_timer_create(&audio_power_timer_args, &audio_power_timer_); +} + +void AudioService::Start() { + service_stopped_ = false; + xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING | AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING); + + esp_timer_start_periodic(audio_power_timer_, 1000000); + +#if CONFIG_USE_AUDIO_PROCESSOR + /* Start the audio input task */ + xTaskCreatePinnedToCore([](void* arg) { + AudioService* audio_service = (AudioService*)arg; + audio_service->AudioInputTask(); + vTaskDelete(NULL); + }, "audio_input", 2048 * 3, this, 8, &audio_input_task_handle_, 0); + + /* Start the audio output task */ + xTaskCreate([](void* arg) { + AudioService* audio_service = (AudioService*)arg; + audio_service->AudioOutputTask(); + vTaskDelete(NULL); + }, "audio_output", 2048 * 2, this, 4, &audio_output_task_handle_); +#else + /* Start the audio input task */ + xTaskCreate([](void* arg) { + AudioService* audio_service = (AudioService*)arg; + audio_service->AudioInputTask(); + vTaskDelete(NULL); + }, "audio_input", 2048 * 2, this, 8, &audio_input_task_handle_); + + /* Start the audio output task */ + xTaskCreate([](void* arg) { + AudioService* audio_service = (AudioService*)arg; + audio_service->AudioOutputTask(); + vTaskDelete(NULL); + }, "audio_output", 2048, this, 4, &audio_output_task_handle_); +#endif + + /* Start the opus codec task */ + xTaskCreate([](void* arg) { + AudioService* audio_service = (AudioService*)arg; + audio_service->OpusCodecTask(); + vTaskDelete(NULL); + }, "opus_codec", 2048 * 12, this, 2, &opus_codec_task_handle_); +} + +void AudioService::Stop() { + esp_timer_stop(audio_power_timer_); + service_stopped_ = true; + xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING | + AS_EVENT_WAKE_WORD_RUNNING | + AS_EVENT_AUDIO_PROCESSOR_RUNNING); + + std::lock_guard lock(audio_queue_mutex_); + audio_encode_queue_.clear(); + audio_decode_queue_.clear(); + audio_playback_queue_.clear(); + audio_testing_queue_.clear(); + audio_queue_cv_.notify_all(); +} + +bool AudioService::ReadAudioData(std::vector& data, int sample_rate, int samples) { + if (!codec_->input_enabled()) { + esp_timer_stop(audio_power_timer_); + esp_timer_start_periodic(audio_power_timer_, AUDIO_POWER_CHECK_INTERVAL_MS * 1000); + codec_->EnableInput(true); + } + + if (codec_->input_sample_rate() != sample_rate) { + data.resize(samples * codec_->input_sample_rate() / sample_rate * codec_->input_channels()); + if (!codec_->InputData(data)) { + return false; + } + if (input_resampler_ != nullptr) { + std::lock_guard lock(input_resampler_mutex_); + uint32_t in_sample_num = data.size() / codec_->input_channels(); + uint32_t output_samples = 0; + esp_ae_rate_cvt_get_max_out_sample_num(input_resampler_, in_sample_num, &output_samples); + auto resampled = std::vector(output_samples * codec_->input_channels()); + uint32_t actual_output = output_samples; + esp_ae_rate_cvt_process(input_resampler_, (esp_ae_sample_t)data.data(), in_sample_num, + (esp_ae_sample_t)resampled.data(), &actual_output); + resampled.resize(actual_output * codec_->input_channels()); + data = std::move(resampled); + } + } else { + data.resize(samples * codec_->input_channels()); + if (!codec_->InputData(data)) { + return false; + } + } + + /* Update the last input time */ + last_input_time_ = std::chrono::steady_clock::now(); + debug_statistics_.input_count++; + +#if CONFIG_USE_AUDIO_DEBUGGER + // 音频调试:发送原始音频数据 + if (audio_debugger_ == nullptr) { + audio_debugger_ = std::make_unique(); + } + audio_debugger_->Feed(data); +#endif + + return true; +} + +void AudioService::AudioInputTask() { + while (true) { + EventBits_t bits = xEventGroupWaitBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING | + AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING, + pdFALSE, pdFALSE, portMAX_DELAY); + + if (service_stopped_) { + break; + } + if (audio_input_need_warmup_) { + audio_input_need_warmup_ = false; + vTaskDelay(pdMS_TO_TICKS(120)); + continue; + } + + /* Used for audio testing in NetworkConfiguring mode by clicking the BOOT button */ + if (bits & AS_EVENT_AUDIO_TESTING_RUNNING) { + if (audio_testing_queue_.size() >= AUDIO_TESTING_MAX_DURATION_MS / OPUS_FRAME_DURATION_MS) { + ESP_LOGW(TAG, "Audio testing queue is full, stopping audio testing"); + EnableAudioTesting(false); + continue; + } + std::vector data; + int samples = OPUS_FRAME_DURATION_MS * 16000 / 1000; + if (ReadAudioData(data, 16000, samples)) { + // If input channels is 2, we need to fetch the left channel data + if (codec_->input_channels() == 2) { + auto mono_data = std::vector(data.size() / 2); + for (size_t i = 0, j = 0; i < mono_data.size(); ++i, j += 2) { + mono_data[i] = data[j]; + } + data = std::move(mono_data); + } + PushTaskToEncodeQueue(kAudioTaskTypeEncodeToTestingQueue, std::move(data)); + continue; + } + } + + /* Feed the wake word and/or audio processor */ + if (bits & (AS_EVENT_WAKE_WORD_RUNNING | AS_EVENT_AUDIO_PROCESSOR_RUNNING)) { + int samples = 160; // 10ms + std::vector data; + if (ReadAudioData(data, 16000, samples)) { + if (bits & AS_EVENT_WAKE_WORD_RUNNING) { + wake_word_->Feed(data); + } + if (bits & AS_EVENT_AUDIO_PROCESSOR_RUNNING) { + audio_processor_->Feed(std::move(data)); + } + continue; + } + } + + // Read timeout/error should not terminate the input task. + vTaskDelay(pdMS_TO_TICKS(10)); + } + + ESP_LOGW(TAG, "Audio input task stopped"); +} + +void AudioService::AudioOutputTask() { + while (true) { + std::unique_lock lock(audio_queue_mutex_); + audio_queue_cv_.wait(lock, [this]() { return !audio_playback_queue_.empty() || service_stopped_; }); + if (service_stopped_) { + break; + } + + auto task = std::move(audio_playback_queue_.front()); + audio_playback_queue_.pop_front(); + audio_queue_cv_.notify_all(); + lock.unlock(); + + if (!codec_->output_enabled()) { + esp_timer_stop(audio_power_timer_); + esp_timer_start_periodic(audio_power_timer_, AUDIO_POWER_CHECK_INTERVAL_MS * 1000); + codec_->EnableOutput(true); + } + + codec_->OutputData(task->pcm); + + /* Update the last output time */ + last_output_time_ = std::chrono::steady_clock::now(); + debug_statistics_.playback_count++; + +#if CONFIG_USE_SERVER_AEC + /* Record the timestamp for server AEC */ + if (task->timestamp > 0) { + lock.lock(); + timestamp_queue_.push_back(task->timestamp); + } +#endif + } + + ESP_LOGW(TAG, "Audio output task stopped"); +} + +void AudioService::OpusCodecTask() { + while (true) { + std::unique_lock lock(audio_queue_mutex_); + audio_queue_cv_.wait(lock, [this]() { + return service_stopped_ || + (!audio_encode_queue_.empty() && audio_send_queue_.size() < MAX_SEND_PACKETS_IN_QUEUE) || + (!audio_decode_queue_.empty() && audio_playback_queue_.size() < MAX_PLAYBACK_TASKS_IN_QUEUE); + }); + if (service_stopped_) { + break; + } + + /* Decode the audio from decode queue */ + if (!audio_decode_queue_.empty() && audio_playback_queue_.size() < MAX_PLAYBACK_TASKS_IN_QUEUE) { + auto packet = std::move(audio_decode_queue_.front()); + audio_decode_queue_.pop_front(); + audio_queue_cv_.notify_all(); + lock.unlock(); + + auto task = std::make_unique(); + task->type = kAudioTaskTypeDecodeToPlaybackQueue; + task->timestamp = packet->timestamp; + + SetDecodeSampleRate(packet->sample_rate, packet->frame_duration); + if (opus_decoder_ != nullptr) { + task->pcm.resize(decoder_frame_size_); + esp_audio_dec_in_raw_t raw = { + .buffer = (uint8_t *)(packet->payload.data()), + .len = (uint32_t)(packet->payload.size()), + .consumed = 0, + .frame_recover = ESP_AUDIO_DEC_RECOVERY_NONE, + }; + esp_audio_dec_out_frame_t out_frame = { + .buffer = (uint8_t *)(task->pcm.data()), + .len = (uint32_t)(task->pcm.size() * sizeof(int16_t)), + .decoded_size = 0, + }; + esp_audio_dec_info_t dec_info = {}; + std::unique_lock decoder_lock(decoder_mutex_); + auto ret = esp_opus_dec_decode(opus_decoder_, &raw, &out_frame, &dec_info); + decoder_lock.unlock(); + if (ret == ESP_AUDIO_ERR_OK) { + task->pcm.resize(out_frame.decoded_size / sizeof(int16_t)); + if (decoder_sample_rate_ != codec_->output_sample_rate() && output_resampler_ != nullptr) { + uint32_t target_size = 0; + esp_ae_rate_cvt_get_max_out_sample_num(output_resampler_, task->pcm.size(), &target_size); + std::vector resampled(target_size); + uint32_t actual_output = target_size; + esp_ae_rate_cvt_process(output_resampler_, (esp_ae_sample_t)task->pcm.data(), task->pcm.size(), + (esp_ae_sample_t)resampled.data(), &actual_output); + resampled.resize(actual_output); + task->pcm = std::move(resampled); + } + lock.lock(); + audio_playback_queue_.push_back(std::move(task)); + audio_queue_cv_.notify_all(); + debug_statistics_.decode_count++; + } else { + ESP_LOGE(TAG, "Failed to decode audio after resize, error code: %d", ret); + lock.lock(); + } + } else { + ESP_LOGE(TAG, "Audio decoder is not configured"); + lock.lock(); + } + debug_statistics_.decode_count++; + } + /* Encode the audio to send queue */ + if (!audio_encode_queue_.empty() && audio_send_queue_.size() < MAX_SEND_PACKETS_IN_QUEUE) { + auto task = std::move(audio_encode_queue_.front()); + audio_encode_queue_.pop_front(); + audio_queue_cv_.notify_all(); + lock.unlock(); + + auto packet = std::make_unique(); + packet->frame_duration = OPUS_FRAME_DURATION_MS; + packet->sample_rate = 16000; + packet->timestamp = task->timestamp; + + if (opus_encoder_ != nullptr && task->pcm.size() == encoder_frame_size_) { + std::vector buf(encoder_outbuf_size_); + esp_audio_enc_in_frame_t in = { + .buffer = (uint8_t *)(task->pcm.data()), + .len = (uint32_t)(encoder_frame_size_ * sizeof(int16_t)), + }; + esp_audio_enc_out_frame_t out = { + .buffer = buf.data(), + .len = (uint32_t)encoder_outbuf_size_, + .encoded_bytes = 0, + }; + auto ret = esp_opus_enc_process(opus_encoder_, &in, &out); + if (ret == ESP_AUDIO_ERR_OK) { + packet->payload.assign(buf.data(), buf.data() + out.encoded_bytes); + + if (task->type == kAudioTaskTypeEncodeToSendQueue) { + { + std::lock_guard lock2(audio_queue_mutex_); + audio_send_queue_.push_back(std::move(packet)); + } + if (callbacks_.on_send_queue_available) { + callbacks_.on_send_queue_available(); + } + } else if (task->type == kAudioTaskTypeEncodeToTestingQueue) { + std::lock_guard lock2(audio_queue_mutex_); + audio_testing_queue_.push_back(std::move(packet)); + } + debug_statistics_.encode_count++; + } else { + ESP_LOGE(TAG, "Failed to encode audio, error code: %d", ret); + } + } else { + ESP_LOGE(TAG, "Failed to encode audio: encoder not configured or invalid frame size (got %u, expected %u)", + task->pcm.size(), encoder_frame_size_); + } + lock.lock(); + } + } + + ESP_LOGW(TAG, "Opus codec task stopped"); +} + +void AudioService::SetDecodeSampleRate(int sample_rate, int frame_duration) { + if (decoder_sample_rate_ == sample_rate && decoder_duration_ms_ == frame_duration) { + return; + } + std::unique_lock decoder_lock(decoder_mutex_); + if (opus_decoder_ != nullptr) { + esp_opus_dec_close(opus_decoder_); + opus_decoder_ = nullptr; + } + decoder_lock.unlock(); + esp_opus_dec_cfg_t opus_dec_cfg = OPUS_DEC_CFG(sample_rate, frame_duration); + auto ret = esp_opus_dec_open(&opus_dec_cfg, sizeof(esp_opus_dec_cfg_t), &opus_decoder_); + if (opus_decoder_ == nullptr) { + ESP_LOGE(TAG, "Failed to create audio decoder, error code: %d", ret); + return; + } + decoder_sample_rate_ = sample_rate; + decoder_duration_ms_ = frame_duration; + decoder_frame_size_ = decoder_sample_rate_ / 1000 * frame_duration; + + auto codec = Board::GetInstance().GetAudioCodec(); + if (decoder_sample_rate_ != codec->output_sample_rate()) { + ESP_LOGI(TAG, "Resampling audio from %d to %d", decoder_sample_rate_, codec->output_sample_rate()); + if (output_resampler_ != nullptr) { + esp_ae_rate_cvt_close(output_resampler_); + output_resampler_ = nullptr; + } + esp_ae_rate_cvt_cfg_t output_resampler_cfg = RATE_CVT_CFG( + decoder_sample_rate_, codec->output_sample_rate(), ESP_AUDIO_MONO); + auto resampler_ret = esp_ae_rate_cvt_open(&output_resampler_cfg, &output_resampler_); + if (output_resampler_ == nullptr) { + ESP_LOGE(TAG, "Failed to create output resampler, error code: %d", resampler_ret); + } + } +} + +void AudioService::PushTaskToEncodeQueue(AudioTaskType type, std::vector&& pcm) { + auto task = std::make_unique(); + task->type = type; + task->pcm = std::move(pcm); + /* Push the task to the encode queue */ + std::unique_lock lock(audio_queue_mutex_); + + /* If the task is to send queue, we need to set the timestamp */ + if (type == kAudioTaskTypeEncodeToSendQueue && !timestamp_queue_.empty()) { + if (timestamp_queue_.size() <= MAX_TIMESTAMPS_IN_QUEUE) { + task->timestamp = timestamp_queue_.front(); + } else { + ESP_LOGW(TAG, "Timestamp queue (%u) is full, dropping timestamp", timestamp_queue_.size()); + } + timestamp_queue_.pop_front(); + } + + audio_queue_cv_.wait(lock, [this]() { return audio_encode_queue_.size() < MAX_ENCODE_TASKS_IN_QUEUE; }); + audio_encode_queue_.push_back(std::move(task)); + audio_queue_cv_.notify_all(); +} + +bool AudioService::PushPacketToDecodeQueue(std::unique_ptr packet, bool wait) { + std::unique_lock lock(audio_queue_mutex_); + if (audio_decode_queue_.size() >= MAX_DECODE_PACKETS_IN_QUEUE) { + if (wait) { + audio_queue_cv_.wait(lock, [this]() { return audio_decode_queue_.size() < MAX_DECODE_PACKETS_IN_QUEUE; }); + } else { + return false; + } + } + audio_decode_queue_.push_back(std::move(packet)); + audio_queue_cv_.notify_all(); + return true; +} + +std::unique_ptr AudioService::PopPacketFromSendQueue() { + std::lock_guard lock(audio_queue_mutex_); + if (audio_send_queue_.empty()) { + return nullptr; + } + auto packet = std::move(audio_send_queue_.front()); + audio_send_queue_.pop_front(); + audio_queue_cv_.notify_all(); + return packet; +} + +void AudioService::EncodeWakeWord() { + if (wake_word_) { + wake_word_->EncodeWakeWordData(); + } +} + +const std::string& AudioService::GetLastWakeWord() const { + return wake_word_->GetLastDetectedWakeWord(); +} + +std::unique_ptr AudioService::PopWakeWordPacket() { + auto packet = std::make_unique(); + if (wake_word_->GetWakeWordOpus(packet->payload)) { + return packet; + } + return nullptr; +} + +void AudioService::EnableWakeWordDetection(bool enable) { + if (!wake_word_) { + return; + } + + ESP_LOGD(TAG, "%s wake word detection", enable ? "Enabling" : "Disabling"); + if (enable) { + if (!wake_word_initialized_) { + if (!wake_word_->Initialize(codec_, models_list_)) { + ESP_LOGE(TAG, "Failed to initialize wake word"); + return; + } + wake_word_initialized_ = true; + } + // Reset input resampler to clear cached data from previous mode (e.g. AudioProcessor) + // This prevents buffer overflow when switching between different feed sizes + { + std::lock_guard lock(input_resampler_mutex_); + if (input_resampler_ != nullptr) { + esp_ae_rate_cvt_reset(input_resampler_); + } + } + wake_word_->Start(); + xEventGroupSetBits(event_group_, AS_EVENT_WAKE_WORD_RUNNING); + } else { + wake_word_->Stop(); + xEventGroupClearBits(event_group_, AS_EVENT_WAKE_WORD_RUNNING); + } +} + +void AudioService::EnableVoiceProcessing(bool enable) { + ESP_LOGD(TAG, "%s voice processing", enable ? "Enabling" : "Disabling"); + if (enable) { + if (!audio_processor_initialized_) { + audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_); + audio_processor_initialized_ = true; + } + + /* We should make sure no audio is playing */ + ResetDecoder(); + audio_input_need_warmup_ = true; + // Reset input resampler to clear cached data from previous mode (e.g. WakeWord) + // This prevents buffer overflow when switching between different feed sizes + { + std::lock_guard lock(input_resampler_mutex_); + if (input_resampler_ != nullptr) { + esp_ae_rate_cvt_reset(input_resampler_); + } + } + audio_processor_->Start(); + xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_PROCESSOR_RUNNING); + } else { + audio_processor_->Stop(); + xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_PROCESSOR_RUNNING); + } +} + +void AudioService::EnableAudioTesting(bool enable) { + ESP_LOGI(TAG, "%s audio testing", enable ? "Enabling" : "Disabling"); + if (enable) { + xEventGroupSetBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING); + } else { + xEventGroupClearBits(event_group_, AS_EVENT_AUDIO_TESTING_RUNNING); + /* Copy audio_testing_queue_ to audio_decode_queue_ */ + std::lock_guard lock(audio_queue_mutex_); + audio_decode_queue_ = std::move(audio_testing_queue_); + audio_queue_cv_.notify_all(); + } +} + +void AudioService::EnableDeviceAec(bool enable) { + ESP_LOGI(TAG, "%s device AEC", enable ? "Enabling" : "Disabling"); + if (!audio_processor_initialized_) { + audio_processor_->Initialize(codec_, OPUS_FRAME_DURATION_MS, models_list_); + audio_processor_initialized_ = true; + } + + audio_processor_->EnableDeviceAec(enable); +} + +void AudioService::SetCallbacks(AudioServiceCallbacks& callbacks) { + callbacks_ = callbacks; +} + +void AudioService::PlaySound(const std::string_view& ogg) { + if (!codec_->output_enabled()) { + esp_timer_stop(audio_power_timer_); + esp_timer_start_periodic(audio_power_timer_, AUDIO_POWER_CHECK_INTERVAL_MS * 1000); + codec_->EnableOutput(true); + } + + const auto* buf = reinterpret_cast(ogg.data()); + size_t size = ogg.size(); + + auto demuxer = std::make_unique(); + demuxer->OnDemuxerFinished([this](const uint8_t* data, int sample_rate, size_t size){ + auto packet = std::make_unique(); + packet->sample_rate = sample_rate; + packet->frame_duration = 60; + packet->payload.resize(size); + std::memcpy(packet->payload.data(), data, size); + PushPacketToDecodeQueue(std::move(packet), true); + }); + demuxer->Reset(); + demuxer->Process(buf, size); +} + +bool AudioService::IsIdle() { + std::lock_guard lock(audio_queue_mutex_); + return audio_encode_queue_.empty() && audio_decode_queue_.empty() && audio_playback_queue_.empty() && audio_testing_queue_.empty(); +} + +void AudioService::WaitForPlaybackQueueEmpty() { + std::unique_lock lock(audio_queue_mutex_); + audio_queue_cv_.wait(lock, [this]() { + return service_stopped_ || (audio_decode_queue_.empty() && audio_playback_queue_.empty()); + }); +} + +void AudioService::ResetDecoder() { + std::lock_guard lock(audio_queue_mutex_); + std::unique_lock decoder_lock(decoder_mutex_); + if (opus_decoder_ != nullptr) { + esp_opus_dec_reset(opus_decoder_); + } + decoder_lock.unlock(); + timestamp_queue_.clear(); + audio_decode_queue_.clear(); + audio_playback_queue_.clear(); + audio_testing_queue_.clear(); + audio_queue_cv_.notify_all(); +} + +void AudioService::CheckAndUpdateAudioPowerState() { + auto now = std::chrono::steady_clock::now(); + auto input_elapsed = std::chrono::duration_cast(now - last_input_time_).count(); + auto output_elapsed = std::chrono::duration_cast(now - last_output_time_).count(); + if (input_elapsed > AUDIO_POWER_TIMEOUT_MS && codec_->input_enabled()) { + codec_->EnableInput(false); + } + if (output_elapsed > AUDIO_POWER_TIMEOUT_MS && codec_->output_enabled()) { + // Keep TX clock when duplex RX is active; otherwise RX may stall on some boards. + if (!(codec_->duplex() && codec_->input_enabled())) { + codec_->EnableOutput(false); + } + } + if (!codec_->input_enabled() && !codec_->output_enabled()) { + esp_timer_stop(audio_power_timer_); + } +} + +void AudioService::SetModelsList(srmodel_list_t* models_list) { + 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(); + } else if (esp_srmodel_filter(models_list_, ESP_WN_PREFIX, NULL) != nullptr) { + wake_word_ = std::make_unique(); + } else { + wake_word_ = nullptr; + } +#else + if (esp_srmodel_filter(models_list_, ESP_WN_PREFIX, NULL) != nullptr) { + wake_word_ = std::make_unique(); + } else { + wake_word_ = nullptr; + } +#endif + + if (wake_word_) { + wake_word_->OnWakeWordDetected([this](const std::string& wake_word) { + if (callbacks_.on_wake_word_detected) { + callbacks_.on_wake_word_detected(wake_word); + } + }); + } +} + +bool AudioService::IsAfeWakeWord() { +#if CONFIG_IDF_TARGET_ESP32S3 || CONFIG_IDF_TARGET_ESP32P4 + return wake_word_ != nullptr && dynamic_cast(wake_word_.get()) != nullptr; +#else + return false; +#endif +} diff --git a/main/audio/audio_service.h b/main/audio/audio_service.h new file mode 100644 index 0000000..99db546 --- /dev/null +++ b/main/audio/audio_service.h @@ -0,0 +1,195 @@ +#ifndef AUDIO_SERVICE_H +#define AUDIO_SERVICE_H + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include "esp_audio_enc.h" +#include "esp_opus_enc.h" +#include "esp_opus_dec.h" +#include "esp_ae_rate_cvt.h" +#include "esp_audio_types.h" + +#include "audio_codec.h" +#include "audio_processor.h" +#include "processors/audio_debugger.h" +#include "wake_word.h" +#include "protocol.h" +#include "ogg_demuxer.h" + +/* + * There are two types of audio data flow: + * 1. (MIC) -> [Processors] -> {Encode Queue} -> [Opus Encoder] -> {Send Queue} -> (Server) + * 2. (Server) -> {Decode Queue} -> [Opus Decoder] -> {Playback Queue} -> (Speaker) + * + * We use one task for MIC / Speaker / Processors, and one task for Opus Encoder / Opus Decoder. + * + * Decode Queue and Send Queue are the main queues, because Opus packets are quite smaller than PCM packets. + * + */ + +#define OPUS_FRAME_DURATION_MS 60 +#define MAX_ENCODE_TASKS_IN_QUEUE 2 +#define MAX_PLAYBACK_TASKS_IN_QUEUE 2 +#define MAX_DECODE_PACKETS_IN_QUEUE (2400 / OPUS_FRAME_DURATION_MS) +#define MAX_SEND_PACKETS_IN_QUEUE (2400 / OPUS_FRAME_DURATION_MS) +#define AUDIO_TESTING_MAX_DURATION_MS 10000 +#define MAX_TIMESTAMPS_IN_QUEUE 3 + +#define AUDIO_POWER_TIMEOUT_MS 15000 +#define AUDIO_POWER_CHECK_INTERVAL_MS 1000 + +#define AS_EVENT_AUDIO_TESTING_RUNNING (1 << 0) +#define AS_EVENT_WAKE_WORD_RUNNING (1 << 1) +#define AS_EVENT_AUDIO_PROCESSOR_RUNNING (1 << 2) +#define AS_EVENT_PLAYBACK_NOT_EMPTY (1 << 3) + +#define AS_OPUS_GET_FRAME_DRU_ENUM(duration_ms) \ + ((duration_ms) == 5 ? ESP_OPUS_ENC_FRAME_DURATION_5_MS : \ + (duration_ms) == 10 ? ESP_OPUS_ENC_FRAME_DURATION_10_MS : \ + (duration_ms) == 20 ? ESP_OPUS_ENC_FRAME_DURATION_20_MS : \ + (duration_ms) == 40 ? ESP_OPUS_ENC_FRAME_DURATION_40_MS : \ + (duration_ms) == 60 ? ESP_OPUS_ENC_FRAME_DURATION_60_MS : \ + (duration_ms) == 80 ? ESP_OPUS_ENC_FRAME_DURATION_80_MS : \ + (duration_ms) == 100 ? ESP_OPUS_ENC_FRAME_DURATION_100_MS : \ + (duration_ms) == 120 ? ESP_OPUS_ENC_FRAME_DURATION_120_MS : -1) + +#define AS_OPUS_ENC_CONFIG() { \ + .sample_rate = ESP_AUDIO_SAMPLE_RATE_16K, \ + .channel = ESP_AUDIO_MONO, \ + .bits_per_sample = ESP_AUDIO_BIT16, \ + .bitrate = ESP_OPUS_BITRATE_AUTO, \ + .frame_duration = (esp_opus_enc_frame_duration_t)AS_OPUS_GET_FRAME_DRU_ENUM(OPUS_FRAME_DURATION_MS), \ + .application_mode = ESP_OPUS_ENC_APPLICATION_AUDIO, \ + .complexity = 0, \ + .enable_fec = false, \ + .enable_dtx = true, \ + .enable_vbr = true, \ + } + +struct AudioServiceCallbacks { + std::function on_send_queue_available; + std::function on_wake_word_detected; + std::function on_vad_change; + std::function on_audio_testing_queue_full; +}; + + +enum AudioTaskType { + kAudioTaskTypeEncodeToSendQueue, + kAudioTaskTypeEncodeToTestingQueue, + kAudioTaskTypeDecodeToPlaybackQueue, +}; + +struct AudioTask { + AudioTaskType type; + std::vector pcm; + uint32_t timestamp; +}; + +struct DebugStatistics { + uint32_t input_count = 0; + uint32_t decode_count = 0; + uint32_t encode_count = 0; + uint32_t playback_count = 0; +}; + +class AudioService { +public: + AudioService(); + ~AudioService(); + + void Initialize(AudioCodec* codec); + void Start(); + void Stop(); + void EncodeWakeWord(); + std::unique_ptr PopWakeWordPacket(); + const std::string& GetLastWakeWord() const; + bool IsVoiceDetected() const { return voice_detected_; } + bool IsIdle(); + void WaitForPlaybackQueueEmpty(); + bool IsWakeWordRunning() const { return xEventGroupGetBits(event_group_) & AS_EVENT_WAKE_WORD_RUNNING; } + bool IsAudioProcessorRunning() const { return xEventGroupGetBits(event_group_) & AS_EVENT_AUDIO_PROCESSOR_RUNNING; } + bool IsAfeWakeWord(); + + void EnableWakeWordDetection(bool enable); + void EnableVoiceProcessing(bool enable); + void EnableAudioTesting(bool enable); + void EnableDeviceAec(bool enable); + + void SetCallbacks(AudioServiceCallbacks& callbacks); + + bool PushPacketToDecodeQueue(std::unique_ptr packet, bool wait = false); + std::unique_ptr PopPacketFromSendQueue(); + void PlaySound(const std::string_view& sound); + bool ReadAudioData(std::vector& data, int sample_rate, int samples); + void ResetDecoder(); + void SetModelsList(srmodel_list_t* models_list); + +private: + AudioCodec* codec_ = nullptr; + AudioServiceCallbacks callbacks_; + std::unique_ptr audio_processor_; + std::unique_ptr wake_word_; + std::unique_ptr audio_debugger_; + void* opus_encoder_ = nullptr; + void* opus_decoder_ = nullptr; + std::mutex decoder_mutex_; + std::mutex input_resampler_mutex_; + esp_ae_rate_cvt_handle_t input_resampler_ = nullptr; + esp_ae_rate_cvt_handle_t output_resampler_ = nullptr; + + // Encoder/Decoder state + int encoder_sample_rate_ = 16000; + int encoder_duration_ms_ = OPUS_FRAME_DURATION_MS; + int encoder_frame_size_ = 0; + int encoder_outbuf_size_ = 0; + int decoder_sample_rate_ = 0; + int decoder_duration_ms_ = OPUS_FRAME_DURATION_MS; + int decoder_frame_size_ = 0; + DebugStatistics debug_statistics_; + srmodel_list_t* models_list_ = nullptr; + + EventGroupHandle_t event_group_; + + // Audio encode / decode + TaskHandle_t audio_input_task_handle_ = nullptr; + TaskHandle_t audio_output_task_handle_ = nullptr; + TaskHandle_t opus_codec_task_handle_ = nullptr; + std::mutex audio_queue_mutex_; + std::condition_variable audio_queue_cv_; + std::deque> audio_decode_queue_; + std::deque> audio_send_queue_; + std::deque> audio_testing_queue_; + std::deque> audio_encode_queue_; + std::deque> audio_playback_queue_; + // For server AEC + std::deque timestamp_queue_; + + bool wake_word_initialized_ = false; + bool audio_processor_initialized_ = false; + bool voice_detected_ = false; + bool service_stopped_ = true; + bool audio_input_need_warmup_ = false; + + esp_timer_handle_t audio_power_timer_ = nullptr; + std::chrono::steady_clock::time_point last_input_time_; + std::chrono::steady_clock::time_point last_output_time_; + + void AudioInputTask(); + void AudioOutputTask(); + void OpusCodecTask(); + void PushTaskToEncodeQueue(AudioTaskType type, std::vector&& pcm); + void SetDecodeSampleRate(int sample_rate, int frame_duration); + void CheckAndUpdateAudioPowerState(); +}; + +#endif \ No newline at end of file diff --git a/main/audio/codecs/box_audio_codec.cc b/main/audio/codecs/box_audio_codec.cc new file mode 100644 index 0000000..8bbe07d --- /dev/null +++ b/main/audio/codecs/box_audio_codec.cc @@ -0,0 +1,247 @@ +#include "box_audio_codec.h" + +#include +#include +#include + +#define TAG "BoxAudioCodec" + +BoxAudioCodec::BoxAudioCodec(void* i2c_master_handle, int input_sample_rate, int output_sample_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din, + gpio_num_t pa_pin, uint8_t es8311_addr, uint8_t es7210_addr, bool input_reference) { + duplex_ = true; // 是否双工 + input_reference_ = input_reference; // 是否使用参考输入,实现回声消除 + input_channels_ = input_reference_ ? 2 : 1; // 输入通道数 + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; + input_gain_ = 30; + + CreateDuplexChannels(mclk, bclk, ws, dout, din); + + // Do initialize of related interface: data_if, ctrl_if and gpio_if + audio_codec_i2s_cfg_t i2s_cfg = { + .port = I2S_NUM_0, + .rx_handle = rx_handle_, + .tx_handle = tx_handle_, + }; + data_if_ = audio_codec_new_i2s_data(&i2s_cfg); + assert(data_if_ != NULL); + + // Output + audio_codec_i2c_cfg_t i2c_cfg = { + .port = (i2c_port_t)1, + .addr = es8311_addr, + .bus_handle = i2c_master_handle, + }; + out_ctrl_if_ = audio_codec_new_i2c_ctrl(&i2c_cfg); + assert(out_ctrl_if_ != NULL); + + gpio_if_ = audio_codec_new_gpio(); + assert(gpio_if_ != NULL); + + es8311_codec_cfg_t es8311_cfg = {}; + es8311_cfg.ctrl_if = out_ctrl_if_; + es8311_cfg.gpio_if = gpio_if_; + es8311_cfg.codec_mode = ESP_CODEC_DEV_WORK_MODE_DAC; + es8311_cfg.pa_pin = pa_pin; + es8311_cfg.use_mclk = true; + es8311_cfg.hw_gain.pa_voltage = 5.0; + es8311_cfg.hw_gain.codec_dac_voltage = 3.3; + out_codec_if_ = es8311_codec_new(&es8311_cfg); + assert(out_codec_if_ != NULL); + + esp_codec_dev_cfg_t dev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_OUT, + .codec_if = out_codec_if_, + .data_if = data_if_, + }; + output_dev_ = esp_codec_dev_new(&dev_cfg); + assert(output_dev_ != NULL); + + // Input + i2c_cfg.addr = es7210_addr; + in_ctrl_if_ = audio_codec_new_i2c_ctrl(&i2c_cfg); + assert(in_ctrl_if_ != NULL); + + es7210_codec_cfg_t es7210_cfg = {}; + es7210_cfg.ctrl_if = in_ctrl_if_; + es7210_cfg.mic_selected = ES7210_SEL_MIC1 | ES7210_SEL_MIC2 | ES7210_SEL_MIC3 | ES7210_SEL_MIC4; + in_codec_if_ = es7210_codec_new(&es7210_cfg); + assert(in_codec_if_ != NULL); + + dev_cfg.dev_type = ESP_CODEC_DEV_TYPE_IN; + dev_cfg.codec_if = in_codec_if_; + input_dev_ = esp_codec_dev_new(&dev_cfg); + assert(input_dev_ != NULL); + + ESP_LOGI(TAG, "BoxAudioDevice initialized"); +} + +BoxAudioCodec::~BoxAudioCodec() { + ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_)); + esp_codec_dev_delete(output_dev_); + ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_)); + esp_codec_dev_delete(input_dev_); + + audio_codec_delete_codec_if(in_codec_if_); + audio_codec_delete_ctrl_if(in_ctrl_if_); + audio_codec_delete_codec_if(out_codec_if_); + audio_codec_delete_ctrl_if(out_ctrl_if_); + audio_codec_delete_gpio_if(gpio_if_); + audio_codec_delete_data_if(data_if_); +} + +void BoxAudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din) { + assert(input_sample_rate_ == output_sample_rate_); + + i2s_chan_config_t chan_cfg = { + .id = I2S_NUM_0, + .role = I2S_ROLE_MASTER, + .dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM, + .dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM, + .auto_clear_after_cb = true, + .auto_clear_before_cb = false, + .intr_priority = 0, + }; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle_, &rx_handle_)); + + i2s_std_config_t std_cfg = { + .clk_cfg = { + .sample_rate_hz = (uint32_t)output_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .ext_clk_freq_hz = 0, + .mclk_multiple = I2S_MCLK_MULTIPLE_256 + }, + .slot_cfg = { + .data_bit_width = I2S_DATA_BIT_WIDTH_16BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_STEREO, + .slot_mask = I2S_STD_SLOT_BOTH, + .ws_width = I2S_DATA_BIT_WIDTH_16BIT, + .ws_pol = false, + .bit_shift = true, + .left_align = true, + .big_endian = false, + .bit_order_lsb = false + }, + .gpio_cfg = { + .mclk = mclk, + .bclk = bclk, + .ws = ws, + .dout = dout, + .din = I2S_GPIO_UNUSED, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false + } + } + }; + + i2s_tdm_config_t tdm_cfg = { + .clk_cfg = { + .sample_rate_hz = (uint32_t)input_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .ext_clk_freq_hz = 0, + .mclk_multiple = I2S_MCLK_MULTIPLE_256, + .bclk_div = 8, + }, + .slot_cfg = { + .data_bit_width = I2S_DATA_BIT_WIDTH_16BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_STEREO, + .slot_mask = i2s_tdm_slot_mask_t(I2S_TDM_SLOT0 | I2S_TDM_SLOT1 | I2S_TDM_SLOT2 | I2S_TDM_SLOT3), + .ws_width = I2S_TDM_AUTO_WS_WIDTH, + .ws_pol = false, + .bit_shift = true, + .left_align = false, + .big_endian = false, + .bit_order_lsb = false, + .skip_mask = false, + .total_slot = I2S_TDM_AUTO_SLOT_NUM + }, + .gpio_cfg = { + .mclk = mclk, + .bclk = bclk, + .ws = ws, + .dout = I2S_GPIO_UNUSED, + .din = din, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false + } + } + }; + + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_init_tdm_mode(rx_handle_, &tdm_cfg)); + ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_)); + ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_)); + ESP_LOGI(TAG, "Duplex channels created"); +} + +void BoxAudioCodec::SetOutputVolume(int volume) { + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, volume)); + AudioCodec::SetOutputVolume(volume); +} + +void BoxAudioCodec::EnableInput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (enable == input_enabled_) { + return; + } + if (enable) { + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 4, + .channel_mask = ESP_CODEC_DEV_MAKE_CHANNEL_MASK(0), + .sample_rate = (uint32_t)output_sample_rate_, + .mclk_multiple = 0, + }; + if (input_reference_) { + fs.channel_mask |= ESP_CODEC_DEV_MAKE_CHANNEL_MASK(1); + } + ESP_ERROR_CHECK(esp_codec_dev_open(input_dev_, &fs)); + ESP_ERROR_CHECK(esp_codec_dev_set_in_channel_gain(input_dev_, ESP_CODEC_DEV_MAKE_CHANNEL_MASK(0), input_gain_)); + } else { + ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_)); + } + AudioCodec::EnableInput(enable); +} + +void BoxAudioCodec::EnableOutput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (enable == output_enabled_) { + return; + } + if (enable) { + // Play 16bit 1 channel + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 1, + .channel_mask = 0, + .sample_rate = (uint32_t)output_sample_rate_, + .mclk_multiple = 0, + }; + ESP_ERROR_CHECK(esp_codec_dev_open(output_dev_, &fs)); + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, output_volume_)); + } else { + ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_)); + } + AudioCodec::EnableOutput(enable); +} + +int BoxAudioCodec::Read(int16_t* dest, int samples) { + if (input_enabled_) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_read(input_dev_, (void*)dest, samples * sizeof(int16_t))); + } + return samples; +} + +int BoxAudioCodec::Write(const int16_t* data, int samples) { + if (output_enabled_) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_write(output_dev_, (void*)data, samples * sizeof(int16_t))); + } + return samples; +} \ No newline at end of file diff --git a/main/audio/codecs/box_audio_codec.h b/main/audio/codecs/box_audio_codec.h new file mode 100644 index 0000000..cb7d389 --- /dev/null +++ b/main/audio/codecs/box_audio_codec.h @@ -0,0 +1,40 @@ +#ifndef _BOX_AUDIO_CODEC_H +#define _BOX_AUDIO_CODEC_H + +#include "audio_codec.h" + +#include +#include +#include + + +class BoxAudioCodec : public AudioCodec { +private: + const audio_codec_data_if_t* data_if_ = nullptr; + const audio_codec_ctrl_if_t* out_ctrl_if_ = nullptr; + const audio_codec_if_t* out_codec_if_ = nullptr; + const audio_codec_ctrl_if_t* in_ctrl_if_ = nullptr; + const audio_codec_if_t* in_codec_if_ = nullptr; + const audio_codec_gpio_if_t* gpio_if_ = nullptr; + + esp_codec_dev_handle_t output_dev_ = nullptr; + esp_codec_dev_handle_t input_dev_ = nullptr; + std::mutex data_if_mutex_; + + void CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din); + + virtual int Read(int16_t* dest, int samples) override; + virtual int Write(const int16_t* data, int samples) override; + +public: + BoxAudioCodec(void* i2c_master_handle, int input_sample_rate, int output_sample_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din, + gpio_num_t pa_pin, uint8_t es8311_addr, uint8_t es7210_addr, bool input_reference); + virtual ~BoxAudioCodec(); + + virtual void SetOutputVolume(int volume) override; + virtual void EnableInput(bool enable) override; + virtual void EnableOutput(bool enable) override; +}; + +#endif // _BOX_AUDIO_CODEC_H diff --git a/main/audio/codecs/dummy_audio_codec.cc b/main/audio/codecs/dummy_audio_codec.cc new file mode 100644 index 0000000..5f646b3 --- /dev/null +++ b/main/audio/codecs/dummy_audio_codec.cc @@ -0,0 +1,20 @@ +#include "dummy_audio_codec.h" + +DummyAudioCodec::DummyAudioCodec(int input_sample_rate, int output_sample_rate) { + duplex_ = true; + input_reference_ = false; + input_channels_ = 1; + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; +} + +DummyAudioCodec::~DummyAudioCodec() { +} + +int DummyAudioCodec::Read(int16_t* dest, int samples) { + return 0; +} + +int DummyAudioCodec::Write(const int16_t* data, int samples) { + return 0; +} diff --git a/main/audio/codecs/dummy_audio_codec.h b/main/audio/codecs/dummy_audio_codec.h new file mode 100644 index 0000000..158f140 --- /dev/null +++ b/main/audio/codecs/dummy_audio_codec.h @@ -0,0 +1,16 @@ +#ifndef _DUMMY_AUDIO_CODEC_H +#define _DUMMY_AUDIO_CODEC_H + +#include "audio_codec.h" + +class DummyAudioCodec : public AudioCodec { +private: + virtual int Read(int16_t* dest, int samples) override; + virtual int Write(const int16_t* data, int samples) override; + +public: + DummyAudioCodec(int input_sample_rate, int output_sample_rate); + virtual ~DummyAudioCodec(); +}; + +#endif // _DUMMY_AUDIO_CODEC_H \ No newline at end of file diff --git a/main/audio/codecs/es8311_audio_codec.cc b/main/audio/codecs/es8311_audio_codec.cc new file mode 100644 index 0000000..708b7da --- /dev/null +++ b/main/audio/codecs/es8311_audio_codec.cc @@ -0,0 +1,199 @@ +#include "es8311_audio_codec.h" + +#include + +#define TAG "Es8311AudioCodec" + +Es8311AudioCodec::Es8311AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port, int input_sample_rate, int output_sample_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din, + gpio_num_t pa_pin, uint8_t es8311_addr, bool use_mclk, bool pa_inverted) { + duplex_ = true; // 是否双工 + input_reference_ = false; // 是否使用参考输入,实现回声消除 + input_channels_ = 1; // 输入通道数 + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; + pa_pin_ = pa_pin; + pa_inverted_ = pa_inverted; + input_gain_ = 30; + + assert(input_sample_rate_ == output_sample_rate_); + CreateDuplexChannels(mclk, bclk, ws, dout, din); + + // Do initialize of related interface: data_if, ctrl_if and gpio_if + audio_codec_i2s_cfg_t i2s_cfg = { + .port = I2S_NUM_0, + .rx_handle = rx_handle_, + .tx_handle = tx_handle_, + }; + data_if_ = audio_codec_new_i2s_data(&i2s_cfg); + assert(data_if_ != NULL); + + // Output + audio_codec_i2c_cfg_t i2c_cfg = { + .port = i2c_port, + .addr = es8311_addr, + .bus_handle = i2c_master_handle, + }; + ctrl_if_ = audio_codec_new_i2c_ctrl(&i2c_cfg); + assert(ctrl_if_ != NULL); + + gpio_if_ = audio_codec_new_gpio(); + assert(gpio_if_ != NULL); + + es8311_codec_cfg_t es8311_cfg = {}; + es8311_cfg.ctrl_if = ctrl_if_; + es8311_cfg.gpio_if = gpio_if_; + es8311_cfg.codec_mode = ESP_CODEC_DEV_WORK_MODE_BOTH; + es8311_cfg.pa_pin = pa_pin; + es8311_cfg.use_mclk = use_mclk; + es8311_cfg.hw_gain.pa_voltage = 5.0; + es8311_cfg.hw_gain.codec_dac_voltage = 3.3; + es8311_cfg.pa_reverted = pa_inverted_; + codec_if_ = es8311_codec_new(&es8311_cfg); + + if (codec_if_ == nullptr) { + ESP_LOGE(TAG, "Failed to create Es8311AudioCodec"); + } else { + ESP_LOGI(TAG, "Es8311AudioCodec initialized"); + } +} + +Es8311AudioCodec::~Es8311AudioCodec() { + esp_codec_dev_delete(dev_); + + audio_codec_delete_codec_if(codec_if_); + audio_codec_delete_ctrl_if(ctrl_if_); + audio_codec_delete_gpio_if(gpio_if_); + audio_codec_delete_data_if(data_if_); +} + +void Es8311AudioCodec::UpdateDeviceState() { + if ((input_enabled_ || output_enabled_) && dev_ == nullptr) { + esp_codec_dev_cfg_t dev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_IN_OUT, + .codec_if = codec_if_, + .data_if = data_if_, + }; + dev_ = esp_codec_dev_new(&dev_cfg); + assert(dev_ != NULL); + + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 1, + .channel_mask = 0, + .sample_rate = (uint32_t)input_sample_rate_, + .mclk_multiple = 0, + }; + ESP_ERROR_CHECK(esp_codec_dev_open(dev_, &fs)); + ESP_ERROR_CHECK(esp_codec_dev_set_in_gain(dev_, input_gain_)); + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(dev_, output_volume_)); + } else if (!input_enabled_ && !output_enabled_ && dev_ != nullptr) { + esp_codec_dev_close(dev_); + dev_ = nullptr; + } + if (pa_pin_ != GPIO_NUM_NC) { + int level = output_enabled_ ? 1 : 0; + gpio_set_level(pa_pin_, pa_inverted_ ? !level : level); + } +} + +void Es8311AudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din) { + assert(input_sample_rate_ == output_sample_rate_); + + i2s_chan_config_t chan_cfg = { + .id = I2S_NUM_0, + .role = I2S_ROLE_MASTER, + .dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM, + .dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM, + .auto_clear_after_cb = true, + .auto_clear_before_cb = false, + .intr_priority = 0, + }; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle_, &rx_handle_)); + + i2s_std_config_t std_cfg = { + .clk_cfg = { + .sample_rate_hz = (uint32_t)output_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_256, + #ifdef I2S_HW_VERSION_2 + .ext_clk_freq_hz = 0, + #endif + }, + .slot_cfg = { + .data_bit_width = I2S_DATA_BIT_WIDTH_16BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_STEREO, + .slot_mask = I2S_STD_SLOT_BOTH, + .ws_width = I2S_DATA_BIT_WIDTH_16BIT, + .ws_pol = false, + .bit_shift = true, + #ifdef I2S_HW_VERSION_2 + .left_align = true, + .big_endian = false, + .bit_order_lsb = false + #endif + }, + .gpio_cfg = { + .mclk = mclk, + .bclk = bclk, + .ws = ws, + .dout = dout, + .din = din, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false + } + } + }; + + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_)); + ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_)); + ESP_LOGI(TAG, "Duplex channels created"); +} + +void Es8311AudioCodec::SetOutputVolume(int volume) { + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(dev_, volume)); + AudioCodec::SetOutputVolume(volume); +} + +void Es8311AudioCodec::EnableInput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (codec_if_ == nullptr) { + return; + } + if (enable == input_enabled_) { + return; + } + AudioCodec::EnableInput(enable); + UpdateDeviceState(); +} + +void Es8311AudioCodec::EnableOutput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (codec_if_ == nullptr) { + return; + } + if (enable == output_enabled_) { + return; + } + AudioCodec::EnableOutput(enable); + UpdateDeviceState(); +} + +int Es8311AudioCodec::Read(int16_t* dest, int samples) { + if (input_enabled_) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_read(dev_, (void*)dest, samples * sizeof(int16_t))); + } + return samples; +} + +int Es8311AudioCodec::Write(const int16_t* data, int samples) { + if (output_enabled_) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_write(dev_, (void*)data, samples * sizeof(int16_t))); + } + return samples; +} \ No newline at end of file diff --git a/main/audio/codecs/es8311_audio_codec.h b/main/audio/codecs/es8311_audio_codec.h new file mode 100644 index 0000000..dd0e639 --- /dev/null +++ b/main/audio/codecs/es8311_audio_codec.h @@ -0,0 +1,42 @@ +#ifndef _ES8311_AUDIO_CODEC_H +#define _ES8311_AUDIO_CODEC_H + +#include "audio_codec.h" + +#include +#include +#include +#include +#include + + +class Es8311AudioCodec : public AudioCodec { +private: + const audio_codec_data_if_t* data_if_ = nullptr; + const audio_codec_ctrl_if_t* ctrl_if_ = nullptr; + const audio_codec_if_t* codec_if_ = nullptr; + const audio_codec_gpio_if_t* gpio_if_ = nullptr; + + esp_codec_dev_handle_t dev_ = nullptr; + gpio_num_t pa_pin_ = GPIO_NUM_NC; + bool pa_inverted_ = false; + std::mutex data_if_mutex_; + + void CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din); + void UpdateDeviceState(); + + virtual int Read(int16_t* dest, int samples) override; + virtual int Write(const int16_t* data, int samples) override; + +public: + Es8311AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port, int input_sample_rate, int output_sample_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din, + gpio_num_t pa_pin, uint8_t es8311_addr, bool use_mclk = true, bool pa_inverted = false); + virtual ~Es8311AudioCodec(); + + virtual void SetOutputVolume(int volume) override; + virtual void EnableInput(bool enable) override; + virtual void EnableOutput(bool enable) override; +}; + +#endif // _ES8311_AUDIO_CODEC_H \ No newline at end of file diff --git a/main/audio/codecs/es8374_audio_codec.cc b/main/audio/codecs/es8374_audio_codec.cc new file mode 100644 index 0000000..699e777 --- /dev/null +++ b/main/audio/codecs/es8374_audio_codec.cc @@ -0,0 +1,200 @@ +#include "es8374_audio_codec.h" + +#include + +#define TAG "Es8374AudioCodec" + +Es8374AudioCodec::Es8374AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port, int input_sample_rate, int output_sample_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din, + gpio_num_t pa_pin, uint8_t es8374_addr, bool use_mclk) { + duplex_ = true; // 是否双工 + input_reference_ = false; // 是否使用参考输入,实现回声消除 + input_channels_ = 1; // 输入通道数 + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; + input_gain_ = 30; + + pa_pin_ = pa_pin; + CreateDuplexChannels(mclk, bclk, ws, dout, din); + + // Do initialize of related interface: data_if, ctrl_if and gpio_if + audio_codec_i2s_cfg_t i2s_cfg = { + .port = I2S_NUM_0, + .rx_handle = rx_handle_, + .tx_handle = tx_handle_, + }; + data_if_ = audio_codec_new_i2s_data(&i2s_cfg); + assert(data_if_ != NULL); + + // Output + audio_codec_i2c_cfg_t i2c_cfg = { + .port = i2c_port, + .addr = es8374_addr, + .bus_handle = i2c_master_handle, + }; + ctrl_if_ = audio_codec_new_i2c_ctrl(&i2c_cfg); + assert(ctrl_if_ != NULL); + + gpio_if_ = audio_codec_new_gpio(); + assert(gpio_if_ != NULL); + + es8374_codec_cfg_t es8374_cfg = {}; + es8374_cfg.ctrl_if = ctrl_if_; + es8374_cfg.gpio_if = gpio_if_; + es8374_cfg.codec_mode = ESP_CODEC_DEV_WORK_MODE_BOTH; + es8374_cfg.pa_pin = pa_pin; + codec_if_ = es8374_codec_new(&es8374_cfg); + assert(codec_if_ != NULL); + + esp_codec_dev_cfg_t dev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_OUT, + .codec_if = codec_if_, + .data_if = data_if_, + }; + output_dev_ = esp_codec_dev_new(&dev_cfg); + assert(output_dev_ != NULL); + dev_cfg.dev_type = ESP_CODEC_DEV_TYPE_IN; + input_dev_ = esp_codec_dev_new(&dev_cfg); + assert(input_dev_ != NULL); + esp_codec_set_disable_when_closed(output_dev_, false); + esp_codec_set_disable_when_closed(input_dev_, false); + ESP_LOGI(TAG, "Es8374AudioCodec initialized"); +} + +Es8374AudioCodec::~Es8374AudioCodec() { + ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_)); + esp_codec_dev_delete(output_dev_); + ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_)); + esp_codec_dev_delete(input_dev_); + + audio_codec_delete_codec_if(codec_if_); + audio_codec_delete_ctrl_if(ctrl_if_); + audio_codec_delete_gpio_if(gpio_if_); + audio_codec_delete_data_if(data_if_); +} + +void Es8374AudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din) { + assert(input_sample_rate_ == output_sample_rate_); + + i2s_chan_config_t chan_cfg = { + .id = I2S_NUM_0, + .role = I2S_ROLE_MASTER, + .dma_desc_num = 6, + .dma_frame_num = 240, + .auto_clear_after_cb = true, + .auto_clear_before_cb = false, + .intr_priority = 0, + }; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle_, &rx_handle_)); + + i2s_std_config_t std_cfg = { + .clk_cfg = { + .sample_rate_hz = (uint32_t)output_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_256, + #ifdef I2S_HW_VERSION_2 + .ext_clk_freq_hz = 0, + #endif + }, + .slot_cfg = { + .data_bit_width = I2S_DATA_BIT_WIDTH_16BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_STEREO, + .slot_mask = I2S_STD_SLOT_BOTH, + .ws_width = I2S_DATA_BIT_WIDTH_16BIT, + .ws_pol = false, + .bit_shift = true, + #ifdef I2S_HW_VERSION_2 + .left_align = true, + .big_endian = false, + .bit_order_lsb = false + #endif + }, + .gpio_cfg = { + .mclk = mclk, + .bclk = bclk, + .ws = ws, + .dout = dout, + .din = din, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false + } + } + }; + + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_)); + ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_)); + ESP_LOGI(TAG, "Duplex channels created"); +} + +void Es8374AudioCodec::SetOutputVolume(int volume) { + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, volume)); + AudioCodec::SetOutputVolume(volume); +} + +void Es8374AudioCodec::EnableInput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (enable == input_enabled_) { + return; + } + if (enable) { + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 1, + .channel_mask = 0, + .sample_rate = (uint32_t)input_sample_rate_, + .mclk_multiple = 0, + }; + ESP_ERROR_CHECK(esp_codec_dev_open(input_dev_, &fs)); + ESP_ERROR_CHECK(esp_codec_dev_set_in_gain(input_dev_, input_gain_)); + } else { + ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_)); + } + AudioCodec::EnableInput(enable); +} + +void Es8374AudioCodec::EnableOutput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (enable == output_enabled_) { + return; + } + if (enable) { + // Play 16bit 1 channel + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 1, + .channel_mask = 0, + .sample_rate = (uint32_t)output_sample_rate_, + .mclk_multiple = 0, + }; + ESP_ERROR_CHECK(esp_codec_dev_open(output_dev_, &fs)); + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, output_volume_)); + if (pa_pin_ != GPIO_NUM_NC) { + gpio_set_level(pa_pin_, 1); + } + } else { + ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_)); + if (pa_pin_ != GPIO_NUM_NC) { + gpio_set_level(pa_pin_, 0); + } + } + AudioCodec::EnableOutput(enable); +} + +int Es8374AudioCodec::Read(int16_t* dest, int samples) { + if (input_enabled_) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_read(input_dev_, (void*)dest, samples * sizeof(int16_t))); + } + return samples; +} + +int Es8374AudioCodec::Write(const int16_t* data, int samples) { + if (output_enabled_) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_write(output_dev_, (void*)data, samples * sizeof(int16_t))); + } + return samples; +} \ No newline at end of file diff --git a/main/audio/codecs/es8374_audio_codec.h b/main/audio/codecs/es8374_audio_codec.h new file mode 100644 index 0000000..7533c22 --- /dev/null +++ b/main/audio/codecs/es8374_audio_codec.h @@ -0,0 +1,41 @@ +#ifndef _ES8374_AUDIO_CODEC_H +#define _ES8374_AUDIO_CODEC_H + +#include "audio_codec.h" + +#include +#include +#include +#include +#include + + +class Es8374AudioCodec : public AudioCodec { +private: + const audio_codec_data_if_t* data_if_ = nullptr; + const audio_codec_ctrl_if_t* ctrl_if_ = nullptr; + const audio_codec_if_t* codec_if_ = nullptr; + const audio_codec_gpio_if_t* gpio_if_ = nullptr; + + esp_codec_dev_handle_t output_dev_ = nullptr; + esp_codec_dev_handle_t input_dev_ = nullptr; + gpio_num_t pa_pin_ = GPIO_NUM_NC; + std::mutex data_if_mutex_; + + void CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din); + + virtual int Read(int16_t* dest, int samples) override; + virtual int Write(const int16_t* data, int samples) override; + +public: + Es8374AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port, int input_sample_rate, int output_sample_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din, + gpio_num_t pa_pin, uint8_t es8374_addr, bool use_mclk = true); + virtual ~Es8374AudioCodec(); + + virtual void SetOutputVolume(int volume) override; + virtual void EnableInput(bool enable) override; + virtual void EnableOutput(bool enable) override; +}; + +#endif // _ES8374_AUDIO_CODEC_H \ No newline at end of file diff --git a/main/audio/codecs/es8388_audio_codec.cc b/main/audio/codecs/es8388_audio_codec.cc new file mode 100644 index 0000000..821db78 --- /dev/null +++ b/main/audio/codecs/es8388_audio_codec.cc @@ -0,0 +1,220 @@ +#include "es8388_audio_codec.h" + +#include + +#define TAG "Es8388AudioCodec" + +Es8388AudioCodec::Es8388AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port, int input_sample_rate, int output_sample_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din, + gpio_num_t pa_pin, uint8_t es8388_addr, bool input_reference) { + duplex_ = true; // 是否双工 + input_reference_ = input_reference; // 是否使用参考输入,实现回声消除 + input_channels_ = input_reference_ ? 2 : 1; // 输入通道数 + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; + input_gain_ = 24; + + pa_pin_ = pa_pin; + CreateDuplexChannels(mclk, bclk, ws, dout, din); + + // Do initialize of related interface: data_if, ctrl_if and gpio_if + audio_codec_i2s_cfg_t i2s_cfg = { + .port = I2S_NUM_0, + .rx_handle = rx_handle_, + .tx_handle = tx_handle_, + }; + data_if_ = audio_codec_new_i2s_data(&i2s_cfg); + assert(data_if_ != NULL); + + // Output + audio_codec_i2c_cfg_t i2c_cfg = { + .port = i2c_port, + .addr = es8388_addr, + .bus_handle = i2c_master_handle, + }; + ctrl_if_ = audio_codec_new_i2c_ctrl(&i2c_cfg); + assert(ctrl_if_ != NULL); + + gpio_if_ = audio_codec_new_gpio(); + assert(gpio_if_ != NULL); + + es8388_codec_cfg_t es8388_cfg = {}; + es8388_cfg.ctrl_if = ctrl_if_; + es8388_cfg.gpio_if = gpio_if_; + es8388_cfg.codec_mode = ESP_CODEC_DEV_WORK_MODE_BOTH; + es8388_cfg.master_mode = true; + es8388_cfg.pa_pin = pa_pin; + es8388_cfg.pa_reverted = false; + es8388_cfg.hw_gain.pa_voltage = 5.0; + es8388_cfg.hw_gain.codec_dac_voltage = 3.3; + codec_if_ = es8388_codec_new(&es8388_cfg); + assert(codec_if_ != NULL); + + esp_codec_dev_cfg_t outdev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_OUT, + .codec_if = codec_if_, + .data_if = data_if_, + }; + output_dev_ = esp_codec_dev_new(&outdev_cfg); + assert(output_dev_ != NULL); + + esp_codec_dev_cfg_t indev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_IN, + .codec_if = codec_if_, + .data_if = data_if_, + }; + input_dev_ = esp_codec_dev_new(&indev_cfg); + assert(input_dev_ != NULL); + esp_codec_set_disable_when_closed(output_dev_, false); + esp_codec_set_disable_when_closed(input_dev_, false); + ESP_LOGI(TAG, "Es8388AudioCodec initialized"); +} + +Es8388AudioCodec::~Es8388AudioCodec() { + ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_)); + esp_codec_dev_delete(output_dev_); + ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_)); + esp_codec_dev_delete(input_dev_); + + audio_codec_delete_codec_if(codec_if_); + audio_codec_delete_ctrl_if(ctrl_if_); + audio_codec_delete_gpio_if(gpio_if_); + audio_codec_delete_data_if(data_if_); +} + +void Es8388AudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din){ + assert(input_sample_rate_ == output_sample_rate_); + + i2s_chan_config_t chan_cfg = { + .id = I2S_NUM_0, + .role = I2S_ROLE_MASTER, + .dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM, + .dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM, + .auto_clear_after_cb = true, + .auto_clear_before_cb = false, + .intr_priority = 0, + }; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle_, &rx_handle_)); + + i2s_std_config_t std_cfg = { + .clk_cfg = { + .sample_rate_hz = (uint32_t)output_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .ext_clk_freq_hz = 0, + .mclk_multiple = I2S_MCLK_MULTIPLE_256 + }, + .slot_cfg = { + .data_bit_width = I2S_DATA_BIT_WIDTH_16BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_STEREO, + .slot_mask = I2S_STD_SLOT_BOTH, + .ws_width = I2S_DATA_BIT_WIDTH_16BIT, + .ws_pol = false, + .bit_shift = true, + .left_align = true, + .big_endian = false, + .bit_order_lsb = false + }, + .gpio_cfg = { + .mclk = mclk, + .bclk = bclk, + .ws = ws, + .dout = dout, + .din = din, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false + } + } + }; + + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_)); + ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_)); + ESP_LOGI(TAG, "Duplex channels created"); +} + +void Es8388AudioCodec::SetOutputVolume(int volume) { + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, volume)); + AudioCodec::SetOutputVolume(volume); +} + +void Es8388AudioCodec::EnableInput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (enable == input_enabled_) { + return; + } + if (enable) { + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = (uint8_t) input_channels_, + .channel_mask = ESP_CODEC_DEV_MAKE_CHANNEL_MASK(0), + .sample_rate = (uint32_t)input_sample_rate_, + .mclk_multiple = 0, + }; + if (input_reference_) { + fs.channel_mask |= ESP_CODEC_DEV_MAKE_CHANNEL_MASK(1); + } + ESP_ERROR_CHECK(esp_codec_dev_open(input_dev_, &fs)); + if (input_reference_) { + uint8_t gain = (11 << 4) + 0; + ctrl_if_->write_reg(ctrl_if_, 0x09, 1, &gain, 1); + }else{ + ESP_ERROR_CHECK(esp_codec_dev_set_in_gain(input_dev_, input_gain_)); + } + } else { + ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_)); + } + AudioCodec::EnableInput(enable); +} + +void Es8388AudioCodec::EnableOutput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (enable == output_enabled_) { + return; + } + if (enable) { + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 1, + .channel_mask = 0, + .sample_rate = (uint32_t)output_sample_rate_, + .mclk_multiple = 0, + }; + ESP_ERROR_CHECK(esp_codec_dev_open(output_dev_, &fs)); + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, output_volume_)); + + // Set analog output volume to 0dB, default is -45dB + uint8_t reg_val = 30; // 0dB + uint8_t regs[] = { 46, 47, 48, 49 }; // HP_LVOL, HP_RVOL, SPK_LVOL, SPK_RVOL + for (uint8_t reg : regs) { + ctrl_if_->write_reg(ctrl_if_, reg, 1, ®_val, 1); + } + + if (pa_pin_ != GPIO_NUM_NC) { + gpio_set_level(pa_pin_, 1); + } + } else { + ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_)); + if (pa_pin_ != GPIO_NUM_NC) { + gpio_set_level(pa_pin_, 0); + } + } + AudioCodec::EnableOutput(enable); +} + +int Es8388AudioCodec::Read(int16_t* dest, int samples) { + if (input_enabled_) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_read(input_dev_, (void*)dest, samples * sizeof(int16_t))); + } + return samples; +} + +int Es8388AudioCodec::Write(const int16_t* data, int samples) { + if (output_enabled_ && output_dev_ && data != nullptr) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_write(output_dev_, (void*)data, samples * sizeof(int16_t))); + } + return samples; +} diff --git a/main/audio/codecs/es8388_audio_codec.h b/main/audio/codecs/es8388_audio_codec.h new file mode 100644 index 0000000..316dfce --- /dev/null +++ b/main/audio/codecs/es8388_audio_codec.h @@ -0,0 +1,40 @@ +#ifndef _ES8388_AUDIO_CODEC_H +#define _ES8388_AUDIO_CODEC_H + +#include "audio_codec.h" + +#include +#include +#include +#include + + +class Es8388AudioCodec : public AudioCodec { +private: + const audio_codec_data_if_t* data_if_ = nullptr; + const audio_codec_ctrl_if_t* ctrl_if_ = nullptr; + const audio_codec_if_t* codec_if_ = nullptr; + const audio_codec_gpio_if_t* gpio_if_ = nullptr; + + esp_codec_dev_handle_t output_dev_ = nullptr; + esp_codec_dev_handle_t input_dev_ = nullptr; + gpio_num_t pa_pin_ = GPIO_NUM_NC; + std::mutex data_if_mutex_; + + void CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din); + + virtual int Read(int16_t* dest, int samples) override; + virtual int Write(const int16_t* data, int samples) override; + +public: + Es8388AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port, int input_sample_rate, int output_sample_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din, + gpio_num_t pa_pin, uint8_t es8388_addr, bool input_reference = false); + virtual ~Es8388AudioCodec(); + + virtual void SetOutputVolume(int volume) override; + virtual void EnableInput(bool enable) override; + virtual void EnableOutput(bool enable) override; +}; + +#endif // _ES8388_AUDIO_CODEC_H diff --git a/main/audio/codecs/es8389_audio_codec.cc b/main/audio/codecs/es8389_audio_codec.cc new file mode 100644 index 0000000..48820f3 --- /dev/null +++ b/main/audio/codecs/es8389_audio_codec.cc @@ -0,0 +1,206 @@ +#include "es8389_audio_codec.h" + +#include + +static const char TAG[] = "Es8389AudioCodec"; + +Es8389AudioCodec::Es8389AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port, int input_sample_rate, int output_sample_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din, + gpio_num_t pa_pin, uint8_t es8389_addr, bool use_mclk) { + duplex_ = true; // 是否双工 + input_reference_ = false; // 是否使用参考输入,实现回声消除 + input_channels_ = 1; // 输入通道数 + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; + input_gain_ = 40; + pa_pin_ = pa_pin; + CreateDuplexChannels(mclk, bclk, ws, dout, din); + + // Do initialize of related interface: data_if, ctrl_if and gpio_if + audio_codec_i2s_cfg_t i2s_cfg = { + .port = I2S_NUM_0, + .rx_handle = rx_handle_, + .tx_handle = tx_handle_, + }; + data_if_ = audio_codec_new_i2s_data(&i2s_cfg); + assert(data_if_ != NULL); + + // Output + audio_codec_i2c_cfg_t i2c_cfg = { + .port = i2c_port, + .addr = es8389_addr, + .bus_handle = i2c_master_handle, + }; + ctrl_if_ = audio_codec_new_i2c_ctrl(&i2c_cfg); + assert(ctrl_if_ != NULL); + + gpio_if_ = audio_codec_new_gpio(); + assert(gpio_if_ != NULL); + + es8389_codec_cfg_t es8389_cfg = {}; + es8389_cfg.ctrl_if = ctrl_if_; + es8389_cfg.gpio_if = gpio_if_; + es8389_cfg.codec_mode = ESP_CODEC_DEV_WORK_MODE_BOTH; + es8389_cfg.pa_pin = pa_pin; + es8389_cfg.use_mclk = use_mclk; + es8389_cfg.hw_gain.pa_voltage = 5.0; + es8389_cfg.hw_gain.codec_dac_voltage = 3.3; + codec_if_ = es8389_codec_new(&es8389_cfg); + + assert(codec_if_ != NULL); + + esp_codec_dev_cfg_t outdev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_OUT, + .codec_if = codec_if_, + .data_if = data_if_, + }; + output_dev_ = esp_codec_dev_new(&outdev_cfg); + assert(output_dev_ != NULL); + + esp_codec_dev_cfg_t indev_cfg = { + .dev_type = ESP_CODEC_DEV_TYPE_IN, + .codec_if = codec_if_, + .data_if = data_if_, + }; + input_dev_ = esp_codec_dev_new(&indev_cfg); + assert(input_dev_ != NULL); + esp_codec_set_disable_when_closed(output_dev_, false); + esp_codec_set_disable_when_closed(input_dev_, false); + ESP_LOGI(TAG, "Es8389AudioCodec initialized"); +} + +Es8389AudioCodec::~Es8389AudioCodec() { + ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_)); + esp_codec_dev_delete(output_dev_); + ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_)); + esp_codec_dev_delete(input_dev_); + + audio_codec_delete_codec_if(codec_if_); + audio_codec_delete_ctrl_if(ctrl_if_); + audio_codec_delete_gpio_if(gpio_if_); + audio_codec_delete_data_if(data_if_); +} + +void Es8389AudioCodec::CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din) { + assert(input_sample_rate_ == output_sample_rate_); + + i2s_chan_config_t chan_cfg = { + .id = I2S_NUM_0, + .role = I2S_ROLE_MASTER, + .dma_desc_num = 6, + .dma_frame_num = 240, + .auto_clear_after_cb = true, + .auto_clear_before_cb = false, + .intr_priority = 0, + }; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle_, &rx_handle_)); + + i2s_std_config_t std_cfg = { + .clk_cfg = { + .sample_rate_hz = (uint32_t)output_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_256, +#ifdef I2S_HW_VERSION_2 + .ext_clk_freq_hz = 0, +#endif + }, + .slot_cfg = { + .data_bit_width = I2S_DATA_BIT_WIDTH_16BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_STEREO, + .slot_mask = I2S_STD_SLOT_BOTH, + .ws_width = I2S_DATA_BIT_WIDTH_16BIT, + .ws_pol = false, + .bit_shift = true, + .left_align = true, + .big_endian = false, + .bit_order_lsb = false + }, + .gpio_cfg = { + .mclk = mclk, + .bclk = bclk, + .ws = ws, + .dout = dout, + .din = din, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false + } + } + }; + + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_)); + ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_)); + ESP_LOGI(TAG, "Duplex channels created"); +} + +void Es8389AudioCodec::SetOutputVolume(int volume) { + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, volume)); + AudioCodec::SetOutputVolume(volume); +} + +void Es8389AudioCodec::EnableInput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (enable == input_enabled_) { + return; + } + if (enable) { + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 1, + .channel_mask = 0, + .sample_rate = (uint32_t)input_sample_rate_, + .mclk_multiple = 0, + }; + ESP_ERROR_CHECK(esp_codec_dev_open(input_dev_, &fs)); + ESP_ERROR_CHECK(esp_codec_dev_set_in_gain(input_dev_, input_gain_)); + } else { + ESP_ERROR_CHECK(esp_codec_dev_close(input_dev_)); + } + AudioCodec::EnableInput(enable); +} + +void Es8389AudioCodec::EnableOutput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (enable == output_enabled_) { + return; + } + if (enable) { + // Play 16bit 1 channel + esp_codec_dev_sample_info_t fs = { + .bits_per_sample = 16, + .channel = 1, + .channel_mask = 0, + .sample_rate = (uint32_t)output_sample_rate_, + .mclk_multiple = 0, + }; + ESP_ERROR_CHECK(esp_codec_dev_open(output_dev_, &fs)); + ESP_ERROR_CHECK(esp_codec_dev_set_out_vol(output_dev_, output_volume_)); + if (pa_pin_ != GPIO_NUM_NC) { + gpio_set_level(pa_pin_, 1); + } + } else { + ESP_ERROR_CHECK(esp_codec_dev_close(output_dev_)); + if (pa_pin_ != GPIO_NUM_NC) { + gpio_set_level(pa_pin_, 0); + } + } + AudioCodec::EnableOutput(enable); +} + +int Es8389AudioCodec::Read(int16_t* dest, int samples) { + if (input_enabled_) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_read(input_dev_, (void*)dest, samples * sizeof(int16_t))); + } + return samples; +} + +int Es8389AudioCodec::Write(const int16_t* data, int samples) { + if (output_enabled_) { + ESP_ERROR_CHECK_WITHOUT_ABORT(esp_codec_dev_write(output_dev_, (void*)data, samples * sizeof(int16_t))); + } + return samples; +} \ No newline at end of file diff --git a/main/audio/codecs/es8389_audio_codec.h b/main/audio/codecs/es8389_audio_codec.h new file mode 100644 index 0000000..b55b427 --- /dev/null +++ b/main/audio/codecs/es8389_audio_codec.h @@ -0,0 +1,40 @@ +#ifndef _ES8389_AUDIO_CODEC_H +#define _ES8389_AUDIO_CODEC_H + +#include "audio_codec.h" + +#include +#include +#include +#include +#include + +class Es8389AudioCodec : public AudioCodec { +private: + const audio_codec_data_if_t* data_if_ = nullptr; + const audio_codec_ctrl_if_t* ctrl_if_ = nullptr; + const audio_codec_if_t* codec_if_ = nullptr; + const audio_codec_gpio_if_t* gpio_if_ = nullptr; + + esp_codec_dev_handle_t output_dev_ = nullptr; + esp_codec_dev_handle_t input_dev_ = nullptr; + gpio_num_t pa_pin_ = GPIO_NUM_NC; + std::mutex data_if_mutex_; + + void CreateDuplexChannels(gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din); + + virtual int Read(int16_t* dest, int samples) override; + virtual int Write(const int16_t* data, int samples) override; + +public: + Es8389AudioCodec(void* i2c_master_handle, i2c_port_t i2c_port, int input_sample_rate, int output_sample_rate, + gpio_num_t mclk, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din, + gpio_num_t pa_pin, uint8_t es8389_addr, bool use_mclk = true); + virtual ~Es8389AudioCodec(); + + virtual void SetOutputVolume(int volume) override; + virtual void EnableInput(bool enable) override; + virtual void EnableOutput(bool enable) override; +}; + +#endif // _ES8389_AUDIO_CODEC_H diff --git a/main/audio/codecs/no_audio_codec.cc b/main/audio/codecs/no_audio_codec.cc new file mode 100644 index 0000000..791b0f9 --- /dev/null +++ b/main/audio/codecs/no_audio_codec.cc @@ -0,0 +1,385 @@ +#include "no_audio_codec.h" + +#include +#include +#include + +#define TAG "NoAudioCodec" + +NoAudioCodec::~NoAudioCodec() { + if (rx_handle_ != nullptr) { + ESP_ERROR_CHECK(i2s_channel_disable(rx_handle_)); + } + if (tx_handle_ != nullptr) { + ESP_ERROR_CHECK(i2s_channel_disable(tx_handle_)); + } +} + +NoAudioCodecDuplex::NoAudioCodecDuplex(int input_sample_rate, int output_sample_rate, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din) { + duplex_ = true; + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; + + i2s_chan_config_t chan_cfg = { + .id = I2S_NUM_0, + .role = I2S_ROLE_MASTER, + .dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM, + .dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM, + .auto_clear_after_cb = true, + .auto_clear_before_cb = false, + .intr_priority = 0, + }; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle_, &rx_handle_)); + + i2s_std_config_t std_cfg = { + .clk_cfg = { + .sample_rate_hz = (uint32_t)output_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_256, + #ifdef I2S_HW_VERSION_2 + .ext_clk_freq_hz = 0, + #endif + + }, + .slot_cfg = { + .data_bit_width = I2S_DATA_BIT_WIDTH_32BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_MONO, + .slot_mask = I2S_STD_SLOT_LEFT, + .ws_width = I2S_DATA_BIT_WIDTH_32BIT, + .ws_pol = false, + .bit_shift = true, + #ifdef I2S_HW_VERSION_2 + .left_align = true, + .big_endian = false, + .bit_order_lsb = false + #endif + + }, + .gpio_cfg = { + .mclk = I2S_GPIO_UNUSED, + .bclk = bclk, + .ws = ws, + .dout = dout, + .din = din, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false + } + } + }; + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg)); + ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg)); + ESP_LOGI(TAG, "Duplex channels created"); +} + + +NoAudioCodecSimplex::NoAudioCodecSimplex(int input_sample_rate, int output_sample_rate, gpio_num_t spk_bclk, gpio_num_t spk_ws, gpio_num_t spk_dout, gpio_num_t mic_sck, gpio_num_t mic_ws, gpio_num_t mic_din) { + duplex_ = false; + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; + + // Create a new channel for speaker + i2s_chan_config_t chan_cfg = { + .id = (i2s_port_t)0, + .role = I2S_ROLE_MASTER, + .dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM, + .dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM, + .auto_clear_after_cb = true, + .auto_clear_before_cb = false, + .intr_priority = 0, + }; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle_, nullptr)); + + i2s_std_config_t std_cfg = { + .clk_cfg = { + .sample_rate_hz = (uint32_t)output_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_256, + #ifdef I2S_HW_VERSION_2 + .ext_clk_freq_hz = 0, + #endif + + }, + .slot_cfg = { + .data_bit_width = I2S_DATA_BIT_WIDTH_32BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_MONO, + .slot_mask = I2S_STD_SLOT_LEFT, + .ws_width = I2S_DATA_BIT_WIDTH_32BIT, + .ws_pol = false, + .bit_shift = true, + #ifdef I2S_HW_VERSION_2 + .left_align = true, + .big_endian = false, + .bit_order_lsb = false + #endif + + }, + .gpio_cfg = { + .mclk = I2S_GPIO_UNUSED, + .bclk = spk_bclk, + .ws = spk_ws, + .dout = spk_dout, + .din = I2S_GPIO_UNUSED, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false + } + } + }; + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg)); + + // Create a new channel for MIC + chan_cfg.id = (i2s_port_t)1; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, nullptr, &rx_handle_)); + std_cfg.clk_cfg.sample_rate_hz = (uint32_t)input_sample_rate_; + std_cfg.gpio_cfg.bclk = mic_sck; + std_cfg.gpio_cfg.ws = mic_ws; + std_cfg.gpio_cfg.dout = I2S_GPIO_UNUSED; + std_cfg.gpio_cfg.din = mic_din; + ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg)); + ESP_LOGI(TAG, "Simplex channels created"); +} + +NoAudioCodecSimplex::NoAudioCodecSimplex(int input_sample_rate, int output_sample_rate, gpio_num_t spk_bclk, gpio_num_t spk_ws, gpio_num_t spk_dout, i2s_std_slot_mask_t spk_slot_mask, gpio_num_t mic_sck, gpio_num_t mic_ws, gpio_num_t mic_din, i2s_std_slot_mask_t mic_slot_mask){ + duplex_ = false; + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; + + // Create a new channel for speaker + i2s_chan_config_t chan_cfg = { + .id = (i2s_port_t)0, + .role = I2S_ROLE_MASTER, + .dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM, + .dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM, + .auto_clear_after_cb = true, + .auto_clear_before_cb = false, + .intr_priority = 0, + }; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, &tx_handle_, nullptr)); + + i2s_std_config_t std_cfg = { + .clk_cfg = { + .sample_rate_hz = (uint32_t)output_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_256, + #ifdef I2S_HW_VERSION_2 + .ext_clk_freq_hz = 0, + #endif + + }, + .slot_cfg = { + .data_bit_width = I2S_DATA_BIT_WIDTH_32BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_MONO, + .slot_mask = spk_slot_mask, + .ws_width = I2S_DATA_BIT_WIDTH_32BIT, + .ws_pol = false, + .bit_shift = true, + #ifdef I2S_HW_VERSION_2 + .left_align = true, + .big_endian = false, + .bit_order_lsb = false + #endif + + }, + .gpio_cfg = { + .mclk = I2S_GPIO_UNUSED, + .bclk = spk_bclk, + .ws = spk_ws, + .dout = spk_dout, + .din = I2S_GPIO_UNUSED, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false + } + } + }; + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &std_cfg)); + + // Create a new channel for MIC + chan_cfg.id = (i2s_port_t)1; + ESP_ERROR_CHECK(i2s_new_channel(&chan_cfg, nullptr, &rx_handle_)); + std_cfg.clk_cfg.sample_rate_hz = (uint32_t)input_sample_rate_; + std_cfg.slot_cfg.slot_mask = mic_slot_mask; + std_cfg.gpio_cfg.bclk = mic_sck; + std_cfg.gpio_cfg.ws = mic_ws; + std_cfg.gpio_cfg.dout = I2S_GPIO_UNUSED; + std_cfg.gpio_cfg.din = mic_din; + ESP_ERROR_CHECK(i2s_channel_init_std_mode(rx_handle_, &std_cfg)); + ESP_LOGI(TAG, "Simplex channels created"); +} + +int NoAudioCodec::Write(const int16_t* data, int samples) { + std::lock_guard lock(data_if_mutex_); + std::vector buffer(samples); + + // output_volume_: 0-100 + // volume_factor_: 0-65536 + int32_t volume_factor = pow(double(output_volume_) / 100.0, 2) * 65536; + for (int i = 0; i < samples; i++) { + int64_t temp = int64_t(data[i]) * volume_factor; // 使用 int64_t 进行乘法运算 + if (temp > INT32_MAX) { + buffer[i] = INT32_MAX; + } else if (temp < INT32_MIN) { + buffer[i] = INT32_MIN; + } else { + buffer[i] = static_cast(temp); + } + } + + size_t bytes_written; + ESP_ERROR_CHECK(i2s_channel_write(tx_handle_, buffer.data(), samples * sizeof(int32_t), &bytes_written, portMAX_DELAY)); + return bytes_written / sizeof(int32_t); +} + +int NoAudioCodec::Read(int16_t* dest, int samples) { + size_t bytes_read; + constexpr TickType_t kReadTimeoutTicks = pdMS_TO_TICKS(200); + + std::vector bit32_buffer(samples); + if (i2s_channel_read(rx_handle_, bit32_buffer.data(), samples * sizeof(int32_t), &bytes_read, kReadTimeoutTicks) != ESP_OK) { + return 0; + } + + samples = bytes_read / sizeof(int32_t); + for (int i = 0; i < samples; i++) { + int32_t value = bit32_buffer[i] >> 12; + dest[i] = (value > INT16_MAX) ? INT16_MAX : (value < -INT16_MAX) ? -INT16_MAX : (int16_t)value; + } + return samples; +} + +void NoAudioCodec::EnableInput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (enable == input_enabled_) { + return; + } + if (enable) { + ESP_ERROR_CHECK(i2s_channel_enable(rx_handle_)); + } else { + ESP_ERROR_CHECK(i2s_channel_disable(rx_handle_)); + } + AudioCodec::EnableInput(enable); +} + +void NoAudioCodec::EnableOutput(bool enable) { + std::lock_guard lock(data_if_mutex_); + if (enable == output_enabled_) { + return; + } + if (enable) { + ESP_ERROR_CHECK(i2s_channel_enable(tx_handle_)); + } else { + ESP_ERROR_CHECK(i2s_channel_disable(tx_handle_)); + } + AudioCodec::EnableOutput(enable); +} + +// Delegating constructor: calls the main constructor with default slot mask +NoAudioCodecSimplexPdm::NoAudioCodecSimplexPdm(int input_sample_rate, int output_sample_rate, gpio_num_t spk_bclk, gpio_num_t spk_ws, gpio_num_t spk_dout, gpio_num_t mic_sck, gpio_num_t mic_din) + : NoAudioCodecSimplexPdm(input_sample_rate, output_sample_rate, spk_bclk, spk_ws, spk_dout, I2S_STD_SLOT_LEFT, mic_sck, mic_din) { + // All initialization is handled by the delegated constructor +} + +NoAudioCodecSimplexPdm::NoAudioCodecSimplexPdm(int input_sample_rate, int output_sample_rate, gpio_num_t spk_bclk, gpio_num_t spk_ws, gpio_num_t spk_dout, i2s_std_slot_mask_t spk_slot_mask, gpio_num_t mic_sck, gpio_num_t mic_din) { + duplex_ = false; + input_sample_rate_ = input_sample_rate; + output_sample_rate_ = output_sample_rate; + + // Create a new channel for speaker + i2s_chan_config_t tx_chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG((i2s_port_t)1, I2S_ROLE_MASTER); + tx_chan_cfg.dma_desc_num = AUDIO_CODEC_DMA_DESC_NUM; + tx_chan_cfg.dma_frame_num = AUDIO_CODEC_DMA_FRAME_NUM; + tx_chan_cfg.auto_clear_after_cb = true; + tx_chan_cfg.auto_clear_before_cb = false; + tx_chan_cfg.intr_priority = 0; + ESP_ERROR_CHECK(i2s_new_channel(&tx_chan_cfg, &tx_handle_, NULL)); + + + i2s_std_config_t tx_std_cfg = { + .clk_cfg = { + .sample_rate_hz = (uint32_t)output_sample_rate_, + .clk_src = I2S_CLK_SRC_DEFAULT, + .mclk_multiple = I2S_MCLK_MULTIPLE_256, + #ifdef I2S_HW_VERSION_2 + .ext_clk_freq_hz = 0, + #endif + + }, + .slot_cfg = { + .data_bit_width = I2S_DATA_BIT_WIDTH_32BIT, + .slot_bit_width = I2S_SLOT_BIT_WIDTH_AUTO, + .slot_mode = I2S_SLOT_MODE_MONO, + .slot_mask = spk_slot_mask, + .ws_width = I2S_DATA_BIT_WIDTH_32BIT, + .ws_pol = false, + .bit_shift = true, + #ifdef I2S_HW_VERSION_2 + .left_align = true, + .big_endian = false, + .bit_order_lsb = false + #endif + + }, + .gpio_cfg = { + .mclk = I2S_GPIO_UNUSED, + .bclk = spk_bclk, + .ws = spk_ws, + .dout = spk_dout, + .din = I2S_GPIO_UNUSED, + .invert_flags = { + .mclk_inv = false, + .bclk_inv = false, + .ws_inv = false, + }, + }, + }; + ESP_ERROR_CHECK(i2s_channel_init_std_mode(tx_handle_, &tx_std_cfg)); +#if SOC_I2S_SUPPORTS_PDM_RX + // Create a new channel for MIC in PDM mode + i2s_chan_config_t rx_chan_cfg = I2S_CHANNEL_DEFAULT_CONFIG((i2s_port_t)0, I2S_ROLE_MASTER); + ESP_ERROR_CHECK(i2s_new_channel(&rx_chan_cfg, NULL, &rx_handle_)); + i2s_pdm_rx_config_t pdm_rx_cfg = { + .clk_cfg = I2S_PDM_RX_CLK_DEFAULT_CONFIG((uint32_t)input_sample_rate_), + /* The data bit-width of PDM mode is fixed to 16 */ + .slot_cfg = I2S_PDM_RX_SLOT_DEFAULT_CONFIG(I2S_DATA_BIT_WIDTH_16BIT, I2S_SLOT_MODE_MONO), + .gpio_cfg = { + .clk = mic_sck, + .din = mic_din, + + .invert_flags = { + .clk_inv = false, + }, + }, + }; + ESP_ERROR_CHECK(i2s_channel_init_pdm_rx_mode(rx_handle_, &pdm_rx_cfg)); +#else + ESP_LOGE(TAG, "PDM is not supported"); +#endif + ESP_LOGI(TAG, "Simplex channels created"); +} + +int NoAudioCodecSimplexPdm::Read(int16_t* dest, int samples) { + size_t bytes_read; + + // PDM 解调后的数据位宽为 16 位,直接读取到目标缓冲区 + if (i2s_channel_read(rx_handle_, dest, samples * sizeof(int16_t), &bytes_read, portMAX_DELAY) != ESP_OK) { + ESP_LOGE(TAG, "Read Failed!"); + return 0; + } + + samples = bytes_read / sizeof(int16_t); + if (input_gain_ > 0) { + int gain_factor = (int)input_gain_; + for (int i = 0; i < samples; i++) { + int32_t amplified = dest[i] * gain_factor; + dest[i] = (amplified > INT16_MAX) ? INT16_MAX : (amplified < -INT16_MAX) ? -INT16_MAX : (int16_t)amplified; + } + } + return samples; +} diff --git a/main/audio/codecs/no_audio_codec.h b/main/audio/codecs/no_audio_codec.h new file mode 100644 index 0000000..dec803f --- /dev/null +++ b/main/audio/codecs/no_audio_codec.h @@ -0,0 +1,41 @@ +#ifndef _NO_AUDIO_CODEC_H +#define _NO_AUDIO_CODEC_H + +#include "audio_codec.h" + +#include +#include +#include + +class NoAudioCodec : public AudioCodec { +protected: + std::mutex data_if_mutex_; + + virtual int Write(const int16_t* data, int samples) override; + virtual int Read(int16_t* dest, int samples) override; + virtual void EnableInput(bool enable) override; + virtual void EnableOutput(bool enable) override; + +public: + virtual ~NoAudioCodec(); +}; + +class NoAudioCodecDuplex : public NoAudioCodec { +public: + NoAudioCodecDuplex(int input_sample_rate, int output_sample_rate, gpio_num_t bclk, gpio_num_t ws, gpio_num_t dout, gpio_num_t din); +}; + +class NoAudioCodecSimplex : public NoAudioCodec { +public: + NoAudioCodecSimplex(int input_sample_rate, int output_sample_rate, gpio_num_t spk_bclk, gpio_num_t spk_ws, gpio_num_t spk_dout, gpio_num_t mic_sck, gpio_num_t mic_ws, gpio_num_t mic_din); + NoAudioCodecSimplex(int input_sample_rate, int output_sample_rate, gpio_num_t spk_bclk, gpio_num_t spk_ws, gpio_num_t spk_dout, i2s_std_slot_mask_t spk_slot_mask, gpio_num_t mic_sck, gpio_num_t mic_ws, gpio_num_t mic_din, i2s_std_slot_mask_t mic_slot_mask); +}; + +class NoAudioCodecSimplexPdm : public NoAudioCodec { +public: + NoAudioCodecSimplexPdm(int input_sample_rate, int output_sample_rate, gpio_num_t spk_bclk, gpio_num_t spk_ws, gpio_num_t spk_dout, gpio_num_t mic_sck, gpio_num_t mic_din); + NoAudioCodecSimplexPdm(int input_sample_rate, int output_sample_rate, gpio_num_t spk_bclk, gpio_num_t spk_ws, gpio_num_t spk_dout, i2s_std_slot_mask_t spk_slot_mask, gpio_num_t mic_sck, gpio_num_t mic_din); + int Read(int16_t* dest, int samples); +}; + +#endif // _NO_AUDIO_CODEC_H diff --git a/main/audio/demuxer/ogg_demuxer.cc b/main/audio/demuxer/ogg_demuxer.cc new file mode 100644 index 0000000..6b60876 --- /dev/null +++ b/main/audio/demuxer/ogg_demuxer.cc @@ -0,0 +1,311 @@ +#include "ogg_demuxer.h" +#include "esp_log.h" + +#define TAG "OggDemuxer" + +/// @brief 重置解封器 +void OggDemuxer::Reset() +{ + opus_info_ = { + .head_seen = false, + .tags_seen = false, + .sample_rate = 48000 + }; + + state_ = ParseState::FIND_PAGE; + ctx_.packet_len = 0; + ctx_.seg_count = 0; + ctx_.seg_index = 0; + ctx_.data_offset = 0; + ctx_.bytes_needed = 4; // 需要4字节"OggS" + ctx_.seg_remaining = 0; + ctx_.body_size = 0; + ctx_.body_offset = 0; + ctx_.packet_continued = false; + + // 清空缓冲区数据 + memset(ctx_.header, 0, sizeof(ctx_.header)); + memset(ctx_.seg_table, 0, sizeof(ctx_.seg_table)); + memset(ctx_.packet_buf, 0, sizeof(ctx_.packet_buf)); +} + +/// @brief 处理数据块 +/// @param data 输入数据 +/// @param size 输入数据大小 +/// @return 已处理的字节数 +size_t OggDemuxer::Process(const uint8_t* data, size_t size) +{ + size_t processed = 0; // 已处理的字节数 + + while (processed < size) { + switch (state_) { + case ParseState::FIND_PAGE: { + // 寻找页头"OggS" + if (ctx_.bytes_needed < 4) { + // 处理不完整的"OggS"匹配(跨数据块) + size_t to_copy = std::min(size - processed, ctx_.bytes_needed); + memcpy(ctx_.header + (4 - ctx_.bytes_needed), data + processed, to_copy); + + processed += to_copy; + ctx_.bytes_needed -= to_copy; + + if (ctx_.bytes_needed == 0) { + // 检查是否匹配"OggS" + if (memcmp(ctx_.header, "OggS", 4) == 0) { + state_ = ParseState::PARSE_HEADER; + ctx_.data_offset = 4; + ctx_.bytes_needed = 27 - 4; // 还需要23字节完成页头 + } else { + // 匹配失败,滑动1字节继续匹配 + memmove(ctx_.header, ctx_.header + 1, 3); + ctx_.bytes_needed = 1; + } + } else { + // 数据不足,等待更多数据 + return processed; + } + } else if (ctx_.bytes_needed == 4) { + // 在数据块中查找完整的"OggS" + bool found = false; + size_t i = 0; + size_t remaining = size - processed; + + // 搜索"OggS" + for (; i + 4 <= remaining; i++) { + if (memcmp(data + processed + i, "OggS", 4) == 0) { + found = true; + break; + } + } + + if (found) { + // 找到"OggS",跳过已搜索的字节 + processed += i; + + // 不记录找到的"OggS",无必要 + // memcpy(ctx_.header, data + processed, 4); + processed += 4; + + state_ = ParseState::PARSE_HEADER; + ctx_.data_offset = 4; + ctx_.bytes_needed = 27 - 4; // 还需要23字节 + } else { + // 没有找到完整"OggS",保存可能的部分匹配 + size_t partial_len = remaining - i; + if (partial_len > 0) { + memcpy(ctx_.header, data + processed + i, partial_len); + ctx_.bytes_needed = 4 - partial_len; + processed += i + partial_len; + } else { + processed += i; // 已搜索所有字节 + } + return processed; // 返回已处理的字节数 + } + } else { + ESP_LOGE(TAG, "OggDemuxer run in error state: bytes_needed=%zu", ctx_.bytes_needed); + Reset(); + return processed; + } + break; + } + + case ParseState::PARSE_HEADER: { + size_t available = size - processed; + + if (available < ctx_.bytes_needed) { + // 数据不足,复制可用的部分 + memcpy(ctx_.header + ctx_.data_offset, + data + processed, available); + + ctx_.data_offset += available; + ctx_.bytes_needed -= available; + processed += available; + return processed; // 等待更多数据 + } else { + // 有足够的数据完成页头 + size_t to_copy = ctx_.bytes_needed; + memcpy(ctx_.header + ctx_.data_offset, + data + processed, to_copy); + + processed += to_copy; + ctx_.data_offset += to_copy; + ctx_.bytes_needed = 0; + + // 验证页头 + if (ctx_.header[4] != 0) { + ESP_LOGE(TAG, "无效的Ogg版本: %d", ctx_.header[4]); + state_ = ParseState::FIND_PAGE; + ctx_.bytes_needed = 4; + ctx_.data_offset = 0; + break; + } + + ctx_.seg_count = ctx_.header[26]; + if (ctx_.seg_count > 0 && ctx_.seg_count <= 255) { + state_ = ParseState::PARSE_SEGMENTS; + ctx_.bytes_needed = ctx_.seg_count; + ctx_.data_offset = 0; + } else if (ctx_.seg_count == 0) { + // 没有段,直接跳到下一个页面 + state_ = ParseState::FIND_PAGE; + ctx_.bytes_needed = 4; + ctx_.data_offset = 0; + } else { + ESP_LOGE(TAG, "无效的段数: %u", ctx_.seg_count); + state_ = ParseState::FIND_PAGE; + ctx_.bytes_needed = 4; + ctx_.data_offset = 0; + } + } + break; + } + + case ParseState::PARSE_SEGMENTS: { + size_t available = size - processed; + + if (available < ctx_.bytes_needed) { + memcpy(ctx_.seg_table + ctx_.data_offset, + data + processed, available); + + ctx_.data_offset += available; + ctx_.bytes_needed -= available; + processed += available; + return processed; // 等待更多数据 + } else { + size_t to_copy = ctx_.bytes_needed; + memcpy(ctx_.seg_table + ctx_.data_offset, + data + processed, to_copy); + + processed += to_copy; + ctx_.data_offset += to_copy; + ctx_.bytes_needed = 0; + + state_ = ParseState::PARSE_DATA; + ctx_.seg_index = 0; + ctx_.data_offset = 0; + + // 计算数据体总大小 + ctx_.body_size = 0; + for (size_t i = 0; i < ctx_.seg_count; ++i) { + ctx_.body_size += ctx_.seg_table[i]; + } + ctx_.body_offset = 0; + ctx_.seg_remaining = 0; + } + break; + } + + case ParseState::PARSE_DATA: { + while (ctx_.seg_index < ctx_.seg_count && processed < size) { + uint8_t seg_len = ctx_.seg_table[ctx_.seg_index]; + + // 检查段数据是否已经部分读取 + if (ctx_.seg_remaining > 0) { + seg_len = ctx_.seg_remaining; + } else { + ctx_.seg_remaining = seg_len; + } + + // 检查缓冲区是否足够 + if (ctx_.packet_len + seg_len > sizeof(ctx_.packet_buf)) { + ESP_LOGE(TAG, "包缓冲区溢出: %zu + %u > %zu", ctx_.packet_len, seg_len, sizeof(ctx_.packet_buf)); + state_ = ParseState::FIND_PAGE; + ctx_.packet_len = 0; + ctx_.packet_continued = false; + ctx_.seg_remaining = 0; + ctx_.bytes_needed = 4; + return processed; + } + + // 复制数据 + size_t to_copy = std::min(size - processed, (size_t)seg_len); + memcpy(ctx_.packet_buf + ctx_.packet_len, data + processed, to_copy); + + processed += to_copy; + ctx_.packet_len += to_copy; + ctx_.body_offset += to_copy; + ctx_.seg_remaining -= to_copy; + + // 检查段是否完整 + if (ctx_.seg_remaining > 0) { + // 段不完整,等待更多数据 + return processed; + } + + // 段完整 + bool seg_continued = (ctx_.seg_table[ctx_.seg_index] == 255); + + if (!seg_continued) { + // 包结束 + if (ctx_.packet_len) { + if (!opus_info_.head_seen) { + if (ctx_.packet_len >=8 && memcmp(ctx_.packet_buf, "OpusHead", 8) == 0) { + opus_info_.head_seen = true; + if (ctx_.packet_len >= 19) { + opus_info_.sample_rate = ctx_.packet_buf[12] | + (ctx_.packet_buf[13] << 8) | + (ctx_.packet_buf[14] << 16) | + (ctx_.packet_buf[15] << 24); + ESP_LOGD(TAG, "OpusHead found, sample_rate=%d", opus_info_.sample_rate); + } + ctx_.packet_len = 0; + ctx_.packet_continued = false; + ctx_.seg_index++; + ctx_.seg_remaining = 0; + continue; + } + } + if (!opus_info_.tags_seen) { + if (ctx_.packet_len >= 8 && memcmp(ctx_.packet_buf, "OpusTags", 8) == 0) { + opus_info_.tags_seen = true; + ESP_LOGD(TAG, "OpusTags found."); + ctx_.packet_len = 0; + ctx_.packet_continued = false; + ctx_.seg_index++; + ctx_.seg_remaining = 0; + continue; + } + } + if (opus_info_.head_seen && opus_info_.tags_seen) { + if (on_demuxer_finished_) { + on_demuxer_finished_(ctx_.packet_buf, opus_info_.sample_rate, ctx_.packet_len); + } + } else { + ESP_LOGW(TAG, "当前Ogg容器未解析到OpusHead/OpusTags,丢弃"); + } + } + ctx_.packet_len = 0; + ctx_.packet_continued = false; + } else { + ctx_.packet_continued = true; + } + + ctx_.seg_index++; + ctx_.seg_remaining = 0; + } + + if (ctx_.seg_index == ctx_.seg_count) { + // 检查是否所有数据体都已读取 + if (ctx_.body_offset < ctx_.body_size) { + ESP_LOGW(TAG, "数据体不完整: %zu/%zu", + ctx_.body_offset, ctx_.body_size); + } + + // 如果包跨页,保持packet_len和packet_continued + if (!ctx_.packet_continued) { + ctx_.packet_len = 0; + } + + // 进入下一页面 + state_ = ParseState::FIND_PAGE; + ctx_.bytes_needed = 4; + ctx_.data_offset = 0; + } + break; + } + } + } + + return processed; +} + diff --git a/main/audio/demuxer/ogg_demuxer.h b/main/audio/demuxer/ogg_demuxer.h new file mode 100644 index 0000000..1af4456 --- /dev/null +++ b/main/audio/demuxer/ogg_demuxer.h @@ -0,0 +1,63 @@ +#ifndef OGG_DEMUXER_H_ +#define OGG_DEMUXER_H_ + +#include +#include +#include +#include + +class OggDemuxer { +private: + enum ParseState : int8_t { + FIND_PAGE, + PARSE_HEADER, + PARSE_SEGMENTS, + PARSE_DATA + }; + + struct Opus_t { + bool head_seen{false}; + bool tags_seen{false}; + int sample_rate{48000}; + }; + + + // 使用固定大小的缓冲区避免动态分配 + struct context_t { + bool packet_continued{false}; // 当前包是否跨多个段 + uint8_t header[27]; // Ogg页头 + uint8_t seg_table[255]; // 当前存储的段表 + uint8_t packet_buf[8192]; // 8KB包缓冲区 + size_t packet_len = 0; // 缓冲区中累计的数据长度 + size_t seg_count = 0; // 当前页段数 + size_t seg_index = 0; // 当前处理的段索引 + size_t data_offset = 0; // 解析当前阶段已读取的字节数 + size_t bytes_needed = 0; // 解析当前字段还需要读取的字节数 + size_t seg_remaining = 0; // 当前段剩余需要读取的字节数 + size_t body_size = 0; // 数据体总大小 + size_t body_offset = 0; // 数据体已读取的字节数 + }; + +public: + OggDemuxer() { + Reset(); + } + + void Reset(); + + size_t Process(const uint8_t* data, size_t size); + + /// @brief 设置解封装完毕后回调处理函数 + /// @param on_demuxer_finished + void OnDemuxerFinished(std::function on_demuxer_finished) { + on_demuxer_finished_ = on_demuxer_finished; + } +private: + + ParseState state_ = ParseState::FIND_PAGE; + context_t ctx_; + Opus_t opus_info_; + std::function on_demuxer_finished_; +}; + +#endif \ No newline at end of file diff --git a/main/audio/processors/afe_audio_processor.cc b/main/audio/processors/afe_audio_processor.cc new file mode 100644 index 0000000..15e9cc5 --- /dev/null +++ b/main/audio/processors/afe_audio_processor.cc @@ -0,0 +1,201 @@ +#include "afe_audio_processor.h" +#include + +#define PROCESSOR_RUNNING 0x01 + +#define TAG "AfeAudioProcessor" + +AfeAudioProcessor::AfeAudioProcessor() + : afe_data_(nullptr) { + event_group_ = xEventGroupCreate(); +} + +void AfeAudioProcessor::Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) { + codec_ = codec; + frame_samples_ = frame_duration_ms * 16000 / 1000; + + // Pre-allocate output buffer capacity + output_buffer_.reserve(frame_samples_); + + int ref_num = codec_->input_reference() ? 1 : 0; + + std::string input_format; + for (int i = 0; i < codec_->input_channels() - ref_num; i++) { + input_format.push_back('M'); + } + for (int i = 0; i < ref_num; i++) { + input_format.push_back('R'); + } + + srmodel_list_t *models; + if (models_list == nullptr) { + models = esp_srmodel_init("model"); + } else { + models = models_list; + } + + char* ns_model_name = esp_srmodel_filter(models, ESP_NSNET_PREFIX, NULL); + char* vad_model_name = esp_srmodel_filter(models, ESP_VADN_PREFIX, NULL); + + afe_config_t* afe_config = afe_config_init(input_format.c_str(), NULL, AFE_TYPE_VC, AFE_MODE_HIGH_PERF); + afe_config->aec_mode = AEC_MODE_VOIP_HIGH_PERF; + afe_config->vad_mode = VAD_MODE_0; + afe_config->vad_min_noise_ms = 100; + if (vad_model_name != nullptr) { + afe_config->vad_model_name = vad_model_name; + } + + if (ns_model_name != nullptr) { + afe_config->ns_init = true; + afe_config->ns_model_name = ns_model_name; + afe_config->afe_ns_mode = AFE_NS_MODE_NET; + } else { + afe_config->ns_init = false; + } + + afe_config->agc_init = false; + afe_config->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM; + +#ifdef CONFIG_USE_DEVICE_AEC + afe_config->aec_init = true; + afe_config->vad_init = false; +#else + afe_config->aec_init = false; + afe_config->vad_init = true; +#endif + + afe_iface_ = esp_afe_handle_from_config(afe_config); + afe_data_ = afe_iface_->create_from_config(afe_config); + + xTaskCreate([](void* arg) { + auto this_ = (AfeAudioProcessor*)arg; + this_->AudioProcessorTask(); + vTaskDelete(NULL); + }, "audio_communication", 4096, this, 3, NULL); +} + +AfeAudioProcessor::~AfeAudioProcessor() { + if (afe_data_ != nullptr) { + afe_iface_->destroy(afe_data_); + } + vEventGroupDelete(event_group_); +} + +size_t AfeAudioProcessor::GetFeedSize() { + if (afe_data_ == nullptr) { + return 0; + } + return afe_iface_->get_feed_chunksize(afe_data_); +} + +void AfeAudioProcessor::Feed(std::vector&& data) { + if (afe_data_ == nullptr) { + return; + } + + std::lock_guard lock(input_buffer_mutex_); + // Check running state inside lock to avoid TOCTOU race with Stop() + if (!IsRunning()) { + return; + } + input_buffer_.insert(input_buffer_.end(), data.begin(), data.end()); + size_t chunk_size = afe_iface_->get_feed_chunksize(afe_data_) * codec_->input_channels(); + while (input_buffer_.size() >= chunk_size) { + afe_iface_->feed(afe_data_, input_buffer_.data()); + input_buffer_.erase(input_buffer_.begin(), input_buffer_.begin() + chunk_size); + } +} + +void AfeAudioProcessor::Start() { + xEventGroupSetBits(event_group_, PROCESSOR_RUNNING); +} + +void AfeAudioProcessor::Stop() { + xEventGroupClearBits(event_group_, PROCESSOR_RUNNING); + + std::lock_guard lock(input_buffer_mutex_); + if (afe_data_ != nullptr) { + afe_iface_->reset_buffer(afe_data_); + } + input_buffer_.clear(); +} + +bool AfeAudioProcessor::IsRunning() { + return xEventGroupGetBits(event_group_) & PROCESSOR_RUNNING; +} + +void AfeAudioProcessor::OnOutput(std::function&& data)> callback) { + output_callback_ = callback; +} + +void AfeAudioProcessor::OnVadStateChange(std::function callback) { + vad_state_change_callback_ = callback; +} + +void AfeAudioProcessor::AudioProcessorTask() { + auto fetch_size = afe_iface_->get_fetch_chunksize(afe_data_); + auto feed_size = afe_iface_->get_feed_chunksize(afe_data_); + ESP_LOGI(TAG, "Audio communication task started, feed size: %d fetch size: %d", + feed_size, fetch_size); + + while (true) { + xEventGroupWaitBits(event_group_, PROCESSOR_RUNNING, pdFALSE, pdTRUE, portMAX_DELAY); + + auto res = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY); + if ((xEventGroupGetBits(event_group_) & PROCESSOR_RUNNING) == 0) { + continue; + } + if (res == nullptr || res->ret_value == ESP_FAIL) { + if (res != nullptr) { + ESP_LOGI(TAG, "Error code: %d", res->ret_value); + } + continue; + } + + // VAD state change + if (vad_state_change_callback_) { + if (res->vad_state == VAD_SPEECH && !is_speaking_) { + is_speaking_ = true; + vad_state_change_callback_(true); + } else if (res->vad_state == VAD_SILENCE && is_speaking_) { + is_speaking_ = false; + vad_state_change_callback_(false); + } + } + + if (output_callback_) { + size_t samples = res->data_size / sizeof(int16_t); + + // Add data to buffer + output_buffer_.insert(output_buffer_.end(), res->data, res->data + samples); + + // Output complete frames when buffer has enough data + while (output_buffer_.size() >= frame_samples_) { + if (output_buffer_.size() == frame_samples_) { + // If buffer size equals frame size, move the entire buffer + output_callback_(std::move(output_buffer_)); + output_buffer_.clear(); + output_buffer_.reserve(frame_samples_); + } else { + // If buffer size exceeds frame size, copy one frame and remove it + output_callback_(std::vector(output_buffer_.begin(), output_buffer_.begin() + frame_samples_)); + output_buffer_.erase(output_buffer_.begin(), output_buffer_.begin() + frame_samples_); + } + } + } + } +} + +void AfeAudioProcessor::EnableDeviceAec(bool enable) { + if (enable) { +#if CONFIG_USE_DEVICE_AEC + afe_iface_->disable_vad(afe_data_); + afe_iface_->enable_aec(afe_data_); +#else + ESP_LOGE(TAG, "Device AEC is not supported"); +#endif + } else { + afe_iface_->disable_aec(afe_data_); + afe_iface_->enable_vad(afe_data_); + } +} diff --git a/main/audio/processors/afe_audio_processor.h b/main/audio/processors/afe_audio_processor.h new file mode 100644 index 0000000..fe2f025 --- /dev/null +++ b/main/audio/processors/afe_audio_processor.h @@ -0,0 +1,48 @@ +#ifndef AFE_AUDIO_PROCESSOR_H +#define AFE_AUDIO_PROCESSOR_H + +#include +#include +#include +#include + +#include +#include +#include +#include + +#include "audio_processor.h" +#include "audio_codec.h" + +class AfeAudioProcessor : public AudioProcessor { +public: + AfeAudioProcessor(); + ~AfeAudioProcessor(); + + void Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) override; + void Feed(std::vector&& data) override; + void Start() override; + void Stop() override; + bool IsRunning() override; + void OnOutput(std::function&& data)> callback) override; + void OnVadStateChange(std::function callback) override; + size_t GetFeedSize() override; + void EnableDeviceAec(bool enable) override; + +private: + EventGroupHandle_t event_group_ = nullptr; + const esp_afe_sr_iface_t* afe_iface_ = nullptr; + esp_afe_sr_data_t* afe_data_ = nullptr; + std::function&& data)> output_callback_; + std::function vad_state_change_callback_; + AudioCodec* codec_ = nullptr; + int frame_samples_ = 0; + bool is_speaking_ = false; + std::vector input_buffer_; + std::mutex input_buffer_mutex_; + std::vector output_buffer_; + + void AudioProcessorTask(); +}; + +#endif \ No newline at end of file diff --git a/main/audio/processors/audio_debugger.cc b/main/audio/processors/audio_debugger.cc new file mode 100644 index 0000000..630057c --- /dev/null +++ b/main/audio/processors/audio_debugger.cc @@ -0,0 +1,68 @@ +#include "audio_debugger.h" +#include "sdkconfig.h" + +#if CONFIG_USE_AUDIO_DEBUGGER +#include +#include +#include +#include +#include +#include +#endif + +#define TAG "AudioDebugger" + + +AudioDebugger::AudioDebugger() { +#if CONFIG_USE_AUDIO_DEBUGGER + udp_sockfd_ = socket(AF_INET, SOCK_DGRAM, 0); + if (udp_sockfd_ >= 0) { + // 解析配置的服务器地址 "IP:PORT" + std::string server_addr = CONFIG_AUDIO_DEBUG_UDP_SERVER; + size_t colon_pos = server_addr.find(':'); + + if (colon_pos != std::string::npos) { + std::string ip = server_addr.substr(0, colon_pos); + int port = std::stoi(server_addr.substr(colon_pos + 1)); + + memset(&udp_server_addr_, 0, sizeof(udp_server_addr_)); + udp_server_addr_.sin_family = AF_INET; + udp_server_addr_.sin_port = htons(port); + inet_pton(AF_INET, ip.c_str(), &udp_server_addr_.sin_addr); + + ESP_LOGI(TAG, "Initialized server address: %s", CONFIG_AUDIO_DEBUG_UDP_SERVER); + } else { + ESP_LOGW(TAG, "Invalid server address: %s, should be IP:PORT", CONFIG_AUDIO_DEBUG_UDP_SERVER); + close(udp_sockfd_); + udp_sockfd_ = -1; + } + } else { + ESP_LOGW(TAG, "Failed to create UDP socket: %d", errno); + } +#endif +} + +AudioDebugger::~AudioDebugger() { +#if CONFIG_USE_AUDIO_DEBUGGER + if (udp_sockfd_ >= 0) { + close(udp_sockfd_); + ESP_LOGI(TAG, "Closed UDP socket"); + } +#endif +} + +void AudioDebugger::Feed(const std::vector& data) { +#if CONFIG_USE_AUDIO_DEBUGGER + if (udp_sockfd_ >= 0) { + ssize_t sent = sendto(udp_sockfd_, data.data(), data.size() * sizeof(int16_t), 0, + (struct sockaddr*)&udp_server_addr_, sizeof(udp_server_addr_)); + if (sent < 0) { + ESP_LOGW(TAG, "Failed to send audio data to %s: %d", CONFIG_AUDIO_DEBUG_UDP_SERVER, errno); + } else { + ESP_LOGD(TAG, "Sent %d bytes audio data to %s", sent, CONFIG_AUDIO_DEBUG_UDP_SERVER); + } + } +#endif +} + + \ No newline at end of file diff --git a/main/audio/processors/audio_debugger.h b/main/audio/processors/audio_debugger.h new file mode 100644 index 0000000..a81336c --- /dev/null +++ b/main/audio/processors/audio_debugger.h @@ -0,0 +1,22 @@ +#ifndef AUDIO_DEBUGGER_H +#define AUDIO_DEBUGGER_H + +#include +#include + +#include +#include + +class AudioDebugger { +public: + AudioDebugger(); + ~AudioDebugger(); + + void Feed(const std::vector& data); + +private: + int udp_sockfd_ = -1; + struct sockaddr_in udp_server_addr_; +}; + +#endif \ No newline at end of file diff --git a/main/audio/processors/no_audio_processor.cc b/main/audio/processors/no_audio_processor.cc new file mode 100644 index 0000000..90f997a --- /dev/null +++ b/main/audio/processors/no_audio_processor.cc @@ -0,0 +1,71 @@ +#include "no_audio_processor.h" +#include + +#define TAG "NoAudioProcessor" + +void NoAudioProcessor::Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) { + codec_ = codec; + frame_samples_ = frame_duration_ms * 16000 / 1000; + output_buffer_.reserve(frame_samples_); +} + +void NoAudioProcessor::Feed(std::vector&& data) { + if (!is_running_ || !output_callback_) { + return; + } + + // Convert stereo to mono if needed + if (codec_->input_channels() == 2) { + for (size_t i = 0, j = 0; i < data.size() / 2; ++i, j += 2) { + output_buffer_.push_back(data[j]); + } + } else { + output_buffer_.insert(output_buffer_.end(), data.begin(), data.end()); + } + + // Output complete frames when buffer has enough data + while (output_buffer_.size() >= (size_t)frame_samples_) { + if (output_buffer_.size() == (size_t)frame_samples_) { + output_callback_(std::move(output_buffer_)); + output_buffer_.clear(); + output_buffer_.reserve(frame_samples_); + } else { + output_callback_(std::vector(output_buffer_.begin(), output_buffer_.begin() + frame_samples_)); + output_buffer_.erase(output_buffer_.begin(), output_buffer_.begin() + frame_samples_); + } + } +} + +void NoAudioProcessor::Start() { + is_running_ = true; +} + +void NoAudioProcessor::Stop() { + is_running_ = false; + output_buffer_.clear(); +} + +bool NoAudioProcessor::IsRunning() { + return is_running_; +} + +void NoAudioProcessor::OnOutput(std::function&& data)> callback) { + output_callback_ = callback; +} + +void NoAudioProcessor::OnVadStateChange(std::function callback) { + vad_state_change_callback_ = callback; +} + +size_t NoAudioProcessor::GetFeedSize() { + if (!codec_) { + return 0; + } + return frame_samples_; +} + +void NoAudioProcessor::EnableDeviceAec(bool enable) { + if (enable) { + ESP_LOGE(TAG, "Device AEC is not supported"); + } +} diff --git a/main/audio/processors/no_audio_processor.h b/main/audio/processors/no_audio_processor.h new file mode 100644 index 0000000..70ef1d1 --- /dev/null +++ b/main/audio/processors/no_audio_processor.h @@ -0,0 +1,35 @@ +#ifndef DUMMY_AUDIO_PROCESSOR_H +#define DUMMY_AUDIO_PROCESSOR_H + +#include +#include +#include + +#include "audio_processor.h" +#include "audio_codec.h" + +class NoAudioProcessor : public AudioProcessor { +public: + NoAudioProcessor() = default; + ~NoAudioProcessor() = default; + + void Initialize(AudioCodec* codec, int frame_duration_ms, srmodel_list_t* models_list) override; + void Feed(std::vector&& data) override; + void Start() override; + void Stop() override; + bool IsRunning() override; + void OnOutput(std::function&& data)> callback) override; + void OnVadStateChange(std::function callback) override; + size_t GetFeedSize() override; + void EnableDeviceAec(bool enable) override; + +private: + AudioCodec* codec_ = nullptr; + int frame_samples_ = 0; + std::vector output_buffer_; + std::function&& data)> output_callback_; + std::function vad_state_change_callback_; + std::atomic is_running_ = false; +}; + +#endif \ No newline at end of file diff --git a/main/audio/wake_word.h b/main/audio/wake_word.h new file mode 100644 index 0000000..9b8986a --- /dev/null +++ b/main/audio/wake_word.h @@ -0,0 +1,26 @@ +#ifndef WAKE_WORD_H +#define WAKE_WORD_H + +#include +#include +#include + +#include +#include "audio_codec.h" + +class WakeWord { +public: + virtual ~WakeWord() = default; + + virtual bool Initialize(AudioCodec* codec, srmodel_list_t* models_list) = 0; + virtual void Feed(const std::vector& data) = 0; + virtual void OnWakeWordDetected(std::function callback) = 0; + virtual void Start() = 0; + virtual void Stop() = 0; + virtual size_t GetFeedSize() = 0; + virtual void EncodeWakeWordData() = 0; + virtual bool GetWakeWordOpus(std::vector& opus) = 0; + virtual const std::string& GetLastDetectedWakeWord() const = 0; +}; + +#endif diff --git a/main/audio/wake_words/afe_wake_word.cc b/main/audio/wake_words/afe_wake_word.cc new file mode 100644 index 0000000..8fc5fef --- /dev/null +++ b/main/audio/wake_words/afe_wake_word.cc @@ -0,0 +1,263 @@ +#include "afe_wake_word.h" +#include "audio_service.h" +#include +#include + +#define DETECTION_RUNNING_EVENT 1 + +#define TAG "AfeWakeWord" + +AfeWakeWord::AfeWakeWord() + : afe_data_(nullptr), + wake_word_pcm_(), + wake_word_opus_() { + + event_group_ = xEventGroupCreate(); +} + +AfeWakeWord::~AfeWakeWord() { + if (afe_data_ != nullptr) { + afe_iface_->destroy(afe_data_); + } + + if (wake_word_encode_task_stack_ != nullptr) { + heap_caps_free(wake_word_encode_task_stack_); + } + + if (wake_word_encode_task_buffer_ != nullptr) { + heap_caps_free(wake_word_encode_task_buffer_); + } + + if (models_ != nullptr) { + esp_srmodel_deinit(models_); + } + + vEventGroupDelete(event_group_); +} + +bool AfeWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list) { + codec_ = codec; + int ref_num = codec_->input_reference() ? 1 : 0; + + if (models_list == nullptr) { + models_ = esp_srmodel_init("model"); + } else { + models_ = models_list; + } + + if (models_ == nullptr || models_->num == -1) { + ESP_LOGE(TAG, "Failed to initialize wakenet model"); + return false; + } + for (int i = 0; i < models_->num; i++) { + ESP_LOGI(TAG, "Model %d: %s", i, models_->model_name[i]); + if (strstr(models_->model_name[i], ESP_WN_PREFIX) != NULL) { + wakenet_model_ = models_->model_name[i]; + auto words = esp_srmodel_get_wake_words(models_, wakenet_model_); + // split by ";" to get all wake words + std::stringstream ss(words); + std::string word; + while (std::getline(ss, word, ';')) { + wake_words_.push_back(word); + } + } + } + + std::string input_format; + for (int i = 0; i < codec_->input_channels() - ref_num; i++) { + input_format.push_back('M'); + } + for (int i = 0; i < ref_num; i++) { + input_format.push_back('R'); + } + afe_config_t* afe_config = afe_config_init(input_format.c_str(), models_, AFE_TYPE_SR, AFE_MODE_HIGH_PERF); + afe_config->aec_init = codec_->input_reference(); + afe_config->aec_mode = AEC_MODE_SR_HIGH_PERF; + afe_config->afe_perferred_core = 1; + afe_config->afe_perferred_priority = 1; + afe_config->memory_alloc_mode = AFE_MEMORY_ALLOC_MORE_PSRAM; + + afe_iface_ = esp_afe_handle_from_config(afe_config); + afe_data_ = afe_iface_->create_from_config(afe_config); + + xTaskCreate([](void* arg) { + auto this_ = (AfeWakeWord*)arg; + this_->AudioDetectionTask(); + vTaskDelete(NULL); + }, "audio_detection", 4096, this, 3, nullptr); + + return true; +} + +void AfeWakeWord::OnWakeWordDetected(std::function callback) { + wake_word_detected_callback_ = callback; +} + +void AfeWakeWord::Start() { + xEventGroupSetBits(event_group_, DETECTION_RUNNING_EVENT); +} + +void AfeWakeWord::Stop() { + xEventGroupClearBits(event_group_, DETECTION_RUNNING_EVENT); + + std::lock_guard lock(input_buffer_mutex_); + if (afe_data_ != nullptr) { + afe_iface_->reset_buffer(afe_data_); + } + input_buffer_.clear(); +} + +void AfeWakeWord::Feed(const std::vector& data) { + if (afe_data_ == nullptr) { + return; + } + + std::lock_guard lock(input_buffer_mutex_); + // Check running state inside lock to avoid TOCTOU race with Stop() + if (!(xEventGroupGetBits(event_group_) & DETECTION_RUNNING_EVENT)) { + return; + } + input_buffer_.insert(input_buffer_.end(), data.begin(), data.end()); + size_t chunk_size = afe_iface_->get_feed_chunksize(afe_data_) * codec_->input_channels(); + while (input_buffer_.size() >= chunk_size) { + afe_iface_->feed(afe_data_, input_buffer_.data()); + input_buffer_.erase(input_buffer_.begin(), input_buffer_.begin() + chunk_size); + } +} + +size_t AfeWakeWord::GetFeedSize() { + if (afe_data_ == nullptr) { + return 0; + } + return afe_iface_->get_feed_chunksize(afe_data_); +} + +void AfeWakeWord::AudioDetectionTask() { + auto fetch_size = afe_iface_->get_fetch_chunksize(afe_data_); + auto feed_size = afe_iface_->get_feed_chunksize(afe_data_); + ESP_LOGI(TAG, "Audio detection task started, feed size: %d fetch size: %d", + feed_size, fetch_size); + + while (true) { + xEventGroupWaitBits(event_group_, DETECTION_RUNNING_EVENT, pdFALSE, pdTRUE, portMAX_DELAY); + + auto res = afe_iface_->fetch_with_delay(afe_data_, portMAX_DELAY); + if (res == nullptr || res->ret_value == ESP_FAIL) { + continue;; + } + + // Store the wake word data for voice recognition, like who is speaking + StoreWakeWordData(res->data, res->data_size / sizeof(int16_t)); + + if (res->wakeup_state == WAKENET_DETECTED) { + Stop(); + last_detected_wake_word_ = wake_words_[res->wakenet_model_index - 1]; + + if (wake_word_detected_callback_) { + wake_word_detected_callback_(last_detected_wake_word_); + } + } + } +} + +void AfeWakeWord::StoreWakeWordData(const int16_t* data, size_t samples) { + // store audio data to wake_word_pcm_ + wake_word_pcm_.emplace_back(std::vector(data, data + samples)); + // keep about 2 seconds of data, detect duration is 30ms (sample_rate == 16000, chunksize == 512) + while (wake_word_pcm_.size() > 2000 / 30) { + wake_word_pcm_.pop_front(); + } +} + +void AfeWakeWord::EncodeWakeWordData() { + const size_t stack_size = 4096 * 6; + wake_word_opus_.clear(); + if (wake_word_encode_task_stack_ == nullptr) { + wake_word_encode_task_stack_ = (StackType_t*)heap_caps_malloc(stack_size, MALLOC_CAP_SPIRAM); + assert(wake_word_encode_task_stack_ != nullptr); + } + if (wake_word_encode_task_buffer_ == nullptr) { + wake_word_encode_task_buffer_ = (StaticTask_t*)heap_caps_malloc(sizeof(StaticTask_t), MALLOC_CAP_INTERNAL); + assert(wake_word_encode_task_buffer_ != nullptr); + } + + wake_word_encode_task_ = xTaskCreateStatic([](void* arg) { + auto this_ = (AfeWakeWord*)arg; + { + auto start_time = esp_timer_get_time(); + // Create encoder + esp_opus_enc_config_t opus_enc_cfg = AS_OPUS_ENC_CONFIG(); + void* encoder_handle = nullptr; + auto ret = esp_opus_enc_open(&opus_enc_cfg, sizeof(esp_opus_enc_config_t), &encoder_handle); + if (encoder_handle == nullptr) { + ESP_LOGE(TAG, "Failed to create audio encoder, error code: %d", ret); + std::lock_guard lock(this_->wake_word_mutex_); + this_->wake_word_opus_.push_back(std::vector()); + this_->wake_word_cv_.notify_all(); + return; + } + + // Get frame size + int frame_size = 0; + int outbuf_size = 0; + esp_opus_enc_get_frame_size(encoder_handle, &frame_size, &outbuf_size); + frame_size = frame_size / sizeof(int16_t); + + // Encode all PCM data + int packets = 0; + std::vector in_buffer; + esp_audio_enc_in_frame_t in = {}; + esp_audio_enc_out_frame_t out = {}; + + for (auto& pcm: this_->wake_word_pcm_) { + if (in_buffer.empty()) { + in_buffer = std::move(pcm); + } else { + in_buffer.reserve(in_buffer.size() + pcm.size()); + in_buffer.insert(in_buffer.end(), pcm.begin(), pcm.end()); + } + + while (in_buffer.size() >= frame_size) { + std::vector opus_buf(outbuf_size); + in.buffer = (uint8_t *)(in_buffer.data()); + in.len = (uint32_t)(frame_size * sizeof(int16_t)); + out.buffer = opus_buf.data(); + out.len = outbuf_size; + out.encoded_bytes = 0; + + ret = esp_opus_enc_process(encoder_handle, &in, &out); + if (ret == ESP_AUDIO_ERR_OK) { + std::lock_guard lock(this_->wake_word_mutex_); + this_->wake_word_opus_.emplace_back(opus_buf.data(), opus_buf.data() + out.encoded_bytes); + this_->wake_word_cv_.notify_all(); + packets++; + } else { + ESP_LOGE(TAG, "Failed to encode audio, error code: %d", ret); + } + + in_buffer.erase(in_buffer.begin(), in_buffer.begin() + frame_size); + } + } + this_->wake_word_pcm_.clear(); + // Close encoder + esp_opus_enc_close(encoder_handle); + auto end_time = esp_timer_get_time(); + ESP_LOGI(TAG, "Encode wake word opus %d packets in %ld ms", packets, (long)((end_time - start_time) / 1000)); + + std::lock_guard lock(this_->wake_word_mutex_); + this_->wake_word_opus_.push_back(std::vector()); + this_->wake_word_cv_.notify_all(); + } + vTaskDelete(NULL); + }, "encode_wake_word", stack_size, this, 2, wake_word_encode_task_stack_, wake_word_encode_task_buffer_); +} + +bool AfeWakeWord::GetWakeWordOpus(std::vector& opus) { + std::unique_lock lock(wake_word_mutex_); + wake_word_cv_.wait(lock, [this]() { + return !wake_word_opus_.empty(); + }); + opus.swap(wake_word_opus_.front()); + wake_word_opus_.pop_front(); + return !opus.empty(); +} diff --git a/main/audio/wake_words/afe_wake_word.h b/main/audio/wake_words/afe_wake_word.h new file mode 100644 index 0000000..6c2bf72 --- /dev/null +++ b/main/audio/wake_words/afe_wake_word.h @@ -0,0 +1,62 @@ +#ifndef AFE_WAKE_WORD_H +#define AFE_WAKE_WORD_H + +#include +#include +#include + +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#include "audio_codec.h" +#include "wake_word.h" + +class AfeWakeWord : public WakeWord { +public: + AfeWakeWord(); + ~AfeWakeWord(); + + bool Initialize(AudioCodec* codec, srmodel_list_t* models_list); + void Feed(const std::vector& data); + void OnWakeWordDetected(std::function callback); + void Start(); + void Stop(); + size_t GetFeedSize(); + void EncodeWakeWordData(); + bool GetWakeWordOpus(std::vector& opus); + const std::string& GetLastDetectedWakeWord() const { return last_detected_wake_word_; } + +private: + srmodel_list_t *models_ = nullptr; + const esp_afe_sr_iface_t* afe_iface_ = nullptr; + esp_afe_sr_data_t* afe_data_ = nullptr; + char* wakenet_model_ = NULL; + std::vector wake_words_; + EventGroupHandle_t event_group_; + std::function wake_word_detected_callback_; + AudioCodec* codec_ = nullptr; + std::string last_detected_wake_word_; + std::vector input_buffer_; + std::mutex input_buffer_mutex_; + + TaskHandle_t wake_word_encode_task_ = nullptr; + StaticTask_t* wake_word_encode_task_buffer_ = nullptr; + StackType_t* wake_word_encode_task_stack_ = nullptr; + std::deque> wake_word_pcm_; + std::deque> wake_word_opus_; + std::mutex wake_word_mutex_; + std::condition_variable wake_word_cv_; + + void StoreWakeWordData(const int16_t* data, size_t size); + void AudioDetectionTask(); +}; + +#endif diff --git a/main/audio/wake_words/custom_wake_word.cc b/main/audio/wake_words/custom_wake_word.cc new file mode 100644 index 0000000..982bbe9 --- /dev/null +++ b/main/audio/wake_words/custom_wake_word.cc @@ -0,0 +1,303 @@ +#include "custom_wake_word.h" +#include "audio_service.h" +#include "system_info.h" +#include "assets.h" + +#include +#include +#include +#include +#include + +#define TAG "CustomWakeWord" + +CustomWakeWord::CustomWakeWord() + : wake_word_pcm_(), wake_word_opus_() { +} + +CustomWakeWord::~CustomWakeWord() { + if (multinet_model_data_ != nullptr && multinet_ != nullptr) { + multinet_->destroy(multinet_model_data_); + multinet_model_data_ = nullptr; + } + + if (wake_word_encode_task_stack_ != nullptr) { + heap_caps_free(wake_word_encode_task_stack_); + } + + if (wake_word_encode_task_buffer_ != nullptr) { + heap_caps_free(wake_word_encode_task_buffer_); + } + + if (models_ != nullptr) { + esp_srmodel_deinit(models_); + } +} + +void CustomWakeWord::ParseWakenetModelConfig() { + // Read index.json + auto& assets = Assets::GetInstance(); + void* ptr = nullptr; + size_t size = 0; + if (!assets.GetAssetData("index.json", ptr, size)) { + ESP_LOGE(TAG, "Failed to read index.json"); + return; + } + cJSON* root = cJSON_ParseWithLength(static_cast(ptr), size); + if (root == nullptr) { + ESP_LOGE(TAG, "Failed to parse index.json"); + return; + } + cJSON* multinet_model = cJSON_GetObjectItem(root, "multinet_model"); + if (cJSON_IsObject(multinet_model)) { + cJSON* language = cJSON_GetObjectItem(multinet_model, "language"); + cJSON* duration = cJSON_GetObjectItem(multinet_model, "duration"); + cJSON* threshold = cJSON_GetObjectItem(multinet_model, "threshold"); + cJSON* commands = cJSON_GetObjectItem(multinet_model, "commands"); + if (cJSON_IsString(language)) { + language_ = language->valuestring; + } + if (cJSON_IsNumber(duration)) { + duration_ = duration->valueint; + } + if (cJSON_IsNumber(threshold)) { + threshold_ = threshold->valuedouble; + } + if (cJSON_IsArray(commands)) { + for (int i = 0; i < cJSON_GetArraySize(commands); i++) { + cJSON* command = cJSON_GetArrayItem(commands, i); + if (cJSON_IsObject(command)) { + cJSON* command_name = cJSON_GetObjectItem(command, "command"); + cJSON* text = cJSON_GetObjectItem(command, "text"); + cJSON* action = cJSON_GetObjectItem(command, "action"); + if (cJSON_IsString(command_name) && cJSON_IsString(text) && cJSON_IsString(action)) { + commands_.push_back({command_name->valuestring, text->valuestring, action->valuestring}); + ESP_LOGI(TAG, "Command: %s, Text: %s, Action: %s", command_name->valuestring, text->valuestring, action->valuestring); + } + } + } + } + } + cJSON_Delete(root); +} + + +bool CustomWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list) { + codec_ = codec; + commands_.clear(); + + if (models_list == nullptr) { + language_ = "cn"; + models_ = esp_srmodel_init("model"); +#ifdef CONFIG_CUSTOM_WAKE_WORD + threshold_ = CONFIG_CUSTOM_WAKE_WORD_THRESHOLD / 100.0f; + commands_.push_back({CONFIG_CUSTOM_WAKE_WORD, CONFIG_CUSTOM_WAKE_WORD_DISPLAY, "wake"}); +#endif + } else { + models_ = models_list; + ParseWakenetModelConfig(); + } + + if (models_ == nullptr || models_->num == -1) { + ESP_LOGE(TAG, "Failed to initialize wakenet model"); + return false; + } + + // 初始化 multinet (命令词识别) + mn_name_ = esp_srmodel_filter(models_, ESP_MN_PREFIX, language_.c_str()); + if (mn_name_ == nullptr) { + ESP_LOGW(TAG, "Language '%s' multinet not found, falling back to any multinet model", language_.c_str()); + mn_name_ = esp_srmodel_filter(models_, ESP_MN_PREFIX, NULL); + } + if (mn_name_ == nullptr) { + ESP_LOGE(TAG, "Failed to initialize multinet, mn_name is nullptr"); + ESP_LOGI(TAG, "Please refer to https://pcn7cs20v8cr.feishu.cn/wiki/CpQjwQsCJiQSWSkYEvrcxcbVnwh to add custom wake word"); + return false; + } + + multinet_ = esp_mn_handle_from_name(mn_name_); + multinet_model_data_ = multinet_->create(mn_name_, duration_); + multinet_->set_det_threshold(multinet_model_data_, threshold_); + esp_mn_commands_clear(); + for (int i = 0; i < commands_.size(); i++) { + esp_mn_commands_add(i + 1, commands_[i].command.c_str()); + } + esp_mn_commands_update(); + + multinet_->print_active_speech_commands(multinet_model_data_); + return true; +} + +void CustomWakeWord::OnWakeWordDetected(std::function callback) { + wake_word_detected_callback_ = callback; +} + +void CustomWakeWord::Start() { + running_ = true; +} + +void CustomWakeWord::Stop() { + running_ = false; + + std::lock_guard lock(input_buffer_mutex_); + input_buffer_.clear(); +} + +void CustomWakeWord::Feed(const std::vector& data) { + if (multinet_model_data_ == nullptr) { + return; + } + + std::lock_guard lock(input_buffer_mutex_); + // Check running state inside lock to avoid TOCTOU race with Stop() + if (!running_) { + return; + } + + // If input channels is 2, we need to fetch the left channel data + if (codec_->input_channels() == 2) { + for (size_t i = 0; i < data.size(); i += 2) { + input_buffer_.push_back(data[i]); + } + } else { + input_buffer_.insert(input_buffer_.end(), data.begin(), data.end()); + } + + int chunksize = multinet_->get_samp_chunksize(multinet_model_data_); + while (input_buffer_.size() >= chunksize) { + std::vector chunk(input_buffer_.begin(), input_buffer_.begin() + chunksize); + StoreWakeWordData(chunk); + + esp_mn_state_t mn_state = multinet_->detect(multinet_model_data_, chunk.data()); + + if (mn_state == ESP_MN_STATE_DETECTED) { + esp_mn_results_t *mn_result = multinet_->get_results(multinet_model_data_); + for (int i = 0; i < mn_result->num && running_; i++) { + ESP_LOGI(TAG, "Custom wake word detected: command_id=%d, string=%s, prob=%f", + mn_result->command_id[i], mn_result->string, mn_result->prob[i]); + auto& command = commands_[mn_result->command_id[i] - 1]; + if (command.action == "wake") { + last_detected_wake_word_ = command.text; + running_ = false; + input_buffer_.clear(); + + if (wake_word_detected_callback_) { + wake_word_detected_callback_(last_detected_wake_word_); + } + } + } + multinet_->clean(multinet_model_data_); + } else if (mn_state == ESP_MN_STATE_TIMEOUT) { + ESP_LOGD(TAG, "Command word detection timeout, cleaning state"); + multinet_->clean(multinet_model_data_); + } + + if (!running_) { + break; + } + input_buffer_.erase(input_buffer_.begin(), input_buffer_.begin() + chunksize); + } +} + +size_t CustomWakeWord::GetFeedSize() { + if (multinet_model_data_ == nullptr) { + return 0; + } + return multinet_->get_samp_chunksize(multinet_model_data_); +} + +void CustomWakeWord::StoreWakeWordData(const std::vector& data) { + // store audio data to wake_word_pcm_ + wake_word_pcm_.push_back(data); + // keep about 2 seconds of data, detect duration is 30ms (sample_rate == 16000, chunksize == 512) + while (wake_word_pcm_.size() > 2000 / 30) { + wake_word_pcm_.pop_front(); + } +} + +void CustomWakeWord::EncodeWakeWordData() { + const size_t stack_size = 4096 * 7; + wake_word_opus_.clear(); + if (wake_word_encode_task_stack_ == nullptr) { + wake_word_encode_task_stack_ = (StackType_t*)heap_caps_malloc(stack_size, MALLOC_CAP_SPIRAM); + assert(wake_word_encode_task_stack_ != nullptr); + } + if (wake_word_encode_task_buffer_ == nullptr) { + wake_word_encode_task_buffer_ = (StaticTask_t*)heap_caps_malloc(sizeof(StaticTask_t), MALLOC_CAP_INTERNAL); + assert(wake_word_encode_task_buffer_ != nullptr); + } + + wake_word_encode_task_ = xTaskCreateStatic([](void* arg) { + auto this_ = (CustomWakeWord*)arg; + { + auto start_time = esp_timer_get_time(); + // Create encoder + esp_opus_enc_config_t opus_enc_cfg = AS_OPUS_ENC_CONFIG(); + void* encoder_handle = nullptr; + auto ret = esp_opus_enc_open(&opus_enc_cfg, sizeof(esp_opus_enc_config_t), &encoder_handle); + if (encoder_handle == nullptr) { + ESP_LOGE(TAG, "Failed to create audio encoder, error code: %d", ret); + std::lock_guard lock(this_->wake_word_mutex_); + this_->wake_word_opus_.push_back(std::vector()); + this_->wake_word_cv_.notify_all(); + return; + } + // Get frame size + int frame_size = 0; + int outbuf_size = 0; + esp_opus_enc_get_frame_size(encoder_handle, &frame_size, &outbuf_size); + frame_size = frame_size / sizeof(int16_t); + // Encode all PCM data + int packets = 0; + std::vector in_buffer; + esp_audio_enc_in_frame_t in = {}; + esp_audio_enc_out_frame_t out = {}; + for (auto& pcm: this_->wake_word_pcm_) { + if (in_buffer.empty()) { + in_buffer = std::move(pcm); + } else { + in_buffer.reserve(in_buffer.size() + pcm.size()); + in_buffer.insert(in_buffer.end(), pcm.begin(), pcm.end()); + } + while (in_buffer.size() >= frame_size) { + std::vector opus_buf(outbuf_size); + in.buffer = (uint8_t *)(in_buffer.data()); + in.len = (uint32_t)(frame_size * sizeof(int16_t)); + out.buffer = opus_buf.data(); + out.len = outbuf_size; + out.encoded_bytes = 0; + ret = esp_opus_enc_process(encoder_handle, &in, &out); + if (ret == ESP_AUDIO_ERR_OK) { + std::lock_guard lock(this_->wake_word_mutex_); + this_->wake_word_opus_.emplace_back(opus_buf.data(), opus_buf.data() + out.encoded_bytes); + this_->wake_word_cv_.notify_all(); + packets++; + } else { + ESP_LOGE(TAG, "Failed to encode audio, error code: %d", ret); + } + in_buffer.erase(in_buffer.begin(), in_buffer.begin() + frame_size); + } + } + this_->wake_word_pcm_.clear(); + // Close encoder + esp_opus_enc_close(encoder_handle); + auto end_time = esp_timer_get_time(); + ESP_LOGI(TAG, "Encode wake word opus %d packets in %ld ms", packets, (long)((end_time - start_time) / 1000)); + + std::lock_guard lock(this_->wake_word_mutex_); + this_->wake_word_opus_.push_back(std::vector()); + this_->wake_word_cv_.notify_all(); + } + vTaskDelete(NULL); + }, "encode_wake_word", stack_size, this, 2, wake_word_encode_task_stack_, wake_word_encode_task_buffer_); +} + +bool CustomWakeWord::GetWakeWordOpus(std::vector& opus) { + std::unique_lock lock(wake_word_mutex_); + wake_word_cv_.wait(lock, [this]() { + return !wake_word_opus_.empty(); + }); + opus.swap(wake_word_opus_.front()); + wake_word_opus_.pop_front(); + return !opus.empty(); +} diff --git a/main/audio/wake_words/custom_wake_word.h b/main/audio/wake_words/custom_wake_word.h new file mode 100644 index 0000000..645ad1b --- /dev/null +++ b/main/audio/wake_words/custom_wake_word.h @@ -0,0 +1,71 @@ +#ifndef CUSTOM_WAKE_WORD_H +#define CUSTOM_WAKE_WORD_H + +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include +#include + +#include "audio_codec.h" +#include "wake_word.h" + +class CustomWakeWord : public WakeWord { +public: + CustomWakeWord(); + ~CustomWakeWord(); + + bool Initialize(AudioCodec* codec, srmodel_list_t* models_list); + void Feed(const std::vector& data); + void OnWakeWordDetected(std::function callback); + void Start(); + void Stop(); + size_t GetFeedSize(); + void EncodeWakeWordData(); + bool GetWakeWordOpus(std::vector& opus); + const std::string& GetLastDetectedWakeWord() const { return last_detected_wake_word_; } + +private: + struct Command { + std::string command; + std::string text; + std::string action; + }; + + // multinet 相关成员变量 + esp_mn_iface_t* multinet_ = nullptr; + model_iface_data_t* multinet_model_data_ = nullptr; + srmodel_list_t *models_ = nullptr; + char* mn_name_ = nullptr; + std::string language_ = "cn"; + int duration_ = 3000; + float threshold_ = 0.2; + std::deque commands_; + + std::function wake_word_detected_callback_; + AudioCodec* codec_ = nullptr; + std::string last_detected_wake_word_; + std::atomic running_ = false; + std::vector input_buffer_; + std::mutex input_buffer_mutex_; + + TaskHandle_t wake_word_encode_task_ = nullptr; + StaticTask_t* wake_word_encode_task_buffer_ = nullptr; + StackType_t* wake_word_encode_task_stack_ = nullptr; + std::deque> wake_word_pcm_; + std::deque> wake_word_opus_; + std::mutex wake_word_mutex_; + std::condition_variable wake_word_cv_; + + void StoreWakeWordData(const std::vector& data); + void ParseWakenetModelConfig(); +}; + +#endif diff --git a/main/audio/wake_words/esp_wake_word.cc b/main/audio/wake_words/esp_wake_word.cc new file mode 100644 index 0000000..930aa2d --- /dev/null +++ b/main/audio/wake_words/esp_wake_word.cc @@ -0,0 +1,110 @@ +#include "esp_wake_word.h" +#include + + +#define TAG "EspWakeWord" + +EspWakeWord::EspWakeWord() { +} + +EspWakeWord::~EspWakeWord() { + if (wakenet_data_ != nullptr) { + wakenet_iface_->destroy(wakenet_data_); + esp_srmodel_deinit(wakenet_model_); + } +} + +bool EspWakeWord::Initialize(AudioCodec* codec, srmodel_list_t* models_list) { + codec_ = codec; + + if (models_list == nullptr) { + wakenet_model_ = esp_srmodel_init("model"); + } else { + wakenet_model_ = models_list; + } + + if (wakenet_model_ == nullptr || wakenet_model_->num == -1) { + ESP_LOGE(TAG, "Failed to initialize wakenet model"); + return false; + } + if(wakenet_model_->num > 1) { + ESP_LOGW(TAG, "More than one model found, using the first one"); + } else if (wakenet_model_->num == 0) { + ESP_LOGE(TAG, "No model found"); + return false; + } + char *model_name = wakenet_model_->model_name[0]; + wakenet_iface_ = (esp_wn_iface_t*)esp_wn_handle_from_name(model_name); + wakenet_data_ = wakenet_iface_->create(model_name, DET_MODE_95); + + int frequency = wakenet_iface_->get_samp_rate(wakenet_data_); + int audio_chunksize = wakenet_iface_->get_samp_chunksize(wakenet_data_); + ESP_LOGI(TAG, "Wake word(%s),freq: %d, chunksize: %d", model_name, frequency, audio_chunksize); + + return true; +} + +void EspWakeWord::OnWakeWordDetected(std::function callback) { + wake_word_detected_callback_ = callback; +} + +void EspWakeWord::Start() { + running_ = true; +} + +void EspWakeWord::Stop() { + running_ = false; + + std::lock_guard lock(input_buffer_mutex_); + input_buffer_.clear(); +} + +void EspWakeWord::Feed(const std::vector& data) { + if (wakenet_data_ == nullptr) { + return; + } + + std::lock_guard lock(input_buffer_mutex_); + // Check running state inside lock to avoid TOCTOU race with Stop() + if (!running_) { + return; + } + + if (codec_->input_channels() == 2) { + for (size_t i = 0; i < data.size(); i += 2) { + input_buffer_.push_back(data[i]); + } + } else { + input_buffer_.insert(input_buffer_.end(), data.begin(), data.end()); + } + + int chunksize = wakenet_iface_->get_samp_chunksize(wakenet_data_); + while (input_buffer_.size() >= chunksize) { + int res = wakenet_iface_->detect(wakenet_data_, input_buffer_.data()); + if (res > 0) { + last_detected_wake_word_ = wakenet_iface_->get_word_name(wakenet_data_, res); + running_ = false; + input_buffer_.clear(); + + if (wake_word_detected_callback_) { + wake_word_detected_callback_(last_detected_wake_word_); + } + break; + } + input_buffer_.erase(input_buffer_.begin(), input_buffer_.begin() + chunksize); + } +} + +size_t EspWakeWord::GetFeedSize() { + if (wakenet_data_ == nullptr) { + return 0; + } + return wakenet_iface_->get_samp_chunksize(wakenet_data_); +} + +void EspWakeWord::EncodeWakeWordData() { +} + +bool EspWakeWord::GetWakeWordOpus(std::vector& opus) { + return false; +} diff --git a/main/audio/wake_words/esp_wake_word.h b/main/audio/wake_words/esp_wake_word.h new file mode 100644 index 0000000..87e792d --- /dev/null +++ b/main/audio/wake_words/esp_wake_word.h @@ -0,0 +1,45 @@ +#ifndef ESP_WAKE_WORD_H +#define ESP_WAKE_WORD_H + +#include +#include +#include + +#include +#include +#include +#include +#include + +#include "audio_codec.h" +#include "wake_word.h" + +class EspWakeWord : public WakeWord { +public: + EspWakeWord(); + ~EspWakeWord(); + + bool Initialize(AudioCodec* codec, srmodel_list_t* models_list); + void Feed(const std::vector& data); + void OnWakeWordDetected(std::function callback); + void Start(); + void Stop(); + size_t GetFeedSize(); + void EncodeWakeWordData(); + bool GetWakeWordOpus(std::vector& opus); + const std::string& GetLastDetectedWakeWord() const { return last_detected_wake_word_; } + +private: + esp_wn_iface_t *wakenet_iface_ = nullptr; + model_iface_data_t *wakenet_data_ = nullptr; + srmodel_list_t *wakenet_model_ = nullptr; + AudioCodec* codec_ = nullptr; + std::atomic running_ = false; + + std::function wake_word_detected_callback_; + std::string last_detected_wake_word_; + std::vector input_buffer_; + std::mutex input_buffer_mutex_; +}; + +#endif diff --git a/main/boards/common/adc_battery_monitor.cc b/main/boards/common/adc_battery_monitor.cc new file mode 100644 index 0000000..c34da68 --- /dev/null +++ b/main/boards/common/adc_battery_monitor.cc @@ -0,0 +1,116 @@ +#include "adc_battery_monitor.h" + +AdcBatteryMonitor::AdcBatteryMonitor(adc_unit_t adc_unit, adc_channel_t adc_channel, float upper_resistor, float lower_resistor, gpio_num_t charging_pin) + : charging_pin_(charging_pin) { + + // Initialize charging pin (only if it's not NC) + if (charging_pin_ != GPIO_NUM_NC) { + gpio_config_t gpio_cfg = { + .pin_bit_mask = 1ULL << charging_pin, + .mode = GPIO_MODE_INPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + ESP_ERROR_CHECK(gpio_config(&gpio_cfg)); + } + + // Initialize ADC battery estimation + adc_battery_estimation_t adc_cfg = { + .internal = { + .adc_unit = adc_unit, + .adc_bitwidth = ADC_BITWIDTH_DEFAULT, + .adc_atten = ADC_ATTEN_DB_12, + }, + .adc_channel = adc_channel, + .upper_resistor = upper_resistor, + .lower_resistor = lower_resistor + }; + + // 在ADC配置部分进行条件设置 + if (charging_pin_ != GPIO_NUM_NC) { + adc_cfg.charging_detect_cb = [](void *user_data) -> bool { + AdcBatteryMonitor *self = (AdcBatteryMonitor *)user_data; + return gpio_get_level(self->charging_pin_) == 1; + }; + adc_cfg.charging_detect_user_data = this; + } else { + // 不设置回调,让adc_battery_estimation库使用软件估算 + adc_cfg.charging_detect_cb = nullptr; + adc_cfg.charging_detect_user_data = nullptr; + } + adc_battery_estimation_handle_ = adc_battery_estimation_create(&adc_cfg); + + // Initialize timer + esp_timer_create_args_t timer_cfg = { + .callback = [](void *arg) { + AdcBatteryMonitor *self = (AdcBatteryMonitor *)arg; + self->CheckBatteryStatus(); + }, + .arg = this, + .name = "adc_battery_monitor", + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_cfg, &timer_handle_)); + ESP_ERROR_CHECK(esp_timer_start_periodic(timer_handle_, 1000000)); +} + +AdcBatteryMonitor::~AdcBatteryMonitor() { + if (adc_battery_estimation_handle_) { + ESP_ERROR_CHECK(adc_battery_estimation_destroy(adc_battery_estimation_handle_)); + } + + if (timer_handle_) { + esp_timer_stop(timer_handle_); + esp_timer_delete(timer_handle_); + } +} + +bool AdcBatteryMonitor::IsCharging() { + // 优先使用adc_battery_estimation库的功能 + if (adc_battery_estimation_handle_ != nullptr) { + bool is_charging = false; + esp_err_t err = adc_battery_estimation_get_charging_state(adc_battery_estimation_handle_, &is_charging); + if (err == ESP_OK) { + return is_charging; + } + } + + // 回退到GPIO读取或返回默认值 + if (charging_pin_ != GPIO_NUM_NC) { + return gpio_get_level(charging_pin_) == 1; + } + + return false; +} + +bool AdcBatteryMonitor::IsDischarging() { + return !IsCharging(); +} + +uint8_t AdcBatteryMonitor::GetBatteryLevel() { + // 如果句柄无效,返回默认值 + if (adc_battery_estimation_handle_ == nullptr) { + return 100; + } + + float capacity = 0; + esp_err_t err = adc_battery_estimation_get_capacity(adc_battery_estimation_handle_, &capacity); + if (err != ESP_OK) { + return 100; // 出错时返回默认值 + } + return (uint8_t)capacity; +} + +void AdcBatteryMonitor::OnChargingStatusChanged(std::function callback) { + on_charging_status_changed_ = callback; +} + +void AdcBatteryMonitor::CheckBatteryStatus() { + bool new_charging_status = IsCharging(); + if (new_charging_status != is_charging_) { + is_charging_ = new_charging_status; + if (on_charging_status_changed_) { + on_charging_status_changed_(is_charging_); + } + } +} \ No newline at end of file diff --git a/main/boards/common/adc_battery_monitor.h b/main/boards/common/adc_battery_monitor.h new file mode 100644 index 0000000..6a123a7 --- /dev/null +++ b/main/boards/common/adc_battery_monitor.h @@ -0,0 +1,30 @@ +#ifndef ADC_BATTERY_MONITOR_H +#define ADC_BATTERY_MONITOR_H + +#include +#include +#include +#include + +class AdcBatteryMonitor { +public: + AdcBatteryMonitor(adc_unit_t adc_unit, adc_channel_t adc_channel, float upper_resistor, float lower_resistor, gpio_num_t charging_pin = GPIO_NUM_NC); + ~AdcBatteryMonitor(); + + bool IsCharging(); + bool IsDischarging(); + uint8_t GetBatteryLevel(); + + void OnChargingStatusChanged(std::function callback); + +private: + gpio_num_t charging_pin_; + adc_battery_estimation_handle_t adc_battery_estimation_handle_ = nullptr; + esp_timer_handle_t timer_handle_ = nullptr; + bool is_charging_ = false; + std::function on_charging_status_changed_; + + void CheckBatteryStatus(); +}; + +#endif // ADC_BATTERY_MONITOR_H diff --git a/main/boards/common/afsk_demod.cc b/main/boards/common/afsk_demod.cc new file mode 100644 index 0000000..0fe41ea --- /dev/null +++ b/main/boards/common/afsk_demod.cc @@ -0,0 +1,375 @@ +#include "afsk_demod.h" +#include +#include +#include "esp_log.h" +#include "display.h" +#include "ssid_manager.h" + +#ifndef M_PI +#define M_PI 3.14159265358979323846 +#endif + +namespace audio_wifi_config +{ + static const char *kLogTag = "AUDIO_WIFI_CONFIG"; + + void ReceiveWifiCredentialsFromAudio(Application *app, + WifiManager *wifi_manager, + Display *display, + size_t input_channels + ) + { + const int kInputSampleRate = 16000; // Input sampling rate + const float kDownsampleStep = static_cast(kInputSampleRate) / static_cast(kAudioSampleRate); // Downsampling step + std::vector audio_data; + AudioSignalProcessor signal_processor(kAudioSampleRate, kMarkFrequency, kSpaceFrequency, kBitRate, kWindowSize); + AudioDataBuffer data_buffer; + + while (true) + { + // 检查Application状态,只有在WiFi配置模式下才处理音频 + if (app->GetDeviceState() != kDeviceStateWifiConfiguring) { + // 不在WiFi配置状态,休眠100ms后再检查 + vTaskDelay(pdMS_TO_TICKS(100)); + continue; + } + + if (!app->GetAudioService().ReadAudioData(audio_data, 16000, 480)) { // 16kHz, 480 samples corresponds to 30ms data + // 读取音频失败,短暂延迟后重试 + ESP_LOGI(kLogTag, "Failed to read audio data, retrying."); + vTaskDelay(pdMS_TO_TICKS(10)); + continue; + } + + if (input_channels == 2) { // 如果是双声道输入,转换为单声道 + auto mono_data = std::vector(audio_data.size() / 2); + for (size_t i = 0, j = 0; i < mono_data.size(); ++i, j += 2) { + mono_data[i] = audio_data[j]; + } + audio_data = std::move(mono_data); + } + + // Downsample the audio data + std::vector downsampled_data; + size_t last_index = 0; + + if (kDownsampleStep > 1.0f) { + downsampled_data.reserve(audio_data.size() / static_cast(kDownsampleStep)); + for (size_t i = 0; i < audio_data.size(); ++i) { + size_t sample_index = static_cast(i / kDownsampleStep); + if ((sample_index + 1) > last_index) { + downsampled_data.push_back(static_cast(audio_data[i])); + last_index = sample_index + 1; + } + } + } else { + downsampled_data.reserve(audio_data.size()); + for (int16_t sample : audio_data) { + downsampled_data.push_back(static_cast(sample)); + } + } + + // Process audio samples to get probability data + auto probabilities = signal_processor.ProcessAudioSamples(downsampled_data); + + // Feed probability data to the data buffer + if (data_buffer.ProcessProbabilityData(probabilities, 0.5f)) { + // If complete data was received, extract WiFi credentials + if (data_buffer.decoded_text.has_value()) { + ESP_LOGI(kLogTag, "Received text data: %s", data_buffer.decoded_text->c_str()); + display->SetChatMessage("system", data_buffer.decoded_text->c_str()); + + // Split SSID and password by newline character + std::string wifi_ssid, wifi_password; + size_t newline_position = data_buffer.decoded_text->find('\n'); + if (newline_position != std::string::npos) { + wifi_ssid = data_buffer.decoded_text->substr(0, newline_position); + wifi_password = data_buffer.decoded_text->substr(newline_position + 1); + ESP_LOGI(kLogTag, "WiFi SSID: %s, Password: %s", wifi_ssid.c_str(), wifi_password.c_str()); + } else { + ESP_LOGE(kLogTag, "Invalid data format, no newline character found"); + continue; + } + + // Save WiFi credentials using SsidManager + auto& ssid_manager = SsidManager::GetInstance(); + ssid_manager.AddSsid(wifi_ssid, wifi_password); + ESP_LOGI(kLogTag, "WiFi credentials saved successfully"); + + // Exit config mode (triggers ConfigModeExit event) + wifi_manager->StopConfigAp(); + + data_buffer.decoded_text.reset(); // Clear processed data + return; // Exit the function + } + } + vTaskDelay(pdMS_TO_TICKS(1)); // 1ms delay + } + } + + // Default start and end transmission identifiers + // \x01\x02 = 00000001 00000010 + const std::vector kDefaultStartTransmissionPattern = { + 0, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 0}; + + // \x03\x04 = 00000011 00000100 + const std::vector kDefaultEndTransmissionPattern = { + 0, 0, 0, 0, 0, 0, 1, 1, 0, 0, 0, 0, 0, 1, 0, 0}; + + // FrequencyDetector implementation + FrequencyDetector::FrequencyDetector(float frequency, size_t window_size) + : frequency_(frequency), window_size_(window_size) { + frequency_bin_ = std::floor(frequency_ * static_cast(window_size_)); + angular_frequency_ = 2.0f * M_PI * frequency_; + cos_coefficient_ = std::cos(angular_frequency_); + sin_coefficient_ = std::sin(angular_frequency_); + filter_coefficient_ = 2.0f * cos_coefficient_; + + // Initialize state buffer + state_buffer_.push_back(0.0f); + state_buffer_.push_back(0.0f); + } + + void FrequencyDetector::Reset() { + state_buffer_.clear(); + state_buffer_.push_back(0.0f); + state_buffer_.push_back(0.0f); + } + + void FrequencyDetector::ProcessSample(float sample) { + if (state_buffer_.size() < 2) { + return; + } + + float s_minus_2 = state_buffer_.front(); // S[-2] + state_buffer_.pop_front(); + float s_minus_1 = state_buffer_.front(); // S[-1] + state_buffer_.pop_front(); + + float s_current = sample + filter_coefficient_ * s_minus_1 - s_minus_2; + + state_buffer_.push_back(s_minus_1); // Put S[-1] back + state_buffer_.push_back(s_current); // Add new S[0] + } + + float FrequencyDetector::GetAmplitude() const { + if (state_buffer_.size() < 2) { + return 0.0f; + } + + float s_minus_1 = state_buffer_[1]; // S[-1] + float s_minus_2 = state_buffer_[0]; // S[-2] + float real_part = cos_coefficient_ * s_minus_1 - s_minus_2; // Real part + float imaginary_part = sin_coefficient_ * s_minus_1; // Imaginary part + + return std::sqrt(real_part * real_part + imaginary_part * imaginary_part) / + (static_cast(window_size_) / 2.0f); + } + + // AudioSignalProcessor implementation + AudioSignalProcessor::AudioSignalProcessor(size_t sample_rate, size_t mark_frequency, size_t space_frequency, + size_t bit_rate, size_t window_size) + : input_buffer_size_(window_size), output_sample_count_(0) { + if (sample_rate % bit_rate != 0) { + // On ESP32 we can continue execution, but log the error + ESP_LOGW(kLogTag, "Sample rate %zu is not divisible by bit rate %zu", sample_rate, bit_rate); + } + + float normalized_mark_freq = static_cast(mark_frequency) / static_cast(sample_rate); + float normalized_space_freq = static_cast(space_frequency) / static_cast(sample_rate); + + mark_detector_ = std::make_unique(normalized_mark_freq, window_size); + space_detector_ = std::make_unique(normalized_space_freq, window_size); + + samples_per_bit_ = sample_rate / bit_rate; // Number of samples per bit + } + + std::vector AudioSignalProcessor::ProcessAudioSamples(const std::vector &samples) { + std::vector result; + + for (float sample : samples) { + if (input_buffer_.size() < input_buffer_size_) { + input_buffer_.push_back(sample); // Just add, don't process yet + } else { + // Input buffer is full, process the data + input_buffer_.pop_front(); // Remove oldest sample + input_buffer_.push_back(sample); // Add new sample + output_sample_count_++; + + if (output_sample_count_ >= samples_per_bit_) { + // Process all samples in the window using Goertzel algorithm + for (float window_sample : input_buffer_) { + mark_detector_->ProcessSample(window_sample); + space_detector_->ProcessSample(window_sample); + } + + float mark_amplitude = mark_detector_->GetAmplitude(); // Mark amplitude + float space_amplitude = space_detector_->GetAmplitude(); // Space amplitude + + // Avoid division by zero + float mark_probability = mark_amplitude / + (space_amplitude + mark_amplitude + std::numeric_limits::epsilon()); + result.push_back(mark_probability); + + // Reset detector windows + mark_detector_->Reset(); + space_detector_->Reset(); + output_sample_count_ = 0; // Reset output counter + } + } + } + + return result; + } + + // AudioDataBuffer implementation + AudioDataBuffer::AudioDataBuffer() + : current_state_(DataReceptionState::kInactive), + start_of_transmission_(kDefaultStartTransmissionPattern), + end_of_transmission_(kDefaultEndTransmissionPattern), + enable_checksum_validation_(true) { + identifier_buffer_size_ = std::max(start_of_transmission_.size(), end_of_transmission_.size()); + max_bit_buffer_size_ = 776; // Preset bit buffer size, 776 bits = (32 + 1 + 63 + 1) * 8 = 776 + + bit_buffer_.reserve(max_bit_buffer_size_); + } + + AudioDataBuffer::AudioDataBuffer(size_t max_byte_size, const std::vector &start_identifier, + const std::vector &end_identifier, bool enable_checksum) + : current_state_(DataReceptionState::kInactive), + start_of_transmission_(start_identifier), + end_of_transmission_(end_identifier), + enable_checksum_validation_(enable_checksum) { + identifier_buffer_size_ = std::max(start_of_transmission_.size(), end_of_transmission_.size()); + max_bit_buffer_size_ = max_byte_size * 8; // Bit buffer size in bytes + + bit_buffer_.reserve(max_bit_buffer_size_); + } + + uint8_t AudioDataBuffer::CalculateChecksum(const std::string &text) { + uint8_t checksum = 0; + for (char character : text) { + checksum += static_cast(character); + } + return checksum; + } + + void AudioDataBuffer::ClearBuffers() { + identifier_buffer_.clear(); + bit_buffer_.clear(); + } + + bool AudioDataBuffer::ProcessProbabilityData(const std::vector &probabilities, float threshold) { + for (float probability : probabilities) { + uint8_t bit = (probability > threshold) ? 1 : 0; + + if (identifier_buffer_.size() >= identifier_buffer_size_) { + identifier_buffer_.pop_front(); // Maintain buffer size + } + identifier_buffer_.push_back(bit); + + // Process received bit based on state machine + switch (current_state_) { + case DataReceptionState::kInactive: + if (identifier_buffer_.size() >= start_of_transmission_.size()) { + current_state_ = DataReceptionState::kWaiting; // Enter waiting state + ESP_LOGI(kLogTag, "Entering Waiting state"); + } + break; + + case DataReceptionState::kWaiting: + // Waiting state, possibly waiting for transmission end + if (identifier_buffer_.size() >= start_of_transmission_.size()) { + std::vector identifier_snapshot(identifier_buffer_.begin(), identifier_buffer_.end()); + if (identifier_snapshot == start_of_transmission_) + { + ClearBuffers(); // Clear buffers + current_state_ = DataReceptionState::kReceiving; // Enter receiving state + ESP_LOGI(kLogTag, "Entering Receiving state"); + } + } + break; + + case DataReceptionState::kReceiving: + bit_buffer_.push_back(bit); + if (identifier_buffer_.size() >= end_of_transmission_.size()) { + std::vector identifier_snapshot(identifier_buffer_.begin(), identifier_buffer_.end()); + if (identifier_snapshot == end_of_transmission_) { + current_state_ = DataReceptionState::kInactive; // Enter inactive state + + // Convert bits to bytes + std::vector bytes = ConvertBitsToBytes(bit_buffer_); + + uint8_t received_checksum = 0; + size_t minimum_length = 0; + + if (enable_checksum_validation_) { + // If checksum is required, last byte is checksum + minimum_length = 1 + start_of_transmission_.size() / 8; + if (bytes.size() >= minimum_length) + { + received_checksum = bytes[bytes.size() - start_of_transmission_.size() / 8 - 1]; + } + } else { + minimum_length = start_of_transmission_.size() / 8; + } + + if (bytes.size() < minimum_length) { + ClearBuffers(); + ESP_LOGW(kLogTag, "Data too short, clearing buffer"); + return false; // Data too short, return failure + } + + // Extract text data (remove trailing identifier part) + std::vector text_bytes( + bytes.begin(), bytes.begin() + bytes.size() - minimum_length); + + std::string result(text_bytes.begin(), text_bytes.end()); + + // Validate checksum if required + if (enable_checksum_validation_) { + uint8_t calculated_checksum = CalculateChecksum(result); + if (calculated_checksum != received_checksum) { + // Checksum mismatch + ESP_LOGW(kLogTag, "Checksum mismatch: expected %d, got %d", + received_checksum, calculated_checksum); + ClearBuffers(); + return false; + } + } + + ClearBuffers(); + decoded_text = result; + return true; // Return success + } else if (bit_buffer_.size() >= max_bit_buffer_size_) { + // If not end identifier and bit buffer is full, reset + ClearBuffers(); + ESP_LOGW(kLogTag, "Buffer overflow, clearing buffer"); + current_state_ = DataReceptionState::kInactive; // Reset state machine + } + } + break; + } + } + + return false; + } + + std::vector AudioDataBuffer::ConvertBitsToBytes(const std::vector &bits) const { + std::vector bytes; + + // Ensure number of bits is a multiple of 8 + size_t complete_bytes_count = bits.size() / 8; + bytes.reserve(complete_bytes_count); + + for (size_t i = 0; i < complete_bytes_count; ++i) { + uint8_t byte_value = 0; + for (size_t j = 0; j < 8; ++j) { + byte_value |= bits[i * 8 + j] << (7 - j); + } + bytes.push_back(byte_value); + } + + return bytes; + } +} diff --git a/main/boards/common/afsk_demod.h b/main/boards/common/afsk_demod.h new file mode 100644 index 0000000..4ac0ab6 --- /dev/null +++ b/main/boards/common/afsk_demod.h @@ -0,0 +1,177 @@ +#pragma once + +#include +#include +#include +#include +#include +#include +#include "wifi_manager.h" +#include "application.h" + +// Audio signal processing constants for WiFi configuration via audio +const size_t kAudioSampleRate = 6400; +const size_t kMarkFrequency = 1800; +const size_t kSpaceFrequency = 1500; +const size_t kBitRate = 100; +const size_t kWindowSize = 64; + +namespace audio_wifi_config +{ + // Main function to receive WiFi credentials through audio signal + void ReceiveWifiCredentialsFromAudio(Application *app, WifiManager *wifi_manager, Display *display, + size_t input_channels = 1); + + /** + * Goertzel algorithm implementation for single frequency detection + * Used to detect specific audio frequencies in the AFSK demodulation process + */ + class FrequencyDetector + { + private: + float frequency_; // Target frequency (normalized, i.e., f / fs) + size_t window_size_; // Window size for analysis + float frequency_bin_; // Frequency bin + float angular_frequency_; // Angular frequency + float cos_coefficient_; // cos(w) + float sin_coefficient_; // sin(w) + float filter_coefficient_; // 2 * cos(w) + std::deque state_buffer_; // Circular buffer for storing S[-1] and S[-2] + + public: + /** + * Constructor + * @param frequency Normalized frequency (f / fs) + * @param window_size Window size for analysis + */ + FrequencyDetector(float frequency, size_t window_size); + + /** + * Reset the detector state + */ + void Reset(); + + /** + * Process one audio sample + * @param sample Input audio sample + */ + void ProcessSample(float sample); + + /** + * Calculate current amplitude + * @return Amplitude value + */ + float GetAmplitude() const; + }; + + /** + * Audio signal processor for Mark/Space frequency pair detection + * Processes audio signals to extract digital data using AFSK demodulation + */ + class AudioSignalProcessor + { + private: + std::deque input_buffer_; // Input sample buffer + size_t input_buffer_size_; // Input buffer size = window size + size_t output_sample_count_; // Output sample counter + size_t samples_per_bit_; // Samples per bit threshold + std::unique_ptr mark_detector_; // Mark frequency detector + std::unique_ptr space_detector_; // Space frequency detector + + public: + /** + * Constructor + * @param sample_rate Audio sampling rate + * @param mark_frequency Mark frequency for digital '1' + * @param space_frequency Space frequency for digital '0' + * @param bit_rate Data transmission bit rate + * @param window_size Analysis window size + */ + AudioSignalProcessor(size_t sample_rate, size_t mark_frequency, size_t space_frequency, + size_t bit_rate, size_t window_size); + + /** + * Process input audio samples + * @param samples Input audio sample vector + * @return Vector of Mark probability values (0.0 to 1.0) + */ + std::vector ProcessAudioSamples(const std::vector &samples); + }; + + /** + * Data reception state machine states + */ + enum class DataReceptionState + { + kInactive, // Waiting for start signal + kWaiting, // Detected potential start, waiting for confirmation + kReceiving // Actively receiving data + }; + + /** + * Data buffer for managing audio-to-digital data conversion + * Handles the complete process from audio signal to decoded text data + */ + class AudioDataBuffer + { + private: + DataReceptionState current_state_; // Current reception state + std::deque identifier_buffer_; // Buffer for start/end identifier detection + size_t identifier_buffer_size_; // Identifier buffer size + std::vector bit_buffer_; // Buffer for storing bit stream + size_t max_bit_buffer_size_; // Maximum bit buffer size + const std::vector start_of_transmission_; // Start-of-transmission identifier + const std::vector end_of_transmission_; // End-of-transmission identifier + bool enable_checksum_validation_; // Whether to validate checksum + + public: + std::optional decoded_text; // Successfully decoded text data + + /** + * Default constructor using predefined start and end identifiers + */ + AudioDataBuffer(); + + /** + * Constructor with custom parameters + * @param max_byte_size Expected maximum data size in bytes + * @param start_identifier Start-of-transmission identifier + * @param end_identifier End-of-transmission identifier + * @param enable_checksum Whether to enable checksum validation + */ + AudioDataBuffer(size_t max_byte_size, const std::vector &start_identifier, + const std::vector &end_identifier, bool enable_checksum = false); + + /** + * Process probability data and attempt to decode + * @param probabilities Vector of Mark probabilities + * @param threshold Decision threshold for bit detection + * @return true if complete data was successfully received and decoded + */ + bool ProcessProbabilityData(const std::vector &probabilities, float threshold = 0.5f); + + /** + * Calculate checksum for ASCII text + * @param text Input text string + * @return Checksum value (0-255) + */ + static uint8_t CalculateChecksum(const std::string &text); + + private: + /** + * Convert bit vector to byte vector + * @param bits Input bit vector + * @return Converted byte vector + */ + std::vector ConvertBitsToBytes(const std::vector &bits) const; + + /** + * Clear all buffers and reset state + */ + void ClearBuffers(); + }; + + // Default start and end transmission identifiers + extern const std::vector kDefaultStartTransmissionPattern; + extern const std::vector kDefaultEndTransmissionPattern; +} \ No newline at end of file diff --git a/main/boards/common/axp2101.cc b/main/boards/common/axp2101.cc new file mode 100644 index 0000000..854f0b8 --- /dev/null +++ b/main/boards/common/axp2101.cc @@ -0,0 +1,41 @@ +#include "axp2101.h" +#include "board.h" +#include "display.h" + +#include + +#define TAG "Axp2101" + +Axp2101::Axp2101(i2c_master_bus_handle_t i2c_bus, uint8_t addr) : I2cDevice(i2c_bus, addr) { +} + +int Axp2101::GetBatteryCurrentDirection() { + return (ReadReg(0x01) & 0b01100000) >> 5; +} + +bool Axp2101::IsCharging() { + return GetBatteryCurrentDirection() == 1; +} + +bool Axp2101::IsDischarging() { + return GetBatteryCurrentDirection() == 2; +} + +bool Axp2101::IsChargingDone() { + uint8_t value = ReadReg(0x01); + return (value & 0b00000111) == 0b00000100; +} + +int Axp2101::GetBatteryLevel() { + return ReadReg(0xA4); +} + +float Axp2101::GetTemperature() { + return ReadReg(0xA5); +} + +void Axp2101::PowerOff() { + uint8_t value = ReadReg(0x10); + value = value | 0x01; + WriteReg(0x10, value); +} diff --git a/main/boards/common/axp2101.h b/main/boards/common/axp2101.h new file mode 100644 index 0000000..473cd3e --- /dev/null +++ b/main/boards/common/axp2101.h @@ -0,0 +1,20 @@ +#ifndef __AXP2101_H__ +#define __AXP2101_H__ + +#include "i2c_device.h" + +class Axp2101 : public I2cDevice { +public: + Axp2101(i2c_master_bus_handle_t i2c_bus, uint8_t addr); + bool IsCharging(); + bool IsDischarging(); + bool IsChargingDone(); + int GetBatteryLevel(); + float GetTemperature(); + void PowerOff(); + +private: + int GetBatteryCurrentDirection(); +}; + +#endif diff --git a/main/boards/common/backlight.cc b/main/boards/common/backlight.cc new file mode 100644 index 0000000..c62fd7a --- /dev/null +++ b/main/boards/common/backlight.cc @@ -0,0 +1,121 @@ +#include "backlight.h" +#include "settings.h" + +#include +#include + +#define TAG "Backlight" + + +Backlight::Backlight() { + // 创建背光渐变定时器 + const esp_timer_create_args_t timer_args = { + .callback = [](void* arg) { + auto self = static_cast(arg); + self->OnTransitionTimer(); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "backlight_timer", + .skip_unhandled_events = true, + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &transition_timer_)); +} + +Backlight::~Backlight() { + if (transition_timer_ != nullptr) { + esp_timer_stop(transition_timer_); + esp_timer_delete(transition_timer_); + } +} + +void Backlight::RestoreBrightness() { + // Load brightness from settings + Settings settings("display"); + int saved_brightness = settings.GetInt("brightness", 75); + + // 检查亮度值是否为0或过小,设置默认值 + if (saved_brightness <= 0) { + ESP_LOGW(TAG, "Brightness value (%d) is too small, setting to default (10)", saved_brightness); + saved_brightness = 10; // 设置一个较低的默认值 + } + + SetBrightness(saved_brightness); +} + +void Backlight::SetBrightness(uint8_t brightness, bool permanent) { + if (brightness > 100) { + brightness = 100; + } + + if (brightness_ == brightness) { + return; + } + + if (permanent) { + Settings settings("display", true); + settings.SetInt("brightness", brightness); + } + + target_brightness_ = brightness; + step_ = (target_brightness_ > brightness_) ? 1 : -1; + + if (transition_timer_ != nullptr) { + // 启动定时器,每 5ms 更新一次 + esp_timer_start_periodic(transition_timer_, 5 * 1000); + } + ESP_LOGI(TAG, "Set brightness to %d", brightness); +} + +void Backlight::OnTransitionTimer() { + if (brightness_ == target_brightness_) { + esp_timer_stop(transition_timer_); + return; + } + + brightness_ += step_; + SetBrightnessImpl(brightness_); + + if (brightness_ == target_brightness_) { + esp_timer_stop(transition_timer_); + } +} + +PwmBacklight::PwmBacklight(gpio_num_t pin, bool output_invert, uint32_t freq_hz) : Backlight() { + const ledc_timer_config_t backlight_timer = { + .speed_mode = LEDC_LOW_SPEED_MODE, + .duty_resolution = LEDC_TIMER_10_BIT, + .timer_num = LEDC_TIMER_0, + .freq_hz = freq_hz, //背光pwm频率需要高一点,防止电感啸叫 + .clk_cfg = LEDC_AUTO_CLK, + .deconfigure = false + }; + ESP_ERROR_CHECK(ledc_timer_config(&backlight_timer)); + + // Setup LEDC peripheral for PWM backlight control + const ledc_channel_config_t backlight_channel = { + .gpio_num = pin, + .speed_mode = LEDC_LOW_SPEED_MODE, + .channel = LEDC_CHANNEL_0, + .intr_type = LEDC_INTR_DISABLE, + .timer_sel = LEDC_TIMER_0, + .duty = 0, + .hpoint = 0, + .flags = { + .output_invert = output_invert, + } + }; + ESP_ERROR_CHECK(ledc_channel_config(&backlight_channel)); +} + +PwmBacklight::~PwmBacklight() { + ledc_stop(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, 0); +} + +void PwmBacklight::SetBrightnessImpl(uint8_t brightness) { + // LEDC resolution set to 10bits, thus: 100% = 1023 + uint32_t duty_cycle = (1023 * brightness) / 100; + ledc_set_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0, duty_cycle); + ledc_update_duty(LEDC_LOW_SPEED_MODE, LEDC_CHANNEL_0); +} + diff --git a/main/boards/common/backlight.h b/main/boards/common/backlight.h new file mode 100644 index 0000000..5c09b3d --- /dev/null +++ b/main/boards/common/backlight.h @@ -0,0 +1,36 @@ +#pragma once + +#include +#include + +#include +#include + + +class Backlight { +public: + Backlight(); + ~Backlight(); + + void RestoreBrightness(); + void SetBrightness(uint8_t brightness, bool permanent = false); + inline uint8_t brightness() const { return brightness_; } + +protected: + void OnTransitionTimer(); + virtual void SetBrightnessImpl(uint8_t brightness) = 0; + + esp_timer_handle_t transition_timer_ = nullptr; + uint8_t brightness_ = 0; + uint8_t target_brightness_ = 0; + uint8_t step_ = 1; +}; + + +class PwmBacklight : public Backlight { +public: + PwmBacklight(gpio_num_t pin, bool output_invert = false, uint32_t freq_hz = 25000); + ~PwmBacklight(); + + void SetBrightnessImpl(uint8_t brightness) override; +}; diff --git a/main/boards/common/blufi.cpp b/main/boards/common/blufi.cpp new file mode 100644 index 0000000..c4be933 --- /dev/null +++ b/main/boards/common/blufi.cpp @@ -0,0 +1,899 @@ +#include "blufi.h" +#include +#include +#include +#include +#include +#include "esp_bt.h" +#include "esp_event.h" +#include "esp_log.h" +#include "esp_timer.h" +#include "esp_wifi.h" +#include "freertos/task.h" +#include "wifi_manager.h" + +#define BLUFI_DEVICE_NAME "Xiaozhi-Blufi" + +#ifdef CONFIG_BT_BLUEDROID_ENABLED +#include "esp_bt_device.h" +#include "esp_bt_main.h" +#include "esp_gap_ble_api.h" +#endif + +#ifdef CONFIG_BT_NIMBLE_ENABLED +#include "console/console.h" +#include "host/ble_hs.h" +#include "nimble/nimble_port.h" +#include "nimble/nimble_port_freertos.h" +#include "services/gap/ble_svc_gap.h" +extern void esp_blufi_gatt_svr_register_cb(struct ble_gatt_register_ctxt* ctxt, void* arg); +extern int esp_blufi_gatt_svr_init(void); +extern void esp_blufi_gatt_svr_deinit(void); +extern void esp_blufi_btc_init(void); +extern void esp_blufi_btc_deinit(void); +#endif + +extern "C" { +void esp_blufi_adv_start(void); + +void esp_blufi_adv_stop(void); + +void esp_blufi_disconnect(void); + +void btc_blufi_report_error(esp_blufi_error_state_t state); + +#ifdef CONFIG_BT_BLUEDROID_ENABLED +void esp_blufi_gap_event_handler(esp_gap_ble_cb_event_t event, esp_ble_gap_cb_param_t* param); +#endif + +#ifdef CONFIG_BT_NIMBLE_ENABLED +void esp_blufi_gatt_svr_register_cb(struct ble_gatt_register_ctxt* ctxt, void* arg); +int esp_blufi_gatt_svr_init(void); +void esp_blufi_gatt_svr_deinit(void); +void esp_blufi_btc_init(void); +void esp_blufi_btc_deinit(void); +#endif +} + +#include +#include "esp_crc.h" +#include "esp_random.h" +#include "mbedtls/md5.h" +#include "ssid_manager.h" + +static const char* BLUFI_TAG = "BLUFI_CLASS"; + +static wifi_mode_t GetWifiModeWithFallback(const WifiManager& wifi) { + if (wifi.IsConfigMode()) { + return WIFI_MODE_AP; + } + if (wifi.IsInitialized() && wifi.IsConnected()) { + return WIFI_MODE_STA; + } + + wifi_mode_t mode = WIFI_MODE_STA; + esp_wifi_get_mode(&mode); + return mode; +} + +Blufi& Blufi::GetInstance() { + static Blufi instance; + return instance; +} + +Blufi::Blufi() + : m_sec(nullptr), + m_ble_is_connected(false), + m_sta_connected(false), + m_sta_got_ip(false), + m_provisioned(false), + m_deinited(false), + m_sta_ssid_len(0), + m_sta_is_connecting(false) { + memset(&m_sta_config, 0, sizeof(m_sta_config)); + memset(m_sta_bssid, 0, sizeof(m_sta_bssid)); + memset(m_sta_ssid, 0, sizeof(m_sta_ssid)); + memset(&m_sta_conn_info, 0, sizeof(m_sta_conn_info)); +} + +Blufi::~Blufi() { + if (m_sec) { + _security_deinit(); + } +} + +esp_err_t Blufi::init() { + esp_err_t ret = ESP_FAIL; + inited_ = true; + m_provisioned = false; + m_deinited = false; + + // Start WiFi scan early to have results ready when user connects + auto& wifi_manager = WifiManager::GetInstance(); + if (!wifi_manager.IsInitialized() || !wifi_manager.IsConfigMode()) { + // start scan immediately + start_wifi_scan(); + } else { + ESP_LOGE(BLUFI_TAG, + "Blufi and WiFi hotspot network configuration cannot " + "be used simultaneously."); + return ret; + } + +#if CONFIG_BT_CONTROLLER_ENABLED || !CONFIG_BT_NIMBLE_ENABLED + ret = _controller_init(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "BLUFI controller init failed: %s", esp_err_to_name(ret)); + return ret; + } +#endif + + ret = _host_and_cb_init(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "BLUFI host and cb init failed: %s", esp_err_to_name(ret)); + return ret; + } + + ESP_LOGI(BLUFI_TAG, "BLUFI VERSION %04x", esp_blufi_get_version()); + return ESP_OK; +} + +esp_err_t Blufi::deinit() { + esp_err_t ret = ESP_OK; + + if (inited_) { + if (m_deinited) { + return ESP_OK; + } + m_deinited = true; + ret = _host_deinit(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "Host deinit failed: %s", esp_err_to_name(ret)); + } +#if CONFIG_BT_CONTROLLER_ENABLED || !CONFIG_BT_NIMBLE_ENABLED + ret = _controller_deinit(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "Controller deinit failed: %s", esp_err_to_name(ret)); + } +#endif + } + return ret; +} + +#ifdef CONFIG_BT_BLUEDROID_ENABLED +esp_err_t Blufi::_host_init() { + esp_err_t ret = esp_bluedroid_init(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s init bluedroid failed: %s", __func__, esp_err_to_name(ret)); + return ESP_FAIL; + } + ret = esp_bluedroid_enable(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s enable bluedroid failed: %s", __func__, esp_err_to_name(ret)); + return ESP_FAIL; + } + ESP_LOGI(BLUFI_TAG, "BD ADDR: " ESP_BD_ADDR_STR, ESP_BD_ADDR_HEX(esp_bt_dev_get_address())); + return ESP_OK; +} + +esp_err_t Blufi::_host_deinit() { + esp_err_t ret = esp_blufi_profile_deinit(); + if (ret != ESP_OK) + return ret; + + ret = esp_bluedroid_disable(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s disable bluedroid failed: %s", __func__, esp_err_to_name(ret)); + return ESP_FAIL; + } + ret = esp_bluedroid_deinit(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s deinit bluedroid failed: %s", __func__, esp_err_to_name(ret)); + return ESP_FAIL; + } + return ESP_OK; +} + +esp_err_t Blufi::_gap_register_callback() { + esp_err_t rc = esp_ble_gap_register_callback(esp_blufi_gap_event_handler); + if (rc) { + return rc; + } + return esp_blufi_profile_init(); +} + +esp_err_t Blufi::_host_and_cb_init() { + static esp_blufi_callbacks_t blufi_callbacks = { + .event_cb = &_event_callback_trampoline, + .negotiate_data_handler = &_negotiate_data_handler_trampoline, + .encrypt_func = &_encrypt_func_trampoline, + .decrypt_func = &_decrypt_func_trampoline, + .checksum_func = &_checksum_func_trampoline, + }; + + esp_err_t ret = _host_init(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s initialise host failed: %s", __func__, esp_err_to_name(ret)); + return ret; + } + ret = esp_blufi_register_callbacks(&blufi_callbacks); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s blufi register failed, error code = %x", __func__, ret); + return ret; + } + ret = _gap_register_callback(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s gap register failed, error code = %x", __func__, ret); + return ret; + } + return ESP_OK; +} +#endif /* CONFIG_BT_BLUEDROID_ENABLED */ + +#ifdef CONFIG_BT_NIMBLE_ENABLED +// Stubs for NimBLE specific store functionality +void ble_store_config_init(); + +void Blufi::_nimble_on_reset(int reason) { + ESP_LOGE(BLUFI_TAG, "NimBLE Resetting state; reason=%d", reason); +} + +void Blufi::_nimble_on_sync() { esp_blufi_profile_init(); } + +void Blufi::_nimble_host_task(void* param) { + ESP_LOGI(BLUFI_TAG, "BLE Host Task Started"); + nimble_port_run(); + nimble_port_freertos_deinit(); +} + +esp_err_t Blufi::_host_init() { + ble_hs_cfg.reset_cb = _nimble_on_reset; + ble_hs_cfg.sync_cb = _nimble_on_sync; + ble_hs_cfg.gatts_register_cb = esp_blufi_gatt_svr_register_cb; + + ble_hs_cfg.sm_io_cap = 4; +#ifdef CONFIG_EXAMPLE_BONDING + ble_hs_cfg.sm_bonding = 1; +#endif + + int rc = esp_blufi_gatt_svr_init(); + assert(rc == 0); + + ble_store_config_init(); + esp_blufi_btc_init(); + + esp_err_t err = esp_nimble_enable(_nimble_host_task); + if (err) { + ESP_LOGE(BLUFI_TAG, "%s failed: %s", __func__, esp_err_to_name(err)); + return ESP_FAIL; + } + return ESP_OK; +} + +esp_err_t Blufi::_host_deinit(void) { + esp_err_t ret = nimble_port_stop(); + if (ret == ESP_OK) { + esp_nimble_deinit(); + } + esp_blufi_gatt_svr_deinit(); + ret = esp_blufi_profile_deinit(); + esp_blufi_btc_deinit(); + return ret; +} + +esp_err_t Blufi::_gap_register_callback(void) { return ESP_OK; } + +esp_err_t Blufi::_host_and_cb_init() { + static esp_blufi_callbacks_t blufi_callbacks = { + .event_cb = &_event_callback_trampoline, + .negotiate_data_handler = &_negotiate_data_handler_trampoline, + .encrypt_func = &_encrypt_func_trampoline, + .decrypt_func = &_decrypt_func_trampoline, + .checksum_func = &_checksum_func_trampoline, + }; + + esp_err_t ret = esp_blufi_register_callbacks(&blufi_callbacks); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s blufi register failed, error code = %x", __func__, ret); + return ret; + } + + // Host init must be called after registering callbacks for NimBLE + ret = _host_init(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s initialise host failed: %s", __func__, esp_err_to_name(ret)); + return ret; + } + return ESP_OK; +} +#endif /* CONFIG_BT_NIMBLE_ENABLED */ + +#if CONFIG_BT_CONTROLLER_ENABLED || !CONFIG_BT_NIMBLE_ENABLED +esp_err_t Blufi::_controller_init() { + esp_bt_controller_config_t bt_cfg = BT_CONTROLLER_INIT_CONFIG_DEFAULT(); + esp_err_t ret = esp_bt_controller_init(&bt_cfg); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s initialize controller failed: %s", __func__, esp_err_to_name(ret)); + return ret; + } + ret = esp_bt_controller_enable(ESP_BT_MODE_BLE); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s enable controller failed: %s", __func__, esp_err_to_name(ret)); + return ret; + } + +#ifdef CONFIG_BT_NIMBLE_ENABLED + ret = esp_nimble_init(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "esp_nimble_init() failed: %s", esp_err_to_name(ret)); + return ret; + } +#endif + return ESP_OK; +} + +esp_err_t Blufi::_controller_deinit() { + esp_err_t ret = esp_bt_controller_disable(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s disable controller failed: %s", __func__, esp_err_to_name(ret)); + } + ret = esp_bt_controller_deinit(); + if (ret) { + ESP_LOGE(BLUFI_TAG, "%s deinit controller failed: %s", __func__, esp_err_to_name(ret)); + } + return ret; +} +#endif + +static int myrand(void* rng_state, unsigned char* output, size_t len) { + esp_fill_random(output, len); + return 0; +} + +void Blufi::_security_init() { + m_sec = new BlufiSecurity(); + if (m_sec == nullptr) { + ESP_LOGE(BLUFI_TAG, "Failed to allocate security context"); + return; + } + memset(m_sec, 0, sizeof(BlufiSecurity)); + m_sec->dhm = new mbedtls_dhm_context(); + m_sec->aes = new mbedtls_aes_context(); + + mbedtls_dhm_init(m_sec->dhm); + mbedtls_aes_init(m_sec->aes); + + memset(m_sec->iv, 0x0, sizeof(m_sec->iv)); +} + +void Blufi::_security_deinit() { + if (m_sec == nullptr) + return; + + if (m_sec->dh_param) { + free(m_sec->dh_param); + } + mbedtls_dhm_free(m_sec->dhm); + mbedtls_aes_free(m_sec->aes); + delete m_sec->dhm; + delete m_sec->aes; + delete m_sec; + m_sec = nullptr; +} + +void Blufi::_dh_negotiate_data_handler(uint8_t* data, int len, uint8_t** output_data, + int* output_len, bool* need_free) { + if (m_sec == nullptr) { + ESP_LOGE(BLUFI_TAG, "Security not initialized in DH handler"); + btc_blufi_report_error(ESP_BLUFI_INIT_SECURITY_ERROR); + return; + } + + if (len < 1) { + ESP_LOGE(BLUFI_TAG, "DH handler: data too short"); + btc_blufi_report_error(ESP_BLUFI_DATA_FORMAT_ERROR); + return; + } + + uint8_t type = data[0]; + switch (type) { + case 0x00: + if (len < 3) { + ESP_LOGE(BLUFI_TAG, "DH_PARAM_LEN packet too short"); + btc_blufi_report_error(ESP_BLUFI_DATA_FORMAT_ERROR); + return; + } + + m_sec->dh_param_len = (data[1] << 8) | data[2]; + if (m_sec->dh_param) { + free(m_sec->dh_param); + m_sec->dh_param = nullptr; + } + m_sec->dh_param = (uint8_t*)malloc(m_sec->dh_param_len); + if (m_sec->dh_param == nullptr) { + ESP_LOGE(BLUFI_TAG, "DH malloc failed"); + btc_blufi_report_error(ESP_BLUFI_DH_MALLOC_ERROR); + } + break; + case 0x01: { + if (m_sec->dh_param == nullptr) { + ESP_LOGE(BLUFI_TAG, "DH param not allocated"); + btc_blufi_report_error(ESP_BLUFI_DH_PARAM_ERROR); + return; + } + uint8_t* param = m_sec->dh_param; + memcpy(m_sec->dh_param, &data[1], m_sec->dh_param_len); + int ret = mbedtls_dhm_read_params(m_sec->dhm, ¶m, ¶m[m_sec->dh_param_len]); + if (ret) { + ESP_LOGE(BLUFI_TAG, "mbedtls_dhm_read_params failed %d", ret); + btc_blufi_report_error(ESP_BLUFI_READ_PARAM_ERROR); + return; + } + + const int dhm_len = mbedtls_dhm_get_len(m_sec->dhm); + + ret = mbedtls_dhm_make_public(m_sec->dhm, dhm_len, m_sec->self_public_key, dhm_len, + myrand, NULL); + if (ret != 0) { + ESP_LOGE(BLUFI_TAG, "mbedtls_dhm_make_public failed: %d", ret); + btc_blufi_report_error(ESP_BLUFI_MAKE_PUBLIC_ERROR); + return; + } + ret = mbedtls_dhm_calc_secret(m_sec->dhm, m_sec->share_key, SHARE_KEY_LEN, + &m_sec->share_len, myrand, NULL); + if (ret != 0) { + ESP_LOGE(BLUFI_TAG, "mbedtls_dhm_calc_secret failed: %d", ret); + btc_blufi_report_error(ESP_BLUFI_ENCRYPT_ERROR); + return; + } + + ret = mbedtls_md5(m_sec->share_key, m_sec->share_len, m_sec->psk); + if (ret != 0) { + ESP_LOGE(BLUFI_TAG, "mbedtls_md5 failed: %d", ret); + btc_blufi_report_error(ESP_BLUFI_CALC_MD5_ERROR); + return; + } + ret = mbedtls_aes_setkey_enc(m_sec->aes, m_sec->psk, PSK_LEN * 8); + if (ret != 0) { + ESP_LOGE(BLUFI_TAG, "mbedtls_aes_setkey_enc failed: -0x%04X", -ret); + btc_blufi_report_error(ESP_BLUFI_ENCRYPT_ERROR); + return; + } + *output_data = m_sec->self_public_key; + *output_len = dhm_len; + *need_free = false; + ESP_LOGI(BLUFI_TAG, "DH negotiation completed successfully"); + + free(m_sec->dh_param); + m_sec->dh_param = nullptr; + m_sec->dh_param_len = 0; + break; + } + default: + ESP_LOGE(BLUFI_TAG, "DH handler unknown type: %d", type); + } +} + +int Blufi::_aes_encrypt(uint8_t iv8, uint8_t* crypt_data, int crypt_len) { + if (!m_sec || !m_sec->aes || !crypt_data || crypt_len <= 0) { + ESP_LOGE(BLUFI_TAG, "Invalid parameters for AES encryption"); + return -ESP_ERR_INVALID_ARG; + } + + size_t iv_offset = 0; + uint8_t iv0[16]; + memcpy(iv0, m_sec->iv, 16); + iv0[0] = iv8; + int ret = mbedtls_aes_crypt_cfb128(m_sec->aes, MBEDTLS_AES_ENCRYPT, crypt_len, &iv_offset, iv0, + crypt_data, crypt_data); + + if (ret == 0) { + return crypt_len; + } else { + ESP_LOGE(BLUFI_TAG, "AES encrypt failed: %d", ret); + return ret; + } +} + +int Blufi::_aes_decrypt(uint8_t iv8, uint8_t* crypt_data, int crypt_len) { + if (!m_sec || !m_sec->aes || !crypt_data || crypt_len < 0) { + ESP_LOGE(BLUFI_TAG, "Invalid parameters for AES decryption %p %p %d", m_sec->aes, + crypt_data, crypt_len); + return -ESP_ERR_INVALID_ARG; + } + + size_t iv_offset = 0; + uint8_t iv0[16]; + memcpy(iv0, m_sec->iv, 16); + iv0[0] = iv8; + int ret = mbedtls_aes_crypt_cfb128(m_sec->aes, MBEDTLS_AES_DECRYPT, crypt_len, &iv_offset, iv0, + crypt_data, crypt_data); + if (ret != 0) { + ESP_LOGE(BLUFI_TAG, "AES decrypt failed: %d", ret); + return ret; + } else { + return crypt_len; + } +} + +uint16_t Blufi::_crc_checksum(uint8_t iv8, uint8_t* data, int len) { + return esp_crc16_be(0, data, len); +} + +int Blufi::_get_softap_conn_num() { + auto& wifi = WifiManager::GetInstance(); + if (!wifi.IsInitialized() || !wifi.IsConfigMode()) { + return 0; + } + + wifi_sta_list_t sta_list{}; + if (esp_wifi_ap_get_sta_list(&sta_list) == ESP_OK) { + return sta_list.num; + } + return 0; +} + +void Blufi::start_wifi_scan() { + ESP_LOGI(BLUFI_TAG, "Starting dedicated WiFi scan"); + + // Check if a scan is already in progress + if (m_scan_in_progress) { + ESP_LOGW(BLUFI_TAG, "Scan already in progress, skipping"); + return; + } + + m_scan_in_progress = true; + + // Get current WiFi mode + wifi_mode_t current_mode; + esp_err_t err = esp_wifi_get_mode(¤t_mode); + + if (current_mode == WIFI_MODE_AP) { + // If in AP mode, temporarily switch to APSTA to allow scanning + ESP_LOGI(BLUFI_TAG, "WiFi in AP mode"); + err = esp_wifi_set_mode(WIFI_MODE_STA); + if (err != ESP_OK) { + ESP_LOGE(BLUFI_TAG, "Failed to set WiFi mode to STA: %s", esp_err_to_name(err)); + m_scan_in_progress = false; + return; + } + // Need to restart WiFi for mode change to take effect + err = esp_wifi_start(); + if (err != ESP_OK) { + ESP_LOGE(BLUFI_TAG, "Failed to start WiFi after mode switch: %s", esp_err_to_name(err)); + m_scan_in_progress = false; + return; + } + // Register scan event handler + esp_event_handler_instance_t scan_event_instance; + esp_event_handler_instance_register(WIFI_EVENT, ESP_EVENT_ANY_ID, + &Blufi::_wifi_scan_event_handler, this, + &scan_event_instance); + + // Start scan + err = esp_wifi_scan_start(NULL, false); + if (err != ESP_OK) { + ESP_LOGE(BLUFI_TAG, "Failed to start WiFi scan: %s", esp_err_to_name(err)); + m_scan_in_progress = false; + return; + } + } else if (current_mode == WIFI_MODE_STA) { + // Start scan + err = esp_wifi_scan_start(NULL, false); + if (err != ESP_OK) { + ESP_LOGE(BLUFI_TAG, "Failed to start WiFi scan: %s", esp_err_to_name(err)); + m_scan_in_progress = false; + return; + } + } else { + ESP_LOGE(BLUFI_TAG, "Unexpected WiFi mode: %d", current_mode); + m_scan_in_progress = false; + return; + } + + ESP_LOGI(BLUFI_TAG, "WiFi scan started"); +} + +void Blufi::_send_wifi_list() { + if (m_ap_records.empty()) { + ESP_LOGW(BLUFI_TAG, "No AP records available to send"); + return; + } + + ESP_LOGI(BLUFI_TAG, "Sending WiFi list with %d APs", m_ap_records.size()); + + std::vector blufi_ap_list; + for (const auto& ap : m_ap_records) { + esp_blufi_ap_record_t blufi_ap; + memset(&blufi_ap, 0, sizeof(blufi_ap)); + memcpy(blufi_ap.ssid, ap.ssid, std::min((size_t)32, sizeof(ap.ssid))); + blufi_ap.rssi = ap.rssi; + blufi_ap_list.push_back(blufi_ap); + } + + esp_blufi_send_wifi_list(blufi_ap_list.size(), blufi_ap_list.data()); + + m_ap_records.clear(); + start_wifi_scan(); +} + +void Blufi::_wifi_scan_event_handler(void* arg, esp_event_base_t event_base, int32_t event_id, + void* event_data) { + Blufi* self = static_cast(arg); + + if (event_base == WIFI_EVENT && event_id == WIFI_EVENT_SCAN_DONE) { + ESP_LOGI(BLUFI_TAG, "WiFi scan done"); + + uint16_t ap_num = 0; + esp_wifi_scan_get_ap_num(&ap_num); + + if (ap_num == 0) { + ESP_LOGW(BLUFI_TAG, "No APs found"); + self->m_ap_records.clear(); + } else { + if (static_cast(arg)->m_scan_should_save_ssid == true) { + self->m_ap_records.resize(ap_num); + esp_wifi_scan_get_ap_records(&ap_num, self->m_ap_records.data()); + + ESP_LOGI(BLUFI_TAG, "Found %d APs", ap_num); + for (const auto& ap : self->m_ap_records) { + ESP_LOGI(BLUFI_TAG, " SSID: %s, RSSI: %d, Authmode: %d", (char*)ap.ssid, + ap.rssi, ap.authmode); + } + } + } + self->m_scan_in_progress = false; + } +} + +void Blufi::_handle_event(esp_blufi_cb_event_t event, esp_blufi_cb_param_t* param) { + switch (event) { + case ESP_BLUFI_EVENT_INIT_FINISH: + ESP_LOGI(BLUFI_TAG, "BLUFI init finish"); + esp_ble_gap_set_device_name(BLUFI_DEVICE_NAME); + esp_blufi_adv_start(); + break; + case ESP_BLUFI_EVENT_DEINIT_FINISH: + ESP_LOGI(BLUFI_TAG, "BLUFI deinit finish"); + break; + case ESP_BLUFI_EVENT_BLE_CONNECT: + ESP_LOGI(BLUFI_TAG, "BLUFI ble connect"); + m_ble_is_connected = true; + esp_blufi_adv_stop(); + _security_init(); + break; + case ESP_BLUFI_EVENT_BLE_DISCONNECT: + ESP_LOGI(BLUFI_TAG, "BLUFI ble disconnect"); + m_ble_is_connected = false; + _security_deinit(); + if (!m_provisioned) { + esp_blufi_adv_start(); + } else { + esp_blufi_adv_stop(); + if (!m_deinited) { + xTaskCreate( + [](void* ctx) { + static_cast(ctx)->deinit(); + vTaskDelete(nullptr); + }, + "blufi_deinit", 4096, this, 5, nullptr); + } + } + break; + case ESP_BLUFI_EVENT_SET_WIFI_OPMODE: { + ESP_LOGI(BLUFI_TAG, "BLUFI Set WIFI opmode %d", param->wifi_mode.op_mode); + auto& wifi_manager = WifiManager::GetInstance(); + if (!wifi_manager.IsInitialized() && !wifi_manager.Initialize()) { + ESP_LOGE(BLUFI_TAG, "Failed to initialize WifiManager for opmode change"); + break; + } + switch (param->wifi_mode.op_mode) { + case WIFI_MODE_STA: + wifi_manager.StartStation(); + break; + case WIFI_MODE_AP: + wifi_manager.StartConfigAp(); + break; + case WIFI_MODE_APSTA: + ESP_LOGW(BLUFI_TAG, "APSTA mode not supported, starting station only"); + wifi_manager.StartStation(); + break; + default: + wifi_manager.StopStation(); + wifi_manager.StopConfigAp(); + break; + } + break; + } + case ESP_BLUFI_EVENT_REQ_CONNECT_TO_AP: { + ESP_LOGI(BLUFI_TAG, "BLUFI request wifi connect to AP via esp-wifi-connect"); + std::string ssid(reinterpret_cast(m_sta_config.sta.ssid)); + std::string password(reinterpret_cast(m_sta_config.sta.password)); + + SsidManager::GetInstance().AddSsid(ssid, password); + m_scan_should_save_ssid = false; + + m_sta_ssid_len = static_cast(std::min(ssid.size(), sizeof(m_sta_ssid))); + memcpy(m_sta_ssid, ssid.c_str(), m_sta_ssid_len); + memset(m_sta_bssid, 0, sizeof(m_sta_bssid)); + m_sta_connected = false; + m_sta_got_ip = false; + m_sta_is_connecting = true; + m_sta_conn_info = {}; + m_sta_conn_info.sta_ssid = m_sta_ssid; + m_sta_conn_info.sta_ssid_len = m_sta_ssid_len; + + auto& wifi_manager = WifiManager::GetInstance(); + + if (wifi_manager.IsInitialized()) { + if (wifi_manager.IsConfigMode()) { + wifi_manager.StopConfigAp(); + } + wifi_manager.StopStation(); + } + + if (!wifi_manager.IsInitialized() && !wifi_manager.Initialize()) { + ESP_LOGE(BLUFI_TAG, "Failed to initialize WifiManager"); + break; + } + + vTaskDelay(pdMS_TO_TICKS(500)); + + wifi_manager.StartStation(); + + xTaskCreate( + [](void* ctx) { + auto* self = static_cast(ctx); + auto& wifi = WifiManager::GetInstance(); + constexpr int kConnectTimeoutMs = 10000; + constexpr TickType_t kDelayTick = pdMS_TO_TICKS(200); + int waited_ms = 0; + + while (waited_ms < kConnectTimeoutMs && !wifi.IsConnected()) { + vTaskDelay(kDelayTick); + waited_ms += 200; + } + + wifi_mode_t mode = GetWifiModeWithFallback(wifi); + const int softap_conn_num = _get_softap_conn_num(); + + if (wifi.IsConnected()) { + self->m_sta_is_connecting = false; + self->m_sta_connected = true; + self->m_sta_got_ip = true; + self->m_provisioned = true; + + auto current_ssid = wifi.GetSsid(); + if (!current_ssid.empty()) { + self->m_sta_ssid_len = static_cast( + std::min(current_ssid.size(), sizeof(self->m_sta_ssid))); + memcpy(self->m_sta_ssid, current_ssid.c_str(), self->m_sta_ssid_len); + } + + wifi_ap_record_t ap_info{}; + if (esp_wifi_sta_get_ap_info(&ap_info) == ESP_OK) { + memcpy(self->m_sta_bssid, ap_info.bssid, sizeof(self->m_sta_bssid)); + } + + esp_blufi_extra_info_t info = {}; + memcpy(info.sta_bssid, self->m_sta_bssid, sizeof(self->m_sta_bssid)); + info.sta_bssid_set = true; + info.sta_ssid = self->m_sta_ssid; + info.sta_ssid_len = self->m_sta_ssid_len; + esp_blufi_send_wifi_conn_report(mode, ESP_BLUFI_STA_CONN_SUCCESS, + softap_conn_num, &info); + ESP_LOGI(BLUFI_TAG, "connected to WiFi"); + + if (self->m_ble_is_connected) { + esp_blufi_disconnect(); + } + } else { + self->m_sta_is_connecting = false; + self->m_sta_connected = false; + self->m_sta_got_ip = false; + + esp_blufi_extra_info_t info = {}; + info.sta_ssid = self->m_sta_ssid; + info.sta_ssid_len = self->m_sta_ssid_len; + esp_blufi_send_wifi_conn_report(mode, ESP_BLUFI_STA_CONN_FAIL, + softap_conn_num, &info); + ESP_LOGE(BLUFI_TAG, "Failed to connect to WiFi via esp-wifi-connect"); + } + vTaskDelete(nullptr); + }, + "blufi_wifi_conn", 4096, this, 5, nullptr); + break; + } + case ESP_BLUFI_EVENT_REQ_DISCONNECT_FROM_AP: + ESP_LOGI(BLUFI_TAG, "BLUFI request wifi disconnect from AP"); + if (WifiManager::GetInstance().IsInitialized()) { + WifiManager::GetInstance().StopStation(); + } + m_sta_is_connecting = false; + m_sta_connected = false; + m_sta_got_ip = false; + break; + case ESP_BLUFI_EVENT_GET_WIFI_STATUS: { + auto& wifi = WifiManager::GetInstance(); + wifi_mode_t mode = GetWifiModeWithFallback(wifi); + const int softap_conn_num = _get_softap_conn_num(); + + if (wifi.IsInitialized() && wifi.IsConnected()) { + m_sta_connected = true; + m_sta_got_ip = true; + + auto current_ssid = wifi.GetSsid(); + if (!current_ssid.empty()) { + m_sta_ssid_len = + static_cast(std::min(current_ssid.size(), sizeof(m_sta_ssid))); + memcpy(m_sta_ssid, current_ssid.c_str(), m_sta_ssid_len); + } + + esp_blufi_extra_info_t info; + memset(&info, 0, sizeof(esp_blufi_extra_info_t)); + memcpy(info.sta_bssid, m_sta_bssid, 6); + info.sta_ssid = m_sta_ssid; + info.sta_ssid_len = m_sta_ssid_len; + esp_blufi_send_wifi_conn_report(mode, ESP_BLUFI_STA_CONN_SUCCESS, softap_conn_num, + &info); + } else if (m_sta_is_connecting) { + esp_blufi_send_wifi_conn_report(mode, ESP_BLUFI_STA_CONNECTING, softap_conn_num, + &m_sta_conn_info); + } else { + esp_blufi_send_wifi_conn_report(mode, ESP_BLUFI_STA_CONN_FAIL, softap_conn_num, + &m_sta_conn_info); + } + ESP_LOGI(BLUFI_TAG, "BLUFI get wifi status"); + break; + } + case ESP_BLUFI_EVENT_RECV_STA_BSSID: + memcpy(m_sta_config.sta.bssid, param->sta_bssid.bssid, 6); + m_sta_config.sta.bssid_set = true; + ESP_LOGI(BLUFI_TAG, "Recv STA BSSID"); + break; + case ESP_BLUFI_EVENT_RECV_STA_SSID: + strncpy((char*)m_sta_config.sta.ssid, (char*)param->sta_ssid.ssid, + param->sta_ssid.ssid_len); + m_sta_config.sta.ssid[param->sta_ssid.ssid_len] = '\0'; + ESP_LOGI(BLUFI_TAG, "Recv STA SSID: %s", m_sta_config.sta.ssid); + break; + case ESP_BLUFI_EVENT_RECV_STA_PASSWD: + strncpy((char*)m_sta_config.sta.password, (char*)param->sta_passwd.passwd, + param->sta_passwd.passwd_len); + m_sta_config.sta.password[param->sta_passwd.passwd_len] = '\0'; + ESP_LOGI(BLUFI_TAG, "Recv STA PASSWORD : %s", m_sta_config.sta.password); + break; + case ESP_BLUFI_EVENT_GET_WIFI_LIST: { + ESP_LOGI(BLUFI_TAG, "BLUFI get wifi list"); + while (m_scan_in_progress) { + vTaskDelay(pdMS_TO_TICKS(500)); + } + _send_wifi_list(); + break; + } + default: + ESP_LOGW(BLUFI_TAG, "Unhandled event: %d", event); + break; + } +} + +void Blufi::_event_callback_trampoline(esp_blufi_cb_event_t event, esp_blufi_cb_param_t* param) { + GetInstance()._handle_event(event, param); +} + +void Blufi::_negotiate_data_handler_trampoline(uint8_t* data, int len, uint8_t** output_data, + int* output_len, bool* need_free) { + GetInstance()._dh_negotiate_data_handler(data, len, output_data, output_len, need_free); +} + +int Blufi::_encrypt_func_trampoline(uint8_t iv8, uint8_t* crypt_data, int crypt_len) { + return GetInstance()._aes_encrypt(iv8, crypt_data, crypt_len); +} + +int Blufi::_decrypt_func_trampoline(uint8_t iv8, uint8_t* crypt_data, int crypt_len) { + return GetInstance()._aes_decrypt(iv8, crypt_data, crypt_len); +} + +uint16_t Blufi::_checksum_func_trampoline(uint8_t iv8, uint8_t* data, int len) { + return _crc_checksum(iv8, data, len); +} diff --git a/main/boards/common/blufi.h b/main/boards/common/blufi.h new file mode 100644 index 0000000..2e02f7e --- /dev/null +++ b/main/boards/common/blufi.h @@ -0,0 +1,147 @@ +#pragma once + +#include +#include +#include +#include +#include "esp_blufi_api.h" +#include "esp_err.h" +#include "esp_timer.h" +#include "freertos/FreeRTOS.h" +#include "freertos/task.h" +#include "mbedtls/aes.h" +#include "mbedtls/dhm.h" +#include "wifi_manager.h" + +class Blufi { +public: + /** + * @brief Get the singleton instance of the Blufi class. + */ + static Blufi &GetInstance(); + + /** + * @brief Start WiFi scan for Blufi provisioning + * This method intelligently handles WiFi scanning based on current WiFi state: + * - If WiFi config mode is active, it uses the existing scan results from WifiConfigurationAp + * - Otherwise, it performs a dedicated scan without interfering with normal WiFi operations + */ + void start_wifi_scan(); + + /** + * @brief Initializes the Bluetooth controller, host, and Blufi profile. + * This is the main entry point to start the Blufi process. + * @return ESP_OK on success, otherwise an error code. + */ + esp_err_t init(); + + /** + * @brief Deinitializes Blufi and the Bluetooth stack. + * @return ESP_OK on success, otherwise an error code. + */ + esp_err_t deinit(); + + // Delete copy constructor and assignment operator for singleton + Blufi(const Blufi &) = delete; + + Blufi &operator=(const Blufi &) = delete; + +private: + bool inited_ = false; + + Blufi(); + + ~Blufi(); + + // Initialization logic + static esp_err_t _controller_init(); + + static esp_err_t _controller_deinit(); + + static esp_err_t _host_init(); + + static esp_err_t _host_deinit(); + + static esp_err_t _gap_register_callback(); + + static esp_err_t _host_and_cb_init(); + + void _security_init(); + + void _security_deinit(); + + void _dh_negotiate_data_handler(uint8_t *data, int len, uint8_t **output_data, int *output_len, + bool *need_free); + + int _aes_encrypt(uint8_t iv8, uint8_t *crypt_data, int crypt_len); + + int _aes_decrypt(uint8_t iv8, uint8_t *crypt_data, int crypt_len); + + static uint16_t _crc_checksum(uint8_t iv8, uint8_t *data, int len); + + void _handle_event(esp_blufi_cb_event_t event, esp_blufi_cb_param_t *param); + + static int _get_softap_conn_num(); + + // WiFi scan methods + void _send_wifi_list(); + void _start_dedicated_wifi_scan(); + static void _wifi_scan_event_handler(void *arg, esp_event_base_t event_base, int32_t event_id, + void *event_data); + + // These C-style functions are registered with ESP-IDF and call the corresponding instance + // methods. + + static void _event_callback_trampoline(esp_blufi_cb_event_t event, esp_blufi_cb_param_t *param); + + static void _negotiate_data_handler_trampoline(uint8_t *data, int len, uint8_t **output_data, + int *output_len, bool *need_free); + + static int _encrypt_func_trampoline(uint8_t iv8, uint8_t *crypt_data, int crypt_len); + + static int _decrypt_func_trampoline(uint8_t iv8, uint8_t *crypt_data, int crypt_len); + + static uint16_t _checksum_func_trampoline(uint8_t iv8, uint8_t *data, int len); + +#ifdef CONFIG_BT_NIMBLE_ENABLED + static void _nimble_on_reset(int reason); + static void _nimble_on_sync(); + static void _nimble_host_task(void *param); +#endif + + // Security context, formerly blufi_sec struct + struct BlufiSecurity { +#define DH_SELF_PUB_KEY_LEN 128 + uint8_t self_public_key[DH_SELF_PUB_KEY_LEN]; +#define SHARE_KEY_LEN 128 + uint8_t share_key[SHARE_KEY_LEN]; + size_t share_len; +#define PSK_LEN 16 + uint8_t psk[PSK_LEN]; + uint8_t *dh_param; + int dh_param_len; + uint8_t iv[16]; + mbedtls_dhm_context *dhm; + esp_aes_context *aes; + }; + + BlufiSecurity *m_sec; + + // State variables + wifi_config_t m_sta_config{}; + bool m_ble_is_connected; + bool m_sta_connected; + bool m_sta_got_ip; + bool m_provisioned; + bool m_deinited; + uint8_t m_sta_bssid[6]{}; + uint8_t m_sta_ssid[32]{}; + int m_sta_ssid_len; + bool m_sta_is_connecting; + esp_blufi_extra_info_t m_sta_conn_info{}; + + // WiFi scan related + std::vector m_ap_records; + bool m_scan_in_progress = false; + bool m_scan_should_save_ssid = true; +}; diff --git a/main/boards/common/board.cc b/main/boards/common/board.cc new file mode 100644 index 0000000..8a2f85b --- /dev/null +++ b/main/boards/common/board.cc @@ -0,0 +1,178 @@ +#include "board.h" +#include "system_info.h" +#include "settings.h" +#include "display/display.h" +#include "display/oled_display.h" +#include "assets/lang_config.h" + +#include +#include +#include +#include + +#define TAG "Board" + +Board::Board() { + Settings settings("board", true); + uuid_ = settings.GetString("uuid"); + if (uuid_.empty()) { + uuid_ = GenerateUuid(); + settings.SetString("uuid", uuid_); + } + ESP_LOGI(TAG, "UUID=%s SKU=%s", uuid_.c_str(), BOARD_NAME); +} + +std::string Board::GenerateUuid() { + // UUID v4 需要 16 字节的随机数据 + uint8_t uuid[16]; + + // 使用 ESP32 的硬件随机数生成器 + esp_fill_random(uuid, sizeof(uuid)); + + // 设置版本 (版本 4) 和变体位 + uuid[6] = (uuid[6] & 0x0F) | 0x40; // 版本 4 + uuid[8] = (uuid[8] & 0x3F) | 0x80; // 变体 1 + + // 将字节转换为标准的 UUID 字符串格式 + char uuid_str[37]; + snprintf(uuid_str, sizeof(uuid_str), + "%02x%02x%02x%02x-%02x%02x-%02x%02x-%02x%02x-%02x%02x%02x%02x%02x%02x", + uuid[0], uuid[1], uuid[2], uuid[3], + uuid[4], uuid[5], uuid[6], uuid[7], + uuid[8], uuid[9], uuid[10], uuid[11], + uuid[12], uuid[13], uuid[14], uuid[15]); + + return std::string(uuid_str); +} + +bool Board::GetBatteryLevel(int &level, bool& charging, bool& discharging) { + return false; +} + +bool Board::GetTemperature(float& esp32temp){ + return false; +} + +Display* Board::GetDisplay() { + static NoDisplay display; + return &display; +} + +Camera* Board::GetCamera() { + return nullptr; +} + +Led* Board::GetLed() { + static NoLed led; + return &led; +} + +std::string Board::GetSystemInfoJson() { + /* + { + "version": 2, + "flash_size": 4194304, + "psram_size": 0, + "minimum_free_heap_size": 123456, + "mac_address": "00:00:00:00:00:00", + "uuid": "00000000-0000-0000-0000-000000000000", + "chip_model_name": "esp32s3", + "chip_info": { + "model": 1, + "cores": 2, + "revision": 0, + "features": 0 + }, + "application": { + "name": "my-app", + "version": "1.0.0", + "compile_time": "2021-01-01T00:00:00Z" + "idf_version": "4.2-dev" + "elf_sha256": "" + }, + "partition_table": [ + "app": { + "label": "app", + "type": 1, + "subtype": 2, + "address": 0x10000, + "size": 0x100000 + } + ], + "ota": { + "label": "ota_0" + }, + "board": { + ... + } + } + */ + std::string json = R"({"version":2,"language":")" + std::string(Lang::CODE) + R"(",)"; + json += R"("flash_size":)" + std::to_string(SystemInfo::GetFlashSize()) + R"(,)"; + json += R"("minimum_free_heap_size":")" + std::to_string(SystemInfo::GetMinimumFreeHeapSize()) + R"(",)"; + json += R"("mac_address":")" + SystemInfo::GetMacAddress() + R"(",)"; + json += R"("uuid":")" + uuid_ + R"(",)"; + json += R"("chip_model_name":")" + SystemInfo::GetChipModelName() + R"(",)"; + + esp_chip_info_t chip_info; + esp_chip_info(&chip_info); + json += R"("chip_info":{)"; + json += R"("model":)" + std::to_string(chip_info.model) + R"(,)"; + json += R"("cores":)" + std::to_string(chip_info.cores) + R"(,)"; + json += R"("revision":)" + std::to_string(chip_info.revision) + R"(,)"; + json += R"("features":)" + std::to_string(chip_info.features) + R"(},)"; + + auto app_desc = esp_app_get_description(); + json += R"("application":{)"; + json += R"("name":")" + std::string(app_desc->project_name) + R"(",)"; + json += R"("version":")" + std::string(app_desc->version) + R"(",)"; + json += R"("compile_time":")" + std::string(app_desc->date) + R"(T)" + std::string(app_desc->time) + R"(Z",)"; + json += R"("idf_version":")" + std::string(app_desc->idf_ver) + R"(",)"; + char sha256_str[65]; + for (int i = 0; i < 32; i++) { + snprintf(sha256_str + i * 2, sizeof(sha256_str) - i * 2, "%02x", app_desc->app_elf_sha256[i]); + } + json += R"("elf_sha256":")" + std::string(sha256_str) + R"(")"; + json += R"(},)"; + + json += R"("partition_table": [)"; + esp_partition_iterator_t it = esp_partition_find(ESP_PARTITION_TYPE_ANY, ESP_PARTITION_SUBTYPE_ANY, NULL); + while (it) { + const esp_partition_t *partition = esp_partition_get(it); + json += R"({)"; + json += R"("label":")" + std::string(partition->label) + R"(",)"; + json += R"("type":)" + std::to_string(partition->type) + R"(,)"; + json += R"("subtype":)" + std::to_string(partition->subtype) + R"(,)"; + json += R"("address":)" + std::to_string(partition->address) + R"(,)"; + json += R"("size":)" + std::to_string(partition->size) + R"(},)";; + it = esp_partition_next(it); + } + json.pop_back(); // Remove the last comma + json += R"(],)"; + + json += R"("ota":{)"; + auto ota_partition = esp_ota_get_running_partition(); + json += R"("label":")" + std::string(ota_partition->label) + R"(")"; + json += R"(},)"; + + // Append display info + auto display = GetDisplay(); + if (display) { + json += R"("display":{)"; + if (dynamic_cast(display)) { + json += R"("monochrome":)" + std::string("true") + R"(,)"; + } else { + json += R"("monochrome":)" + std::string("false") + R"(,)"; + } + json += R"("width":)" + std::to_string(display->width()) + R"(,)"; + json += R"("height":)" + std::to_string(display->height()) + R"(,)"; + json.pop_back(); // Remove the last comma + } + json += R"(},)"; + + json += R"("board":)" + GetBoardJson(); + + // Close the JSON object + json += R"(})"; + return json; +} diff --git a/main/boards/common/board.h b/main/boards/common/board.h new file mode 100644 index 0000000..19d3f2f --- /dev/null +++ b/main/boards/common/board.h @@ -0,0 +1,92 @@ +#ifndef BOARD_H +#define BOARD_H + +#include +#include +#include +#include +#include +#include +#include + +#include "led/led.h" +#include "backlight.h" +#include "camera.h" +#include "assets.h" + +/** + * Network events for unified callback + */ +enum class NetworkEvent { + Scanning, // Network is scanning (WiFi scanning, etc.) + Connecting, // Network is connecting (data: SSID/network name) + Connected, // Network connected successfully (data: SSID/network name) + Disconnected, // Network disconnected + WifiConfigModeEnter, // Entered WiFi configuration mode + WifiConfigModeExit, // Exited WiFi configuration mode + // Cellular modem specific events + ModemDetecting, // Detecting modem (baud rate, module type) + ModemErrorNoSim, // No SIM card detected + ModemErrorRegDenied, // Network registration denied + ModemErrorInitFailed, // Modem initialization failed + ModemErrorTimeout // Operation timeout +}; + +// Power save level enumeration +enum class PowerSaveLevel { + LOW_POWER, // Maximum power saving (lowest power consumption) + BALANCED, // Medium power saving (balanced) + PERFORMANCE, // No power saving (maximum power consumption / full performance) +}; + +// Network event callback type (event, data) +// data contains additional info like SSID for Connecting/Connected events +using NetworkEventCallback = std::function; + +void* create_board(); +class AudioCodec; +class Display; +class Board { +private: + Board(const Board&) = delete; // 禁用拷贝构造函数 + Board& operator=(const Board&) = delete; // 禁用赋值操作 + +protected: + Board(); + std::string GenerateUuid(); + + // 软件生成的设备唯一标识 + std::string uuid_; + +public: + static Board& GetInstance() { + static Board* instance = static_cast(create_board()); + return *instance; + } + + virtual ~Board() = default; + virtual std::string GetBoardType() = 0; + virtual std::string GetUuid() { return uuid_; } + virtual Backlight* GetBacklight() { return nullptr; } + virtual Led* GetLed(); + virtual AudioCodec* GetAudioCodec() = 0; + virtual bool GetTemperature(float& esp32temp); + virtual Display* GetDisplay(); + virtual Camera* GetCamera(); + virtual NetworkInterface* GetNetwork() = 0; + virtual void StartNetwork() = 0; + virtual void SetNetworkEventCallback(NetworkEventCallback callback) { (void)callback; } + virtual const char* GetNetworkStateIcon() = 0; + virtual bool GetBatteryLevel(int &level, bool& charging, bool& discharging); + virtual std::string GetSystemInfoJson(); + virtual void SetPowerSaveLevel(PowerSaveLevel level) = 0; + virtual std::string GetBoardJson() = 0; + virtual std::string GetDeviceStatusJson() = 0; +}; + +#define DECLARE_BOARD(BOARD_CLASS_NAME) \ +void* create_board() { \ + return new BOARD_CLASS_NAME(); \ +} + +#endif // BOARD_H diff --git a/main/boards/common/button.cc b/main/boards/common/button.cc new file mode 100644 index 0000000..9570a5f --- /dev/null +++ b/main/boards/common/button.cc @@ -0,0 +1,125 @@ +#include "button.h" + +#include +#include + +#define TAG "Button" + +#if CONFIG_SOC_ADC_SUPPORTED +AdcButton::AdcButton(const button_adc_config_t& adc_config) : Button(nullptr) { + button_config_t btn_config = { + .long_press_time = 2000, + .short_press_time = 0, + }; + ESP_ERROR_CHECK(iot_button_new_adc_device(&btn_config, &adc_config, &button_handle_)); +} +#endif + +Button::Button(button_handle_t button_handle) : button_handle_(button_handle) { +} + +Button::Button(gpio_num_t gpio_num, bool active_high, uint16_t long_press_time, uint16_t short_press_time, bool enable_power_save) : gpio_num_(gpio_num) { + if (gpio_num == GPIO_NUM_NC) { + return; + } + button_config_t button_config = { + .long_press_time = long_press_time, + .short_press_time = short_press_time + }; + button_gpio_config_t gpio_config = { + .gpio_num = gpio_num, + .active_level = static_cast(active_high ? 1 : 0), + .enable_power_save = enable_power_save, + .disable_pull = false + }; + ESP_ERROR_CHECK(iot_button_new_gpio_device(&button_config, &gpio_config, &button_handle_)); +} + +Button::~Button() { + if (button_handle_ != NULL) { + iot_button_delete(button_handle_); + } +} + +void Button::OnPressDown(std::function callback) { + if (button_handle_ == nullptr) { + return; + } + on_press_down_ = callback; + iot_button_register_cb(button_handle_, BUTTON_PRESS_DOWN, nullptr, [](void* handle, void* usr_data) { + Button* button = static_cast(usr_data); + if (button->on_press_down_) { + button->on_press_down_(); + } + }, this); +} + +void Button::OnPressUp(std::function callback) { + if (button_handle_ == nullptr) { + return; + } + on_press_up_ = callback; + iot_button_register_cb(button_handle_, BUTTON_PRESS_UP, nullptr, [](void* handle, void* usr_data) { + Button* button = static_cast(usr_data); + if (button->on_press_up_) { + button->on_press_up_(); + } + }, this); +} + +void Button::OnLongPress(std::function callback) { + if (button_handle_ == nullptr) { + return; + } + on_long_press_ = callback; + iot_button_register_cb(button_handle_, BUTTON_LONG_PRESS_START, nullptr, [](void* handle, void* usr_data) { + Button* button = static_cast(usr_data); + if (button->on_long_press_) { + button->on_long_press_(); + } + }, this); +} + +void Button::OnClick(std::function callback) { + if (button_handle_ == nullptr) { + return; + } + on_click_ = callback; + iot_button_register_cb(button_handle_, BUTTON_SINGLE_CLICK, nullptr, [](void* handle, void* usr_data) { + Button* button = static_cast(usr_data); + if (button->on_click_) { + button->on_click_(); + } + }, this); +} + +void Button::OnDoubleClick(std::function callback) { + if (button_handle_ == nullptr) { + return; + } + on_double_click_ = callback; + iot_button_register_cb(button_handle_, BUTTON_DOUBLE_CLICK, nullptr, [](void* handle, void* usr_data) { + Button* button = static_cast(usr_data); + if (button->on_double_click_) { + button->on_double_click_(); + } + }, this); +} + +void Button::OnMultipleClick(std::function callback, uint8_t click_count) { + if (button_handle_ == nullptr) { + return; + } + on_multiple_click_ = callback; + button_event_args_t event_args = { + .multiple_clicks = { + .clicks = click_count + } + }; + iot_button_register_cb(button_handle_, BUTTON_MULTIPLE_CLICK, &event_args, [](void* handle, void* usr_data) { + Button* button = static_cast(usr_data); + if (button->on_multiple_click_) { + button->on_multiple_click_(); + } + }, this); +} \ No newline at end of file diff --git a/main/boards/common/button.h b/main/boards/common/button.h new file mode 100644 index 0000000..ceecbe5 --- /dev/null +++ b/main/boards/common/button.h @@ -0,0 +1,49 @@ +#ifndef BUTTON_H_ +#define BUTTON_H_ + +#include +#include +#include +#include +#include +#include + +class Button { +public: + Button(button_handle_t button_handle); + Button(gpio_num_t gpio_num, bool active_high = false, uint16_t long_press_time = 0, uint16_t short_press_time = 0, bool enable_power_save = false); + ~Button(); + + void OnPressDown(std::function callback); + void OnPressUp(std::function callback); + void OnLongPress(std::function callback); + void OnClick(std::function callback); + void OnDoubleClick(std::function callback); + void OnMultipleClick(std::function callback, uint8_t click_count = 3); + +protected: + gpio_num_t gpio_num_; + button_handle_t button_handle_ = nullptr; + + std::function on_press_down_; + std::function on_press_up_; + std::function on_long_press_; + std::function on_click_; + std::function on_double_click_; + std::function on_multiple_click_; +}; + +#if CONFIG_SOC_ADC_SUPPORTED +class AdcButton : public Button { +public: + AdcButton(const button_adc_config_t& adc_config); +}; +#endif + +class PowerSaveButton : public Button { +public: + PowerSaveButton(gpio_num_t gpio_num) : Button(gpio_num, false, 0, 0, true) { + } +}; + +#endif // BUTTON_H_ diff --git a/main/boards/common/camera.h b/main/boards/common/camera.h new file mode 100644 index 0000000..09a6a3e --- /dev/null +++ b/main/boards/common/camera.h @@ -0,0 +1,16 @@ +#ifndef CAMERA_H +#define CAMERA_H + +#include + +class Camera { +public: + virtual void SetExplainUrl(const std::string& url, const std::string& token) = 0; + virtual bool Capture() = 0; + virtual bool SetHMirror(bool enabled) = 0; + virtual bool SetVFlip(bool enabled) = 0; + virtual bool SetSwapBytes(bool enabled) { return false; } // Optional, default no-op + virtual std::string Explain(const std::string& question) = 0; +}; + +#endif // CAMERA_H diff --git a/main/boards/common/dual_network_board.cc b/main/boards/common/dual_network_board.cc new file mode 100644 index 0000000..7253e51 --- /dev/null +++ b/main/boards/common/dual_network_board.cc @@ -0,0 +1,98 @@ +#include "dual_network_board.h" +#include "application.h" +#include "display.h" +#include "assets/lang_config.h" +#include "settings.h" +#include + +static const char *TAG = "DualNetworkBoard"; + +DualNetworkBoard::DualNetworkBoard(gpio_num_t ml307_tx_pin, gpio_num_t ml307_rx_pin, gpio_num_t ml307_dtr_pin, int32_t default_net_type) + : Board(), + ml307_tx_pin_(ml307_tx_pin), + ml307_rx_pin_(ml307_rx_pin), + ml307_dtr_pin_(ml307_dtr_pin) { + + // 从Settings加载网络类型 + network_type_ = LoadNetworkTypeFromSettings(default_net_type); + + // 只初始化当前网络类型对应的板卡 + InitializeCurrentBoard(); +} + +NetworkType DualNetworkBoard::LoadNetworkTypeFromSettings(int32_t default_net_type) { + Settings settings("network", true); + int network_type = settings.GetInt("type", default_net_type); // 默认使用ML307 (1) + return network_type == 1 ? NetworkType::ML307 : NetworkType::WIFI; +} + +void DualNetworkBoard::SaveNetworkTypeToSettings(NetworkType type) { + Settings settings("network", true); + int network_type = (type == NetworkType::ML307) ? 1 : 0; + settings.SetInt("type", network_type); +} + +void DualNetworkBoard::InitializeCurrentBoard() { + if (network_type_ == NetworkType::ML307) { + ESP_LOGI(TAG, "Initialize ML307 board"); + current_board_ = std::make_unique(ml307_tx_pin_, ml307_rx_pin_, ml307_dtr_pin_); + } else { + ESP_LOGI(TAG, "Initialize WiFi board"); + current_board_ = std::make_unique(); + } +} + +void DualNetworkBoard::SwitchNetworkType() { + auto display = GetDisplay(); + if (network_type_ == NetworkType::WIFI) { + SaveNetworkTypeToSettings(NetworkType::ML307); + display->ShowNotification(Lang::Strings::SWITCH_TO_4G_NETWORK); + } else { + SaveNetworkTypeToSettings(NetworkType::WIFI); + display->ShowNotification(Lang::Strings::SWITCH_TO_WIFI_NETWORK); + } + vTaskDelay(pdMS_TO_TICKS(1000)); + auto& app = Application::GetInstance(); + app.Reboot(); +} + + +std::string DualNetworkBoard::GetBoardType() { + return current_board_->GetBoardType(); +} + +void DualNetworkBoard::StartNetwork() { + auto display = Board::GetInstance().GetDisplay(); + + if (network_type_ == NetworkType::WIFI) { + display->SetStatus(Lang::Strings::CONNECTING); + } else { + display->SetStatus(Lang::Strings::DETECTING_MODULE); + } + current_board_->StartNetwork(); +} + +void DualNetworkBoard::SetNetworkEventCallback(NetworkEventCallback callback) { + // Forward the callback to the current board + current_board_->SetNetworkEventCallback(std::move(callback)); +} + +NetworkInterface* DualNetworkBoard::GetNetwork() { + return current_board_->GetNetwork(); +} + +const char* DualNetworkBoard::GetNetworkStateIcon() { + return current_board_->GetNetworkStateIcon(); +} + +void DualNetworkBoard::SetPowerSaveLevel(PowerSaveLevel level) { + current_board_->SetPowerSaveLevel(level); +} + +std::string DualNetworkBoard::GetBoardJson() { + return current_board_->GetBoardJson(); +} + +std::string DualNetworkBoard::GetDeviceStatusJson() { + return current_board_->GetDeviceStatusJson(); +} diff --git a/main/boards/common/dual_network_board.h b/main/boards/common/dual_network_board.h new file mode 100644 index 0000000..8bc9ec8 --- /dev/null +++ b/main/boards/common/dual_network_board.h @@ -0,0 +1,60 @@ +#ifndef DUAL_NETWORK_BOARD_H +#define DUAL_NETWORK_BOARD_H + +#include "board.h" +#include "wifi_board.h" +#include "ml307_board.h" +#include + +//enum NetworkType +enum class NetworkType { + WIFI, + ML307 +}; + +// 双网络板卡类,可以在WiFi和ML307之间切换 +class DualNetworkBoard : public Board { +private: + // 使用基类指针存储当前活动的板卡 + std::unique_ptr current_board_; + NetworkType network_type_ = NetworkType::ML307; // Default to ML307 + + // ML307的引脚配置 + gpio_num_t ml307_tx_pin_; + gpio_num_t ml307_rx_pin_; + gpio_num_t ml307_dtr_pin_; + + // 从Settings加载网络类型 + NetworkType LoadNetworkTypeFromSettings(int32_t default_net_type); + + // 保存网络类型到Settings + void SaveNetworkTypeToSettings(NetworkType type); + + // 初始化当前网络类型对应的板卡 + void InitializeCurrentBoard(); + +public: + DualNetworkBoard(gpio_num_t ml307_tx_pin, gpio_num_t ml307_rx_pin, gpio_num_t ml307_dtr_pin = GPIO_NUM_NC, int32_t default_net_type = 1); + virtual ~DualNetworkBoard() = default; + + // 切换网络类型 + void SwitchNetworkType(); + + // 获取当前网络类型 + NetworkType GetNetworkType() const { return network_type_; } + + // 获取当前活动的板卡引用 + Board& GetCurrentBoard() const { return *current_board_; } + + // 重写Board接口 + virtual std::string GetBoardType() override; + virtual void StartNetwork() override; + virtual void SetNetworkEventCallback(NetworkEventCallback callback) override; + virtual NetworkInterface* GetNetwork() override; + virtual const char* GetNetworkStateIcon() override; + virtual void SetPowerSaveLevel(PowerSaveLevel level) override; + virtual std::string GetBoardJson() override; + virtual std::string GetDeviceStatusJson() override; +}; + +#endif // DUAL_NETWORK_BOARD_H \ No newline at end of file diff --git a/main/boards/common/esp32_camera.cc b/main/boards/common/esp32_camera.cc new file mode 100644 index 0000000..76eaaab --- /dev/null +++ b/main/boards/common/esp32_camera.cc @@ -0,0 +1,322 @@ +#include "sdkconfig.h" + +#include +#include +#include +#include +#include + +#include "esp32_camera.h" +#include "board.h" +#include "display.h" +#include "lvgl_display.h" +#include "mcp_server.h" +#include "system_info.h" +#include "jpg/image_to_jpeg.h" +#include "esp_timer.h" + +#define TAG "Esp32Camera" + +Esp32Camera::Esp32Camera(const camera_config_t &config) { + esp_err_t err = esp_camera_init(&config); + if (err != ESP_OK) { + ESP_LOGE(TAG, "esp_camera_init failed with error 0x%x", err); + return; + } + + sensor_t *s = esp_camera_sensor_get(); + if (s) { + if (s->id.PID == GC0308_PID) { + s->set_hmirror(s, 0); // Control camera mirror: 1 for mirror, 0 for normal + } + ESP_LOGI(TAG, "Camera initialized: format=%d", config.pixel_format); + } + + streaming_on_ = true; +} + +Esp32Camera::~Esp32Camera() { + if (streaming_on_) { + if (current_fb_) { + esp_camera_fb_return(current_fb_); + current_fb_ = nullptr; + } + if (encode_buf_) { + heap_caps_free(encode_buf_); + encode_buf_ = nullptr; + encode_buf_size_ = 0; + } + esp_camera_deinit(); + streaming_on_ = false; + } +} + +void Esp32Camera::SetExplainUrl(const std::string &url, const std::string &token) { + explain_url_ = url; + explain_token_ = token; +} + +bool Esp32Camera::Capture() { + if (encoder_thread_.joinable()) { + encoder_thread_.join(); + } + + if (!streaming_on_) { + return false; + } + + // Get the latest frame, discard old frames for real-time performance + for (int i = 0; i < 2; i++) { + if (current_fb_) { + esp_camera_fb_return(current_fb_); + } + current_fb_ = esp_camera_fb_get(); + if (!current_fb_) { + ESP_LOGE(TAG, "Camera capture failed"); + return false; + } + } + + // Prepare encode buffer for RGB565 format (with optional byte swapping) + if (current_fb_->format == PIXFORMAT_RGB565) { + size_t pixel_count = current_fb_->width * current_fb_->height; + size_t data_size = pixel_count * 2; + + // Allocate or reallocate encode buffer if needed + if (encode_buf_size_ < data_size) { + if (encode_buf_) { + heap_caps_free(encode_buf_); + } + encode_buf_ = (uint8_t *)heap_caps_malloc(data_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (encode_buf_ == nullptr) { + ESP_LOGE(TAG, "Failed to allocate memory for encode buffer"); + encode_buf_size_ = 0; + return false; + } + encode_buf_size_ = data_size; + } + + // Copy data to encode buffer with optional byte swapping + uint16_t *src = (uint16_t *)current_fb_->buf; + uint16_t *dst = (uint16_t *)encode_buf_; + if (swap_bytes_enabled_) { + for (size_t i = 0; i < pixel_count; i++) { + dst[i] = __builtin_bswap16(src[i]); + } + } else { + memcpy(encode_buf_, current_fb_->buf, data_size); + } + + // Allocate separate buffer for preview display + uint8_t *preview_data = (uint8_t *)heap_caps_malloc(data_size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (preview_data != nullptr) { + memcpy(preview_data, encode_buf_, data_size); + auto display = dynamic_cast(Board::GetInstance().GetDisplay()); + if (display != nullptr) { + display->SetPreviewImage(std::make_unique(preview_data, data_size, current_fb_->width, current_fb_->height, current_fb_->width * 2, LV_COLOR_FORMAT_RGB565)); + } else { + heap_caps_free(preview_data); + } + } + } else if (current_fb_->format == PIXFORMAT_JPEG) { + // JPEG format preview usually requires decoding, skip preview display for now, just log + ESP_LOGW(TAG, "JPEG capture success, len=%zu, but not supported for preview", current_fb_->len); + } + + ESP_LOGI(TAG, "Captured frame: %dx%d, len=%zu, format=%d", + current_fb_->width, current_fb_->height, current_fb_->len, current_fb_->format); + + return true; +} + +bool Esp32Camera::SetHMirror(bool enabled) { + sensor_t *s = esp_camera_sensor_get(); + if (!s) { + return false; + } + s->set_hmirror(s, enabled ? 1 : 0); + return true; +} + +bool Esp32Camera::SetVFlip(bool enabled) { + sensor_t *s = esp_camera_sensor_get(); + if (!s) { + return false; + } + s->set_vflip(s, enabled ? 1 : 0); + return true; +} + +bool Esp32Camera::SetSwapBytes(bool enabled) { + swap_bytes_enabled_ = enabled; + return true; +} + +std::string Esp32Camera::Explain(const std::string &question) { + if (explain_url_.empty()) { + throw std::runtime_error("Image explain URL or token is not set"); + } + + if (current_fb_ == nullptr) { + throw std::runtime_error("No camera frame captured"); + } + + // Create local JPEG queue + QueueHandle_t jpeg_queue = xQueueCreate(40, sizeof(JpegChunk)); + if (jpeg_queue == nullptr) { + ESP_LOGE(TAG, "Failed to create JPEG queue"); + throw std::runtime_error("Failed to create JPEG queue"); + } + + // Start encoding thread + encoder_thread_ = std::thread([this, jpeg_queue]() { + int64_t start_time = esp_timer_get_time(); + uint16_t w = current_fb_->width; + uint16_t h = current_fb_->height; + v4l2_pix_fmt_t enc_fmt; + switch (current_fb_->format) { + case PIXFORMAT_RGB565: + enc_fmt = V4L2_PIX_FMT_RGB565; + break; + case PIXFORMAT_YUV422: + enc_fmt = V4L2_PIX_FMT_YUYV; // YUV422 is actually YUYV format + break; + case PIXFORMAT_YUV420: + enc_fmt = V4L2_PIX_FMT_YUV420; + break; + case PIXFORMAT_GRAYSCALE: + enc_fmt = V4L2_PIX_FMT_GREY; + break; + case PIXFORMAT_JPEG: + enc_fmt = V4L2_PIX_FMT_JPEG; + break; + case PIXFORMAT_RGB888: + enc_fmt = V4L2_PIX_FMT_RGB24; + break; + default: + ESP_LOGE(TAG, "Unsupported pixel format: %d", current_fb_->format); + return; + } + + // Use encode buffer for RGB565, otherwise use original frame buffer + uint8_t *jpeg_src_buf = current_fb_->buf; + size_t jpeg_src_len = current_fb_->len; + if (current_fb_->format == PIXFORMAT_RGB565 && encode_buf_ != nullptr) { + jpeg_src_buf = encode_buf_; + jpeg_src_len = encode_buf_size_; + } + + bool ok = image_to_jpeg_cb(jpeg_src_buf, jpeg_src_len, w, h, enc_fmt, 80, + [](void* arg, size_t index, const void* data, size_t len) -> size_t { + auto jpeg_queue = static_cast(arg); + JpegChunk chunk = {.data = nullptr, .len = len}; + if (index == 0 && data != nullptr && len > 0) { + chunk.data = (uint8_t*)heap_caps_aligned_alloc(16, len, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (chunk.data == nullptr) { + ESP_LOGE(TAG, "Failed to allocate %zu bytes for JPEG chunk", len); + chunk.len = 0; + } else { + memcpy(chunk.data, data, len); + } + } else { + chunk.len = 0; // Sentinel or error + } + xQueueSend(jpeg_queue, &chunk, portMAX_DELAY); + return len; + }, jpeg_queue); + + if (!ok) { + JpegChunk chunk = {.data = nullptr, .len = 0}; + xQueueSend(jpeg_queue, &chunk, portMAX_DELAY); + } + int64_t end_time = esp_timer_get_time(); + ESP_LOGI(TAG, "JPEG encoding time: %ld ms", int((end_time - start_time) / 1000)); + }); + + auto network = Board::GetInstance().GetNetwork(); + auto http = network->CreateHttp(3); + std::string boundary = "----ESP32_CAMERA_BOUNDARY"; + + http->SetHeader("Device-Id", SystemInfo::GetMacAddress().c_str()); + http->SetHeader("Client-Id", Board::GetInstance().GetUuid().c_str()); + if (!explain_token_.empty()) { + http->SetHeader("Authorization", "Bearer " + explain_token_); + } + http->SetHeader("Content-Type", "multipart/form-data; boundary=" + boundary); + http->SetHeader("Transfer-Encoding", "chunked"); + if (!http->Open("POST", explain_url_)) { + ESP_LOGE(TAG, "Failed to connect to explain URL"); + encoder_thread_.join(); + JpegChunk chunk; + while (xQueueReceive(jpeg_queue, &chunk, portMAX_DELAY) == pdPASS) { + if (chunk.data != nullptr) { + heap_caps_free(chunk.data); + } else { + break; + } + } + vQueueDelete(jpeg_queue); + throw std::runtime_error("Failed to connect to explain URL"); + } + + { + std::string question_field; + question_field += "--" + boundary + "\r\n"; + question_field += "Content-Disposition: form-data; name=\"question\"\r\n"; + question_field += "\r\n"; + question_field += question + "\r\n"; + http->Write(question_field.c_str(), question_field.size()); + } + { + std::string file_header; + file_header += "--" + boundary + "\r\n"; + file_header += "Content-Disposition: form-data; name=\"file\"; filename=\"camera.jpg\"\r\n"; + file_header += "Content-Type: image/jpeg\r\n"; + file_header += "\r\n"; + http->Write(file_header.c_str(), file_header.size()); + } + + size_t total_sent = 0; + bool saw_terminator = false; + while (true) { + JpegChunk chunk; + if (xQueueReceive(jpeg_queue, &chunk, portMAX_DELAY) != pdPASS) { + ESP_LOGE(TAG, "Failed to receive JPEG chunk"); + break; + } + if (chunk.data == nullptr) { + saw_terminator = true; + break; + } + http->Write((const char *)chunk.data, chunk.len); + total_sent += chunk.len; + heap_caps_free(chunk.data); + } + encoder_thread_.join(); + vQueueDelete(jpeg_queue); + + if (!saw_terminator || total_sent == 0) { + ESP_LOGE(TAG, "JPEG encoder failed or produced empty output"); + throw std::runtime_error("Failed to encode image to JPEG"); + } + + { + std::string multipart_footer; + multipart_footer += "\r\n--" + boundary + "--\r\n"; + http->Write(multipart_footer.c_str(), multipart_footer.size()); + } + http->Write("", 0); + + if (http->GetStatusCode() != 200) { + ESP_LOGE(TAG, "Failed to upload photo, status code: %d", http->GetStatusCode()); + throw std::runtime_error("Failed to upload photo"); + } + + std::string result = http->ReadAll(); + http->Close(); + + size_t remain_stack_size = uxTaskGetStackHighWaterMark(nullptr); + ESP_LOGI(TAG, "Explain image size=%dx%d, compressed size=%d, remain stack size=%d, question=%s\n%s", + current_fb_->width, current_fb_->height, (int)total_sent, (int)remain_stack_size, question.c_str(), result.c_str()); + return result; +} diff --git a/main/boards/common/esp32_camera.h b/main/boards/common/esp32_camera.h new file mode 100644 index 0000000..e2fb071 --- /dev/null +++ b/main/boards/common/esp32_camera.h @@ -0,0 +1,44 @@ +#pragma once +#include "sdkconfig.h" + +#include +#include +#include +#include + +#include +#include + +#include "camera.h" +#include "esp_camera.h" +#include "jpg/image_to_jpeg.h" + +struct JpegChunk +{ + uint8_t *data; + size_t len; +}; + +class Esp32Camera : public Camera +{ +private: + bool streaming_on_ = false; + bool swap_bytes_enabled_ = true; // Swap pixel byte order for RGB565, enabled by default + std::string explain_url_; + std::string explain_token_; + std::thread encoder_thread_; + camera_fb_t *current_fb_ = nullptr; + uint8_t *encode_buf_ = nullptr; // Buffer for JPEG encoding (with optional byte swap) + size_t encode_buf_size_ = 0; + +public: + Esp32Camera(const camera_config_t &config); + ~Esp32Camera(); + + virtual void SetExplainUrl(const std::string &url, const std::string &token) override; + virtual bool Capture() override; + virtual bool SetHMirror(bool enabled) override; + virtual bool SetVFlip(bool enabled) override; + virtual bool SetSwapBytes(bool enabled) override; + virtual std::string Explain(const std::string &question) override; +}; diff --git a/main/boards/common/esp_video.cc b/main/boards/common/esp_video.cc new file mode 100644 index 0000000..9fd4b12 --- /dev/null +++ b/main/boards/common/esp_video.cc @@ -0,0 +1,1041 @@ +#include "sdkconfig.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "esp_imgfx_color_convert.h" +#include "esp_video_device.h" +#include "esp_video_init.h" +#include "linux/videodev2.h" + +#include "board.h" +#include "display.h" +#include "esp_video.h" +#include "esp_jpeg_common.h" +#include "jpg/image_to_jpeg.h" +#include "jpg/jpeg_to_image.h" +#include "lvgl_display.h" +#include "mcp_server.h" +#include "system_info.h" + +#ifdef CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE +#undef LOG_LOCAL_LEVEL +#define LOG_LOCAL_LEVEL MAX(CONFIG_LOG_DEFAULT_LEVEL, ESP_LOG_DEBUG) +#endif // CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE +#include // should be after LOCAL_LOG_LEVEL definition + +#ifdef CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE +#ifdef CONFIG_IDF_TARGET_ESP32P4 +#include "driver/ppa.h" +#if defined(CONFIG_XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE_90) +#define IMAGE_ROTATION_ANGLE (PPA_SRM_ROTATION_ANGLE_270) +#elif defined(CONFIG_XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE_270) +#define IMAGE_ROTATION_ANGLE (PPA_SRM_ROTATION_ANGLE_90) +#else +#error "CONFIG_XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE is not set" +#endif // angle +#else // target +#include "esp_imgfx_rotate.h" +#if defined(CONFIG_XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE_90) +#define IMAGE_ROTATION_ANGLE (90) +#elif defined(CONFIG_XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE_270) +#define IMAGE_ROTATION_ANGLE (270) +#else +#error "CONFIG_XIAOZHI_CAMERA_IMAGE_ROTATION_ANGLE is not set" +#endif // angle +#endif // target +#endif // CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + + +#define TAG "EspVideo" + +#if defined(CONFIG_CAMERA_SENSOR_SWAP_PIXEL_BYTE_ORDER) || defined(CONFIG_XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP) +#warning \ + "CAMERA_SENSOR_SWAP_PIXEL_BYTE_ORDER or CONFIG_XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP is enabled, which may cause image corruption in YUV422 format!" +#endif + +#if CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE +#define CAM_PRINT_FOURCC(pixelformat) \ + char fourcc[5]; \ + fourcc[0] = pixelformat & 0xFF; \ + fourcc[1] = (pixelformat >> 8) & 0xFF; \ + fourcc[2] = (pixelformat >> 16) & 0xFF; \ + fourcc[3] = (pixelformat >> 24) & 0xFF; \ + fourcc[4] = '\0'; \ + ESP_LOGD(TAG, "FOURCC: '%c%c%c%c'", fourcc[0], fourcc[1], fourcc[2], fourcc[3]); + +// for compatibility with old esp_video version +#ifndef MAP_FAILED +#define MAP_FAILED nullptr +#endif + +__attribute__((weak)) esp_err_t esp_video_deinit(void) { + return ESP_ERR_NOT_SUPPORTED; +} +// end of for compatibility with old esp_video version + +static void log_available_video_devices() { + for (int i = 0; i < 50; i++) { + char path[16]; + snprintf(path, sizeof(path), "/dev/video%d", i); + int fd = open(path, O_RDONLY); + if (fd >= 0) { + ESP_LOGD(TAG, "found video device: %s", path); + close(fd); + } + } +} +#else +#define CAM_PRINT_FOURCC(pixelformat) (void)0; +#endif // CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE + +EspVideo::EspVideo(const esp_video_init_config_t& config) { + if (esp_video_init(&config) != ESP_OK) { + ESP_LOGE(TAG, "esp_video_init failed"); + return; + } + +#ifdef CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE + esp_log_level_set(TAG, ESP_LOG_DEBUG); +#endif // CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE + + const char* video_device_name = nullptr; + + if (false) { /* 用于构建 else if */ + } +#if CONFIG_ESP_VIDEO_ENABLE_MIPI_CSI_VIDEO_DEVICE + else if (config.csi != nullptr) { + video_device_name = ESP_VIDEO_MIPI_CSI_DEVICE_NAME; + } +#endif +#if CONFIG_ESP_VIDEO_ENABLE_DVP_VIDEO_DEVICE + else if (config.dvp != nullptr) { + video_device_name = ESP_VIDEO_DVP_DEVICE_NAME; + } +#endif +#if CONFIG_ESP_VIDEO_ENABLE_HW_JPEG_VIDEO_DEVICE + else if (config.jpeg != nullptr) { + video_device_name = ESP_VIDEO_JPEG_DEVICE_NAME; + } +#endif +#if CONFIG_ESP_VIDEO_ENABLE_SPI_VIDEO_DEVICE + else if (config.spi != nullptr) { + video_device_name = ESP_VIDEO_SPI_DEVICE_NAME; + } +#endif +#if CONFIG_ESP_VIDEO_ENABLE_USB_UVC_VIDEO_DEVICE + else if (config.usb_uvc != nullptr) { + video_device_name = ESP_VIDEO_USB_UVC_DEVICE_NAME(0); + } +#endif + + if (video_device_name == nullptr) { + ESP_LOGE(TAG, "no video device is enabled"); + return; + } + + video_fd_ = open(video_device_name, O_RDWR); + + if (video_fd_ < 0) { + ESP_LOGE(TAG, "open %s failed, errno=%d(%s)", video_device_name, errno, strerror(errno)); +#if CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE + log_available_video_devices(); +#endif // CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE + return; + } + + struct v4l2_capability cap = {}; + if (ioctl(video_fd_, VIDIOC_QUERYCAP, &cap) != 0) { + ESP_LOGE(TAG, "VIDIOC_QUERYCAP failed, errno=%d(%s)", errno, strerror(errno)); + close(video_fd_); + video_fd_ = -1; + return; + } + + ESP_LOGD( + TAG, + "VIDIOC_QUERYCAP: driver=%s, card=%s, bus_info=%s, version=0x%08lx, capabilities=0x%08lx, device_caps=0x%08lx", + cap.driver, cap.card, cap.bus_info, cap.version, cap.capabilities, cap.device_caps); + + struct v4l2_format format = {}; + format.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + if (ioctl(video_fd_, VIDIOC_G_FMT, &format) != 0) { + ESP_LOGE(TAG, "VIDIOC_G_FMT failed, errno=%d(%s)", errno, strerror(errno)); + close(video_fd_); + video_fd_ = -1; + return; + } + ESP_LOGD(TAG, "VIDIOC_G_FMT: pixelformat=0x%08lx, width=%ld, height=%ld", format.fmt.pix.pixelformat, + format.fmt.pix.width, format.fmt.pix.height); + CAM_PRINT_FOURCC(format.fmt.pix.pixelformat); + + struct v4l2_format setformat = {}; + setformat.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; +#ifdef CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + sensor_width_ = format.fmt.pix.width; + sensor_height_ = format.fmt.pix.height; +#endif // CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + setformat.fmt.pix.width = format.fmt.pix.width; + setformat.fmt.pix.height = format.fmt.pix.height; + + struct v4l2_fmtdesc fmtdesc = {}; + fmtdesc.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + fmtdesc.index = 0; + uint32_t best_fmt = 0; + int best_rank = 1 << 30; // large number + + // 注: 当前版本 esp_video 中 YUV422P 实际输出为 YUYV。 +#if defined(CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE) && defined(CONFIG_SOC_PPA_SUPPORTED) + auto get_rank = [](uint32_t fmt) -> int { + switch (fmt) { + case V4L2_PIX_FMT_RGB24: + return 0; + case V4L2_PIX_FMT_RGB565: + return 1; +#ifdef CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER + case V4L2_PIX_FMT_YUV420: // 软件 JPEG 编码器不支持 YUV420 格式 + return 2; +#endif // CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER + case V4L2_PIX_FMT_GREY: + case V4L2_PIX_FMT_YUV422P: + default: + return 1 << 29; // unsupported + } + }; +#else + auto get_rank = [](uint32_t fmt) -> int { + switch (fmt) { + case V4L2_PIX_FMT_YUV422P: + return 10; + case V4L2_PIX_FMT_RGB565: + return 11; + case V4L2_PIX_FMT_RGB24: + return 12; +#ifdef CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER + case V4L2_PIX_FMT_YUV420: + return 13; +#endif // CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER +#ifdef CONFIG_XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + case V4L2_PIX_FMT_JPEG: + return 5; +#endif // CONFIG_XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + case V4L2_PIX_FMT_GREY: + return 20; + default: + return 1 << 29; // unsupported + } + }; +#endif + while (ioctl(video_fd_, VIDIOC_ENUM_FMT, &fmtdesc) == 0) { + ESP_LOGD(TAG, "VIDIOC_ENUM_FMT: pixelformat=0x%08lx, description=%s", fmtdesc.pixelformat, fmtdesc.description); + CAM_PRINT_FOURCC(fmtdesc.pixelformat); + int rank = get_rank(fmtdesc.pixelformat); + if (rank < best_rank) { + best_rank = rank; + best_fmt = fmtdesc.pixelformat; + } + fmtdesc.index++; + } + if (best_rank < (1 << 29)) { + setformat.fmt.pix.pixelformat = best_fmt; + sensor_format_ = best_fmt; + } + + if (!setformat.fmt.pix.pixelformat) { + ESP_LOGE(TAG, "no supported pixel format found"); + close(video_fd_); + video_fd_ = -1; + sensor_format_ = 0; + return; + } + + ESP_LOGD(TAG, "selected pixel format: 0x%08lx", setformat.fmt.pix.pixelformat); + + if (ioctl(video_fd_, VIDIOC_S_FMT, &setformat) != 0) { + ESP_LOGE(TAG, "VIDIOC_S_FMT failed, errno=%d(%s)", errno, strerror(errno)); + close(video_fd_); + video_fd_ = -1; + sensor_format_ = 0; + return; + } + +#ifdef CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + frame_.width = setformat.fmt.pix.height; + frame_.height = setformat.fmt.pix.width; +#else + frame_.width = setformat.fmt.pix.width; + frame_.height = setformat.fmt.pix.height; +#endif + + // 申请缓冲并mmap + struct v4l2_requestbuffers req = {}; + req.count = strcmp(video_device_name, ESP_VIDEO_MIPI_CSI_DEVICE_NAME) == 0 ? 2 : 1; + req.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + req.memory = V4L2_MEMORY_MMAP; + if (ioctl(video_fd_, VIDIOC_REQBUFS, &req) != 0) { + ESP_LOGE(TAG, "VIDIOC_REQBUFS failed"); + close(video_fd_); + video_fd_ = -1; + sensor_format_ = 0; + return; + } + mmap_buffers_.resize(req.count); + for (uint32_t i = 0; i < req.count; i++) { + struct v4l2_buffer buf = {}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + buf.index = i; + if (ioctl(video_fd_, VIDIOC_QUERYBUF, &buf) != 0) { + ESP_LOGE(TAG, "VIDIOC_QUERYBUF failed"); + close(video_fd_); + video_fd_ = -1; + sensor_format_ = 0; + return; + } + void* start = mmap(NULL, buf.length, PROT_READ | PROT_WRITE, MAP_SHARED, video_fd_, buf.m.offset); + if (start == MAP_FAILED) { + ESP_LOGE(TAG, "mmap failed"); + close(video_fd_); + video_fd_ = -1; + sensor_format_ = 0; + return; + } + mmap_buffers_[i].start = start; + mmap_buffers_[i].length = buf.length; + + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "VIDIOC_QBUF failed"); + close(video_fd_); + video_fd_ = -1; + sensor_format_ = 0; + return; + } + } + + int type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + if (ioctl(video_fd_, VIDIOC_STREAMON, &type) != 0) { + ESP_LOGE(TAG, "VIDIOC_STREAMON failed"); + close(video_fd_); + video_fd_ = -1; + sensor_format_ = 0; + return; + } + +#ifdef CONFIG_ESP_VIDEO_ENABLE_ISP_VIDEO_DEVICE + // 当启用 ISP 时,ISP 需要一些照片来初始化参数,因此开启后后台拍摄5s照片并丢弃 + xTaskCreate( + [](void* arg) { + EspVideo* self = static_cast(arg); + uint16_t capture_count = 0; + TickType_t start = xTaskGetTickCount(); + TickType_t duration = 5000 / portTICK_PERIOD_MS; // 5s + while ((xTaskGetTickCount() - start) < duration) { + struct v4l2_buffer buf = {}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + if (ioctl(self->video_fd_, VIDIOC_DQBUF, &buf) != 0) { + ESP_LOGE(TAG, "VIDIOC_DQBUF failed during init"); + vTaskDelay(10 / portTICK_PERIOD_MS); + continue; + } + if (ioctl(self->video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "VIDIOC_QBUF failed during init"); + } + capture_count++; + } + ESP_LOGI(TAG, "Camera init success, captured %d frames in %lums", capture_count, + (unsigned long)((xTaskGetTickCount() - start) * portTICK_PERIOD_MS)); + self->streaming_on_ = true; + vTaskDelete(NULL); + }, + "CameraInitTask", 4096, this, 5, nullptr); +#else + ESP_LOGI(TAG, "Camera init success"); + streaming_on_ = true; +#endif // CONFIG_ESP_VIDEO_ENABLE_ISP_VIDEO_DEVICE +} + +EspVideo::~EspVideo() { + if (streaming_on_ && video_fd_ >= 0) { + int type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + ioctl(video_fd_, VIDIOC_STREAMOFF, &type); + } + for (auto& b : mmap_buffers_) { + if (b.start && b.length) { + munmap(b.start, b.length); + } + } + if (video_fd_ >= 0) { + close(video_fd_); + video_fd_ = -1; + } + sensor_format_ = 0; + esp_video_deinit(); +} + +void EspVideo::SetExplainUrl(const std::string& url, const std::string& token) { + explain_url_ = url; + explain_token_ = token; +} + +bool EspVideo::Capture() { + if (encoder_thread_.joinable()) { + encoder_thread_.join(); + } + + if (!streaming_on_ || video_fd_ < 0) { + return false; + } + + for (int i = 0; i < 3; i++) { + struct v4l2_buffer buf = {}; + buf.type = V4L2_BUF_TYPE_VIDEO_CAPTURE; + buf.memory = V4L2_MEMORY_MMAP; + if (ioctl(video_fd_, VIDIOC_DQBUF, &buf) != 0) { + ESP_LOGE(TAG, "VIDIOC_DQBUF failed"); + return false; + } + if (i == 2) { + // 保存帧副本到PSRAM + if (frame_.data) { + heap_caps_free(frame_.data); + frame_.data = nullptr; + frame_.format = 0; + } + frame_.len = buf.bytesused; + frame_.data = (uint8_t*)heap_caps_malloc(frame_.len, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!frame_.data) { + ESP_LOGE(TAG, "alloc frame copy failed: need allocate %lu bytes", buf.bytesused); + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + +#ifdef CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + ESP_LOGW(TAG, "mmap_buffers_[buf.index].length = %d, sensor_width = %d, sensor_height = %d", + mmap_buffers_[buf.index].length, sensor_width_, sensor_height_); +#else + ESP_LOGW(TAG, "mmap_buffers_[buf.index].length = %d, frame.width = %d, frame.height = %d", + mmap_buffers_[buf.index].length, frame_.width, frame_.height); +#endif // CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + ESP_LOG_BUFFER_HEXDUMP(TAG, mmap_buffers_[buf.index].start, MIN(mmap_buffers_[buf.index].length, 256), + ESP_LOG_DEBUG); + + switch (sensor_format_) { + case V4L2_PIX_FMT_RGB565: + case V4L2_PIX_FMT_RGB24: + case V4L2_PIX_FMT_YUYV: + case V4L2_PIX_FMT_YUV420: + case V4L2_PIX_FMT_GREY: +#ifdef CONFIG_XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + case V4L2_PIX_FMT_JPEG: +#endif // CONFIG_XIAOZHI_CAMERA_ALLOW_JPEG_INPUT +#ifdef CONFIG_XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP + { + auto src16 = (uint16_t*)mmap_buffers_[buf.index].start; + auto dst16 = (uint16_t*)frame_.data; + size_t count = (size_t)mmap_buffers_[buf.index].length / 2; + for (size_t i = 0; i < count; i++) { + dst16[i] = __builtin_bswap16(src16[i]); + } + } +#else + memcpy(frame_.data, mmap_buffers_[buf.index].start, + MIN(mmap_buffers_[buf.index].length, frame_.len)); +#endif // CONFIG_XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP + frame_.format = sensor_format_; + break; + case V4L2_PIX_FMT_YUV422P: { + // 这个格式是 422 YUYV,不是 planer + frame_.format = V4L2_PIX_FMT_YUYV; +#ifdef CONFIG_XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP + { + auto src16 = (uint16_t*)mmap_buffers_[buf.index].start; + auto dst16 = (uint16_t*)frame_.data; + size_t count = (size_t)mmap_buffers_[buf.index].length / 2; + for (size_t i = 0; i < count; i++) { + dst16[i] = __builtin_bswap16(src16[i]); + } + } +#else + memcpy(frame_.data, mmap_buffers_[buf.index].start, + MIN(mmap_buffers_[buf.index].length, frame_.len)); +#endif // CONFIG_XIAOZHI_ENABLE_CAMERA_ENDIANNESS_SWAP + break; + } + case V4L2_PIX_FMT_RGB565X: { + // 大端序的 RGB565 需要转换为小端序 + // 目前 esp_video 的大小端都会返回格式为 RGB565,不会返回格式为 RGB565X,此 case 用于未来版本兼容 + auto src16 = (uint16_t*)mmap_buffers_[buf.index].start; + auto dst16 = (uint16_t*)frame_.data; + size_t pixel_count = (size_t)frame_.width * (size_t)frame_.height; + for (size_t i = 0; i < pixel_count; i++) { + dst16[i] = __builtin_bswap16(src16[i]); + } + frame_.format = V4L2_PIX_FMT_RGB565; + break; + } + default: + ESP_LOGE(TAG, "unsupported sensor format: 0x%08lx", sensor_format_); + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + +#ifdef CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE +#ifndef CONFIG_SOC_PPA_SUPPORTED + uint8_t* rotate_dst = + (uint8_t*)heap_caps_aligned_alloc(64, frame_.len, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (rotate_dst == nullptr) { + ESP_LOGE(TAG, "Failed to allocate memory for rotate image"); + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + uint8_t* rotate_src = (uint8_t*)frame_.data; + + esp_imgfx_rotate_cfg_t rotate_cfg = { + .in_res = + { + .width = static_cast(sensor_width_), + .height = static_cast(sensor_height_), + }, + .degree = IMAGE_ROTATION_ANGLE, + }; + switch (frame_.format) { + case V4L2_PIX_FMT_RGB565: + rotate_cfg.in_pixel_fmt = ESP_IMGFX_PIXEL_FMT_RGB565_LE; + break; + case V4L2_PIX_FMT_YUYV: + rotate_cfg.in_pixel_fmt = ESP_IMGFX_PIXEL_FMT_RGB565_LE; + break; + case V4L2_PIX_FMT_GREY: + rotate_cfg.in_pixel_fmt = ESP_IMGFX_PIXEL_FMT_Y; + break; + case V4L2_PIX_FMT_RGB24: + rotate_cfg.in_pixel_fmt = ESP_IMGFX_PIXEL_FMT_RGB888; + break; + default: + ESP_LOGE(TAG, "unsupported sensor format: 0x%08lx", sensor_format_); + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + esp_imgfx_rotate_handle_t rotate_handle = nullptr; + esp_imgfx_err_t imgfx_err = esp_imgfx_rotate_open(&rotate_cfg, &rotate_handle); + if (imgfx_err != ESP_IMGFX_ERR_OK || rotate_handle == nullptr) { + ESP_LOGE(TAG, "esp_imgfx_rotate_create failed"); + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + + esp_imgfx_data_t rotate_input_data = { + .data = rotate_src, + .data_len = frame_.len, + }; + esp_imgfx_data_t rotate_output_data = { + .data = rotate_dst, + .data_len = frame_.len, + }; + + imgfx_err = esp_imgfx_rotate_process(rotate_handle, &rotate_input_data, &rotate_output_data); + if (imgfx_err != ESP_IMGFX_ERR_OK) { + ESP_LOGE(TAG, "esp_imgfx_rotate_process failed"); + heap_caps_free(rotate_dst); + rotate_dst = nullptr; + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + esp_imgfx_rotate_close(rotate_handle); + rotate_handle = nullptr; + return false; + } + + frame_.data = rotate_dst; + + heap_caps_free(rotate_src); + rotate_src = nullptr; + + esp_imgfx_rotate_close(rotate_handle); + rotate_handle = nullptr; +#else // CONFIG_SOC_PPA_SUPPORTED + uint8_t* rotate_src = nullptr; + + ppa_srm_color_mode_t ppa_color_mode; + switch (frame_.format) { + case V4L2_PIX_FMT_RGB565: + rotate_src = (uint8_t*)frame_.data; + ppa_color_mode = PPA_SRM_COLOR_MODE_RGB565; + break; + case V4L2_PIX_FMT_RGB24: + rotate_src = (uint8_t*)frame_.data; + ppa_color_mode = PPA_SRM_COLOR_MODE_RGB888; + break; + case V4L2_PIX_FMT_YUYV: { + ESP_LOGW(TAG, "YUYV format is not supported for PPA rotation, using software conversion to RGB888"); + rotate_src = (uint8_t*)heap_caps_malloc(frame_.width * frame_.height * 3, + MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (rotate_src == nullptr) { + ESP_LOGE(TAG, "Failed to allocate memory for rotate image"); + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + esp_imgfx_color_convert_cfg_t convert_cfg = { + .in_res = {.width = static_cast(frame_.width), + .height = static_cast(frame_.height)}, + .in_pixel_fmt = ESP_IMGFX_PIXEL_FMT_YUYV, + .out_pixel_fmt = ESP_IMGFX_PIXEL_FMT_RGB888, + }; + esp_imgfx_color_convert_handle_t convert_handle = nullptr; + esp_imgfx_err_t err = esp_imgfx_color_convert_open(&convert_cfg, &convert_handle); + if (err != ESP_IMGFX_ERR_OK || convert_handle == nullptr) { + ESP_LOGE(TAG, "esp_imgfx_color_convert_open failed"); + heap_caps_free(rotate_src); + rotate_src = nullptr; + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + esp_imgfx_data_t convert_input_data = { + .data = frame_.data, + .data_len = frame_.len, + }; + esp_imgfx_data_t convert_output_data = { + .data = rotate_src, + .data_len = static_cast(frame_.width * frame_.height * 3), + }; + err = esp_imgfx_color_convert_process(convert_handle, &convert_input_data, &convert_output_data); + if (err != ESP_IMGFX_ERR_OK) { + ESP_LOGE(TAG, "esp_imgfx_color_convert_process failed"); + heap_caps_free(rotate_src); + rotate_src = nullptr; + esp_imgfx_color_convert_close(convert_handle); + convert_handle = nullptr; + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + esp_imgfx_color_convert_close(convert_handle); + convert_handle = nullptr; + ppa_color_mode = PPA_SRM_COLOR_MODE_RGB888; + heap_caps_free(frame_.data); + frame_.data = rotate_src; + frame_.len = frame_.width * frame_.height * 3; + break; + } + default: + ESP_LOGE(TAG, "unsupported sensor format for PPA rotation: 0x%08lx", sensor_format_); + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + + uint8_t* rotate_dst = (uint8_t*)heap_caps_malloc( + frame_.width * frame_.height * 2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT | MALLOC_CAP_CACHE_ALIGNED); + if (rotate_dst == nullptr) { + ESP_LOGE(TAG, "Failed to allocate memory for rotate image"); + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + + ppa_client_handle_t ppa_client = nullptr; + ppa_client_config_t client_cfg = { + .oper_type = PPA_OPERATION_SRM, + .max_pending_trans_num = 1, + }; + esp_err_t err = ppa_register_client(&client_cfg, &ppa_client); + if (err != ESP_OK || ppa_client == nullptr) { + ESP_LOGE(TAG, "ppa_register_client failed: %d", (int)err); + heap_caps_free(rotate_dst); + rotate_dst = nullptr; + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + + ppa_srm_rotation_angle_t ppa_angle = IMAGE_ROTATION_ANGLE; + + ppa_srm_oper_config_t srm_cfg = {}; + srm_cfg.in.buffer = (void*)rotate_src; + srm_cfg.in.pic_w = sensor_width_; + srm_cfg.in.pic_h = sensor_height_; + srm_cfg.in.block_w = sensor_width_; + srm_cfg.in.block_h = sensor_height_; + srm_cfg.in.block_offset_x = 0; + srm_cfg.in.block_offset_y = 0; + srm_cfg.in.srm_cm = ppa_color_mode; + + srm_cfg.out.buffer = (void*)rotate_dst; + srm_cfg.out.buffer_size = frame_.len; + srm_cfg.out.pic_w = frame_.width; + srm_cfg.out.pic_h = frame_.height; + srm_cfg.out.block_offset_x = 0; + srm_cfg.out.block_offset_y = 0; + srm_cfg.out.srm_cm = PPA_SRM_COLOR_MODE_RGB565; + + // 等比例缩放 1.0 + srm_cfg.scale_x = 1.0f; + srm_cfg.scale_y = 1.0f; + srm_cfg.rotation_angle = ppa_angle; + srm_cfg.mode = PPA_TRANS_MODE_BLOCKING; + srm_cfg.user_data = nullptr; + + err = ppa_do_scale_rotate_mirror(ppa_client, &srm_cfg); + if (err != ESP_OK) { + ESP_LOGE(TAG, "ppa_do_scale_rotate_mirror failed: %d", (int)err); + heap_caps_free(rotate_dst); + rotate_dst = nullptr; + (void)ppa_unregister_client(ppa_client); + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "Cleanup: VIDIOC_QBUF failed"); + } + return false; + } + + (void)ppa_unregister_client(ppa_client); + + frame_.data = rotate_dst; + frame_.len = frame_.width * frame_.height * 2; + frame_.format = V4L2_PIX_FMT_RGB565; + heap_caps_free(rotate_src); + rotate_src = nullptr; +#endif // CONFIG_SOC_PPA_SUPPORTED +#endif // CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + } + + if (ioctl(video_fd_, VIDIOC_QBUF, &buf) != 0) { + ESP_LOGE(TAG, "VIDIOC_QBUF failed"); + } + } + + // 显示预览图片 + auto display = dynamic_cast(Board::GetInstance().GetDisplay()); + if (display != nullptr) { + if (!frame_.data) { + ESP_LOGE(TAG, "frame.data is null"); + return false; + } + uint16_t w = frame_.width; + uint16_t h = frame_.height; + size_t lvgl_image_size = frame_.len; + size_t stride = ((w * 2) + 3) & ~3; // 4字节对齐 + lv_color_format_t color_format = LV_COLOR_FORMAT_RGB565; + uint8_t* data = nullptr; + + switch (frame_.format) { + // LVGL 显示 YUV 系的图像似乎都有问题,暂时转换为 RGB565 显示 + case V4L2_PIX_FMT_YUYV: + case V4L2_PIX_FMT_YUV420: + case V4L2_PIX_FMT_RGB24: { + color_format = LV_COLOR_FORMAT_RGB565; + data = (uint8_t*)heap_caps_malloc(w * h * 2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (data == nullptr) { + ESP_LOGE(TAG, "Failed to allocate memory for preview image"); + return false; + } + esp_imgfx_color_convert_cfg_t convert_cfg = { + .in_res = {.width = static_cast(frame_.width), + .height = static_cast(frame_.height)}, + .in_pixel_fmt = static_cast(frame_.format), + .out_pixel_fmt = ESP_IMGFX_PIXEL_FMT_RGB565_LE, + .color_space_std = ESP_IMGFX_COLOR_SPACE_STD_BT601, + }; + esp_imgfx_color_convert_handle_t convert_handle = nullptr; + esp_imgfx_err_t err = esp_imgfx_color_convert_open(&convert_cfg, &convert_handle); + if (err != ESP_IMGFX_ERR_OK || convert_handle == nullptr) { + ESP_LOGE(TAG, "esp_imgfx_color_convert_open failed"); + heap_caps_free(data); + data = nullptr; + return false; + } + esp_imgfx_data_t convert_input_data = { + .data = frame_.data, + .data_len = frame_.len, + }; + esp_imgfx_data_t convert_output_data = { + .data = data, + .data_len = static_cast(w * h * 2), + }; + err = esp_imgfx_color_convert_process(convert_handle, &convert_input_data, &convert_output_data); + if (err != ESP_IMGFX_ERR_OK) { + ESP_LOGE(TAG, "esp_imgfx_color_convert_process failed"); + heap_caps_free(data); + data = nullptr; + esp_imgfx_color_convert_close(convert_handle); + convert_handle = nullptr; + return false; + } + esp_imgfx_color_convert_close(convert_handle); + convert_handle = nullptr; + lvgl_image_size = w * h * 2; + break; + } + + case V4L2_PIX_FMT_RGB565: + data = (uint8_t*)heap_caps_malloc(w * h * 2, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (data == nullptr) { + ESP_LOGE(TAG, "Failed to allocate memory for preview image"); + return false; + } + memcpy(data, frame_.data, frame_.len); + lvgl_image_size = frame_.len; // fallthrough 时兼顾 YUYV 与 RGB565 + break; + +#ifdef CONFIG_XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + case V4L2_PIX_FMT_JPEG: { + uint8_t* out_data = nullptr; // out data is allocated by jpeg_to_image + size_t out_len = 0; + size_t out_width = 0; + size_t out_height = 0; + size_t out_stride = 0; + + esp_err_t ret = + jpeg_to_image(frame_.data, frame_.len, &out_data, &out_len, &out_width, &out_height, &out_stride); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to decode JPEG image: %d (%s)", (int)ret, esp_err_to_name(ret)); + if (out_data) { + heap_caps_free(out_data); + out_data = nullptr; + } + return false; + } + + data = out_data; + w = out_width; + h = out_height; + lvgl_image_size = out_len; + stride = out_stride; + break; + } +#endif + default: + ESP_LOGE(TAG, "unsupported frame format: 0x%08lx", frame_.format); + return false; + } + + auto image = std::make_unique(data, lvgl_image_size, w, h, stride, color_format); + display->SetPreviewImage(std::move(image)); + } + return true; +} + +bool EspVideo::SetHMirror(bool enabled) { + if (video_fd_ < 0) + return false; + struct v4l2_ext_controls ctrls = {}; + struct v4l2_ext_control ctrl = {}; + ctrl.id = V4L2_CID_HFLIP; + ctrl.value = enabled ? 1 : 0; + ctrls.ctrl_class = V4L2_CTRL_CLASS_USER; + ctrls.count = 1; + ctrls.controls = &ctrl; + if (ioctl(video_fd_, VIDIOC_S_EXT_CTRLS, &ctrls) != 0) { + ESP_LOGE(TAG, "set HFLIP failed"); + return false; + } + return true; +} + +bool EspVideo::SetVFlip(bool enabled) { + if (video_fd_ < 0) + return false; + struct v4l2_ext_controls ctrls = {}; + struct v4l2_ext_control ctrl = {}; + ctrl.id = V4L2_CID_VFLIP; + ctrl.value = enabled ? 1 : 0; + ctrls.ctrl_class = V4L2_CTRL_CLASS_USER; + ctrls.count = 1; + ctrls.controls = &ctrl; + if (ioctl(video_fd_, VIDIOC_S_EXT_CTRLS, &ctrls) != 0) { + ESP_LOGE(TAG, "set VFLIP failed"); + return false; + } + return true; +} + +/** + * @brief 将摄像头捕获的图像发送到远程服务器进行AI分析和解释 + * + * 该函数将当前摄像头缓冲区中的图像编码为JPEG格式,并通过HTTP POST请求 + * 以multipart/form-data的形式发送到指定的解释服务器。服务器将根据提供的 + * 问题对图像进行AI分析并返回结果。 + * + * 实现特点: + * - 使用独立线程编码JPEG,与主线程分离 + * - 采用分块传输编码(chunked transfer encoding)优化内存使用 + * - 通过队列机制实现编码线程和发送线程的数据同步 + * - 支持设备ID、客户端ID和认证令牌的HTTP头部配置 + * + * @param question 要向AI提出的关于图像的问题,将作为表单字段发送 + * @return std::string 服务器返回的JSON格式响应字符串 + * 成功时包含AI分析结果,失败时包含错误信息 + * 格式示例:{"success": true, "result": "分析结果"} + * {"success": false, "message": "错误信息"} + * + * @note 调用此函数前必须先调用SetExplainUrl()设置服务器URL + * @note 函数会等待之前的编码线程完成后再开始新的处理 + * @warning 如果摄像头缓冲区为空或网络连接失败,将返回错误信息 + */ +std::string EspVideo::Explain(const std::string& question) { + if (explain_url_.empty()) { + throw std::runtime_error("Image explain URL or token is not set"); + } + + // 创建局部的 JPEG 队列, 40 entries is about to store 512 * 40 = 20480 bytes of JPEG data + QueueHandle_t jpeg_queue = xQueueCreate(40, sizeof(JpegChunk)); + if (jpeg_queue == nullptr) { + ESP_LOGE(TAG, "Failed to create JPEG queue"); + throw std::runtime_error("Failed to create JPEG queue"); + } + + // We spawn a thread to encode the image to JPEG using optimized encoder (cost about 500ms and 8KB SRAM) + encoder_thread_ = std::thread([this, jpeg_queue]() { + uint16_t w = frame_.width ? frame_.width : 320; + uint16_t h = frame_.height ? frame_.height : 240; + v4l2_pix_fmt_t enc_fmt = frame_.format; + bool ok = image_to_jpeg_cb( + frame_.data, frame_.len, w, h, enc_fmt, 80, + [](void* arg, size_t index, const void* data, size_t len) -> size_t { + auto jpeg_queue = static_cast(arg); + JpegChunk chunk = {.data = nullptr, .len = len}; + if (index == 0 && data != nullptr && len > 0) { + chunk.data = (uint8_t*)heap_caps_aligned_alloc(16, len, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (chunk.data == nullptr) { + ESP_LOGE(TAG, "Failed to allocate %zu bytes for JPEG chunk", len); + chunk.len = 0; + } else { + memcpy(chunk.data, data, len); + } + } else { + chunk.len = 0; // Sentinel or error + } + xQueueSend(jpeg_queue, &chunk, portMAX_DELAY); + return len; + }, + jpeg_queue); + + if (!ok) { + JpegChunk chunk = {.data = nullptr, .len = 0}; + xQueueSend(jpeg_queue, &chunk, portMAX_DELAY); + } + }); + + auto network = Board::GetInstance().GetNetwork(); + auto http = network->CreateHttp(3); + // 构造multipart/form-data请求体 + std::string boundary = "----ESP32_CAMERA_BOUNDARY"; + + // 配置HTTP客户端,使用分块传输编码 + http->SetHeader("Device-Id", SystemInfo::GetMacAddress().c_str()); + http->SetHeader("Client-Id", Board::GetInstance().GetUuid().c_str()); + if (!explain_token_.empty()) { + http->SetHeader("Authorization", "Bearer " + explain_token_); + } + http->SetHeader("Content-Type", "multipart/form-data; boundary=" + boundary); + http->SetHeader("Transfer-Encoding", "chunked"); + if (!http->Open("POST", explain_url_)) { + ESP_LOGE(TAG, "Failed to connect to explain URL"); + // Clear the queue + encoder_thread_.join(); + JpegChunk chunk; + while (xQueueReceive(jpeg_queue, &chunk, portMAX_DELAY) == pdPASS) { + if (chunk.data != nullptr) { + heap_caps_free(chunk.data); + } else { + break; + } + } + vQueueDelete(jpeg_queue); + throw std::runtime_error("Failed to connect to explain URL"); + } + + { + // 第一块:question字段 + std::string question_field; + question_field += "--" + boundary + "\r\n"; + question_field += "Content-Disposition: form-data; name=\"question\"\r\n"; + question_field += "\r\n"; + question_field += question + "\r\n"; + http->Write(question_field.c_str(), question_field.size()); + } + { + // 第二块:文件字段头部 + std::string file_header; + file_header += "--" + boundary + "\r\n"; + file_header += "Content-Disposition: form-data; name=\"file\"; filename=\"camera.jpg\"\r\n"; + file_header += "Content-Type: image/jpeg\r\n"; + file_header += "\r\n"; + http->Write(file_header.c_str(), file_header.size()); + } + + // 第三块:JPEG数据 + size_t total_sent = 0; + bool saw_terminator = false; + while (true) { + JpegChunk chunk; + if (xQueueReceive(jpeg_queue, &chunk, portMAX_DELAY) != pdPASS) { + ESP_LOGE(TAG, "Failed to receive JPEG chunk"); + break; + } + if (chunk.data == nullptr) { + saw_terminator = true; + break; // The last chunk + } + http->Write((const char*)chunk.data, chunk.len); + total_sent += chunk.len; + heap_caps_free(chunk.data); + } + // Wait for the encoder thread to finish + encoder_thread_.join(); + // 清理队列 + vQueueDelete(jpeg_queue); + + if (!saw_terminator || total_sent == 0) { + ESP_LOGE(TAG, "JPEG encoder failed or produced empty output"); + throw std::runtime_error("Failed to encode image to JPEG"); + } + + { + // 第四块:multipart尾部 + std::string multipart_footer; + multipart_footer += "\r\n--" + boundary + "--\r\n"; + http->Write(multipart_footer.c_str(), multipart_footer.size()); + } + // 结束块 + http->Write("", 0); + + if (http->GetStatusCode() != 200) { + ESP_LOGE(TAG, "Failed to upload photo, status code: %d", http->GetStatusCode()); + throw std::runtime_error("Failed to upload photo"); + } + + std::string result = http->ReadAll(); + http->Close(); + + // Get remain task stack size + size_t remain_stack_size = uxTaskGetStackHighWaterMark(nullptr); + ESP_LOGI(TAG, "Explain image size=%d bytes, compressed size=%d, remain stack size=%d, question=%s\n%s", + (int)frame_.len, (int)total_sent, (int)remain_stack_size, question.c_str(), result.c_str()); + return result; +} diff --git a/main/boards/common/esp_video.h b/main/boards/common/esp_video.h new file mode 100644 index 0000000..063276e --- /dev/null +++ b/main/boards/common/esp_video.h @@ -0,0 +1,53 @@ +#pragma once +#include "sdkconfig.h" + +#include +#include +#include +#include + +#include +#include + +#include "camera.h" +#include "jpg/image_to_jpeg.h" +#include "esp_video_init.h" + +struct JpegChunk { + uint8_t* data; + size_t len; +}; + +class EspVideo : public Camera { +private: + struct FrameBuffer { + uint8_t *data = nullptr; + size_t len = 0; + uint16_t width = 0; + uint16_t height = 0; + v4l2_pix_fmt_t format = 0; + } frame_; + v4l2_pix_fmt_t sensor_format_ = 0; +#ifdef CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + uint16_t sensor_width_ = 0; + uint16_t sensor_height_ = 0; +#endif // CONFIG_XIAOZHI_ENABLE_ROTATE_CAMERA_IMAGE + int video_fd_ = -1; + bool streaming_on_ = false; + struct MmapBuffer { void *start = nullptr; size_t length = 0; }; + std::vector mmap_buffers_; + std::string explain_url_; + std::string explain_token_; + std::thread encoder_thread_; + +public: + EspVideo(const esp_video_init_config_t& config); + ~EspVideo(); + + virtual void SetExplainUrl(const std::string& url, const std::string& token); + virtual bool Capture(); + // 翻转控制函数 + virtual bool SetHMirror(bool enabled) override; + virtual bool SetVFlip(bool enabled) override; + virtual std::string Explain(const std::string& question); +}; diff --git a/main/boards/common/i2c_device.cc b/main/boards/common/i2c_device.cc new file mode 100644 index 0000000..835997d --- /dev/null +++ b/main/boards/common/i2c_device.cc @@ -0,0 +1,35 @@ +#include "i2c_device.h" + +#include + +#define TAG "I2cDevice" + + +I2cDevice::I2cDevice(i2c_master_bus_handle_t i2c_bus, uint8_t addr) { + i2c_device_config_t i2c_device_cfg = { + .dev_addr_length = I2C_ADDR_BIT_LEN_7, + .device_address = addr, + .scl_speed_hz = 400 * 1000, + .scl_wait_us = 0, + .flags = { + .disable_ack_check = 0, + }, + }; + ESP_ERROR_CHECK(i2c_master_bus_add_device(i2c_bus, &i2c_device_cfg, &i2c_device_)); + assert(i2c_device_ != NULL); +} + +void I2cDevice::WriteReg(uint8_t reg, uint8_t value) { + uint8_t buffer[2] = {reg, value}; + ESP_ERROR_CHECK(i2c_master_transmit(i2c_device_, buffer, 2, 100)); +} + +uint8_t I2cDevice::ReadReg(uint8_t reg) { + uint8_t buffer[1]; + ESP_ERROR_CHECK(i2c_master_transmit_receive(i2c_device_, ®, 1, buffer, 1, 100)); + return buffer[0]; +} + +void I2cDevice::ReadRegs(uint8_t reg, uint8_t* buffer, size_t length) { + ESP_ERROR_CHECK(i2c_master_transmit_receive(i2c_device_, ®, 1, buffer, length, 100)); +} \ No newline at end of file diff --git a/main/boards/common/i2c_device.h b/main/boards/common/i2c_device.h new file mode 100644 index 0000000..7bc917b --- /dev/null +++ b/main/boards/common/i2c_device.h @@ -0,0 +1,18 @@ +#ifndef I2C_DEVICE_H +#define I2C_DEVICE_H + +#include + +class I2cDevice { +public: + I2cDevice(i2c_master_bus_handle_t i2c_bus, uint8_t addr); + +protected: + i2c_master_dev_handle_t i2c_device_; + + void WriteReg(uint8_t reg, uint8_t value); + uint8_t ReadReg(uint8_t reg); + void ReadRegs(uint8_t reg, uint8_t* buffer, size_t length); +}; + +#endif // I2C_DEVICE_H diff --git a/main/boards/common/knob.cc b/main/boards/common/knob.cc new file mode 100644 index 0000000..350fda2 --- /dev/null +++ b/main/boards/common/knob.cc @@ -0,0 +1,52 @@ +#include "knob.h" + +static const char* TAG = "Knob"; + +Knob::Knob(gpio_num_t pin_a, gpio_num_t pin_b) { + knob_config_t config = { + .default_direction = 0, + .gpio_encoder_a = static_cast(pin_a), + .gpio_encoder_b = static_cast(pin_b), + }; + + esp_err_t err = ESP_OK; + knob_handle_ = iot_knob_create(&config); + if (knob_handle_ == NULL) { + ESP_LOGE(TAG, "Failed to create knob instance"); + return; + } + + err = iot_knob_register_cb(knob_handle_, KNOB_LEFT, knob_callback, this); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to register left callback: %s", esp_err_to_name(err)); + return; + } + + err = iot_knob_register_cb(knob_handle_, KNOB_RIGHT, knob_callback, this); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to register right callback: %s", esp_err_to_name(err)); + return; + } + + ESP_LOGI(TAG, "Knob initialized with pins A:%d B:%d", pin_a, pin_b); +} + +Knob::~Knob() { + if (knob_handle_ != NULL) { + iot_knob_delete(knob_handle_); + knob_handle_ = NULL; + } +} + +void Knob::OnRotate(std::function callback) { + on_rotate_ = callback; +} + +void Knob::knob_callback(void* arg, void* data) { + Knob* knob = static_cast(data); + knob_event_t event = iot_knob_get_event(arg); + + if (knob->on_rotate_) { + knob->on_rotate_(event == KNOB_RIGHT); + } +} \ No newline at end of file diff --git a/main/boards/common/knob.h b/main/boards/common/knob.h new file mode 100644 index 0000000..efea5f5 --- /dev/null +++ b/main/boards/common/knob.h @@ -0,0 +1,25 @@ +#ifndef KNOB_H_ +#define KNOB_H_ + +#include +#include +#include +#include + +class Knob { +public: + Knob(gpio_num_t pin_a, gpio_num_t pin_b); + ~Knob(); + + void OnRotate(std::function callback); + +private: + static void knob_callback(void* arg, void* data); + + knob_handle_t knob_handle_; + gpio_num_t pin_a_; + gpio_num_t pin_b_; + std::function on_rotate_; +}; + +#endif // KNOB_H_ \ No newline at end of file diff --git a/main/boards/common/lamp_controller.h b/main/boards/common/lamp_controller.h new file mode 100644 index 0000000..1ed142c --- /dev/null +++ b/main/boards/common/lamp_controller.h @@ -0,0 +1,44 @@ +#ifndef __LAMP_CONTROLLER_H__ +#define __LAMP_CONTROLLER_H__ + +#include "mcp_server.h" + + +class LampController { +private: + bool power_ = false; + gpio_num_t gpio_num_; + +public: + LampController(gpio_num_t gpio_num) : gpio_num_(gpio_num) { + gpio_config_t config = { + .pin_bit_mask = (1ULL << gpio_num_), + .mode = GPIO_MODE_OUTPUT, + .pull_up_en = GPIO_PULLUP_DISABLE, + .pull_down_en = GPIO_PULLDOWN_DISABLE, + .intr_type = GPIO_INTR_DISABLE, + }; + ESP_ERROR_CHECK(gpio_config(&config)); + gpio_set_level(gpio_num_, 0); + + auto& mcp_server = McpServer::GetInstance(); + mcp_server.AddTool("self.lamp.get_state", "Get the power state of the lamp", PropertyList(), [this](const PropertyList& properties) -> ReturnValue { + return power_ ? "{\"power\": true}" : "{\"power\": false}"; + }); + + mcp_server.AddTool("self.lamp.turn_on", "Turn on the lamp", PropertyList(), [this](const PropertyList& properties) -> ReturnValue { + power_ = true; + gpio_set_level(gpio_num_, 1); + return true; + }); + + mcp_server.AddTool("self.lamp.turn_off", "Turn off the lamp", PropertyList(), [this](const PropertyList& properties) -> ReturnValue { + power_ = false; + gpio_set_level(gpio_num_, 0); + return true; + }); + } +}; + + +#endif // __LAMP_CONTROLLER_H__ diff --git a/main/boards/common/ml307_board.cc b/main/boards/common/ml307_board.cc new file mode 100644 index 0000000..d204bd7 --- /dev/null +++ b/main/boards/common/ml307_board.cc @@ -0,0 +1,270 @@ +#include "ml307_board.h" + +#include "audio_codec.h" +#include "display.h" + +#include +#include +#include +#include +#include +#include + +static const char *TAG = "Ml307Board"; + +// Maximum retry count for modem detection +static constexpr int MODEM_DETECT_MAX_RETRIES = 30; +// Maximum retry count for network registration +static constexpr int NETWORK_REG_MAX_RETRIES = 6; + +Ml307Board::Ml307Board(gpio_num_t tx_pin, gpio_num_t rx_pin, gpio_num_t dtr_pin) : tx_pin_(tx_pin), rx_pin_(rx_pin), dtr_pin_(dtr_pin) { +} + +std::string Ml307Board::GetBoardType() { + return "ml307"; +} + +void Ml307Board::SetNetworkEventCallback(NetworkEventCallback callback) { + network_event_callback_ = std::move(callback); +} + +void Ml307Board::OnNetworkEvent(NetworkEvent event, const std::string& data) { + switch (event) { + case NetworkEvent::ModemDetecting: + ESP_LOGI(TAG, "Detecting modem..."); + break; + case NetworkEvent::Connecting: + ESP_LOGI(TAG, "Registering network..."); + break; + case NetworkEvent::Connected: + ESP_LOGI(TAG, "Network connected"); + break; + case NetworkEvent::Disconnected: + ESP_LOGW(TAG, "Network disconnected"); + break; + case NetworkEvent::ModemErrorNoSim: + ESP_LOGE(TAG, "No SIM card detected"); + break; + case NetworkEvent::ModemErrorRegDenied: + ESP_LOGE(TAG, "Network registration denied"); + break; + case NetworkEvent::ModemErrorInitFailed: + ESP_LOGE(TAG, "Modem initialization failed"); + break; + case NetworkEvent::ModemErrorTimeout: + ESP_LOGE(TAG, "Operation timeout"); + break; + default: + break; + } + + // Notify external callback if set + if (network_event_callback_) { + network_event_callback_(event, data); + } +} + +void Ml307Board::NetworkTask() { + // Notify modem detection started + OnNetworkEvent(NetworkEvent::ModemDetecting); + + // Try to detect modem with retry limit + int detect_retries = 0; + while (detect_retries < MODEM_DETECT_MAX_RETRIES) { + modem_ = AtModem::Detect(tx_pin_, rx_pin_, dtr_pin_, 921600); + if (modem_ != nullptr) { + break; + } + detect_retries++; + vTaskDelay(pdMS_TO_TICKS(1000)); + } + + if (modem_ == nullptr) { + ESP_LOGE(TAG, "Failed to detect modem after %d retries", MODEM_DETECT_MAX_RETRIES); + OnNetworkEvent(NetworkEvent::ModemErrorInitFailed); + return; + } + + ESP_LOGI(TAG, "Modem detected successfully"); + + // Set up network state change callback + // Note: Don't call GetCarrierName() here as it sends AT command and will block ReceiveTask + modem_->OnNetworkStateChanged([this](bool network_ready) { + if (network_ready) { + OnNetworkEvent(NetworkEvent::Connected); + } else { + OnNetworkEvent(NetworkEvent::Disconnected); + } + }); + + // Notify network registration started + OnNetworkEvent(NetworkEvent::Connecting); + + // Wait for network ready with retry limit + int reg_retries = 0; + while (reg_retries < NETWORK_REG_MAX_RETRIES) { + auto result = modem_->WaitForNetworkReady(); + if (result == NetworkStatus::Ready) { + break; + } else if (result == NetworkStatus::ErrorInsertPin) { + OnNetworkEvent(NetworkEvent::ModemErrorNoSim); + } else if (result == NetworkStatus::ErrorRegistrationDenied) { + OnNetworkEvent(NetworkEvent::ModemErrorRegDenied); + } else if (result == NetworkStatus::ErrorTimeout) { + OnNetworkEvent(NetworkEvent::ModemErrorTimeout); + } + reg_retries++; + vTaskDelay(pdMS_TO_TICKS(10000)); + } + + if (!modem_->network_ready()) { + ESP_LOGE(TAG, "Failed to register network after %d retries", NETWORK_REG_MAX_RETRIES); + return; + } + + // Print the ML307 modem information + std::string module_revision = modem_->GetModuleRevision(); + std::string imei = modem_->GetImei(); + std::string iccid = modem_->GetIccid(); + ESP_LOGI(TAG, "ML307 Revision: %s", module_revision.c_str()); + ESP_LOGI(TAG, "ML307 IMEI: %s", imei.c_str()); + ESP_LOGI(TAG, "ML307 ICCID: %s", iccid.c_str()); +} + +void Ml307Board::StartNetwork() { + // Create network initialization task and return immediately + xTaskCreate([](void* arg) { + Ml307Board* board = static_cast(arg); + board->NetworkTask(); + vTaskDelete(NULL); + }, "ml307_net", 4096, this, 5, NULL); +} + +NetworkInterface* Ml307Board::GetNetwork() { + return modem_.get(); +} + +const char* Ml307Board::GetNetworkStateIcon() { + if (modem_ == nullptr || !modem_->network_ready()) { + return FONT_AWESOME_SIGNAL_OFF; + } + int csq = modem_->GetCsq(); + if (csq == -1) { + return FONT_AWESOME_SIGNAL_OFF; + } else if (csq >= 0 && csq <= 9) { + return FONT_AWESOME_SIGNAL_WEAK; + } else if (csq >= 10 && csq <= 14) { + return FONT_AWESOME_SIGNAL_FAIR; + } else if (csq >= 15 && csq <= 19) { + return FONT_AWESOME_SIGNAL_GOOD; + } else if (csq >= 20 && csq <= 31) { + return FONT_AWESOME_SIGNAL_STRONG; + } + + ESP_LOGW(TAG, "Invalid CSQ: %d", csq); + return FONT_AWESOME_SIGNAL_OFF; +} + +std::string Ml307Board::GetBoardJson() { + // Set the board type for OTA + std::string board_json = std::string("{\"type\":\"" BOARD_TYPE "\","); + board_json += "\"name\":\"" BOARD_NAME "\","; + board_json += "\"revision\":\"" + modem_->GetModuleRevision() + "\","; + board_json += "\"carrier\":\"" + modem_->GetCarrierName() + "\","; + board_json += "\"csq\":\"" + std::to_string(modem_->GetCsq()) + "\","; + board_json += "\"imei\":\"" + modem_->GetImei() + "\","; + board_json += "\"iccid\":\"" + modem_->GetIccid() + "\","; + board_json += "\"cereg\":" + modem_->GetRegistrationState().ToString() + "}"; + return board_json; +} + +void Ml307Board::SetPowerSaveLevel(PowerSaveLevel level) { + // TODO: Implement power save level for ML307 + (void)level; +} + +std::string Ml307Board::GetDeviceStatusJson() { + /* + * 返回设备状态JSON + * + * 返回的JSON结构如下: + * { + * "audio_speaker": { + * "volume": 70 + * }, + * "screen": { + * "brightness": 100, + * "theme": "light" + * }, + * "battery": { + * "level": 50, + * "charging": true + * }, + * "network": { + * "type": "cellular", + * "carrier": "CHINA MOBILE", + * "csq": 10 + * } + * } + */ + auto& board = Board::GetInstance(); + auto root = cJSON_CreateObject(); + + // Audio speaker + auto audio_speaker = cJSON_CreateObject(); + auto audio_codec = board.GetAudioCodec(); + if (audio_codec) { + cJSON_AddNumberToObject(audio_speaker, "volume", audio_codec->output_volume()); + } + cJSON_AddItemToObject(root, "audio_speaker", audio_speaker); + + // Screen brightness + auto backlight = board.GetBacklight(); + auto screen = cJSON_CreateObject(); + if (backlight) { + cJSON_AddNumberToObject(screen, "brightness", backlight->brightness()); + } + auto display = board.GetDisplay(); + if (display && display->height() > 64) { // For LCD display only + auto theme = display->GetTheme(); + if (theme != nullptr) { + cJSON_AddStringToObject(screen, "theme", theme->name().c_str()); + } + } + cJSON_AddItemToObject(root, "screen", screen); + + // Battery + int battery_level = 0; + bool charging = false; + bool discharging = false; + if (board.GetBatteryLevel(battery_level, charging, discharging)) { + cJSON* battery = cJSON_CreateObject(); + cJSON_AddNumberToObject(battery, "level", battery_level); + cJSON_AddBoolToObject(battery, "charging", charging); + cJSON_AddItemToObject(root, "battery", battery); + } + + // Network + auto network = cJSON_CreateObject(); + cJSON_AddStringToObject(network, "type", "cellular"); + cJSON_AddStringToObject(network, "carrier", modem_->GetCarrierName().c_str()); + int csq = modem_->GetCsq(); + if (csq == -1) { + cJSON_AddStringToObject(network, "signal", "unknown"); + } else if (csq >= 0 && csq <= 14) { + cJSON_AddStringToObject(network, "signal", "very weak"); + } else if (csq >= 15 && csq <= 19) { + cJSON_AddStringToObject(network, "signal", "weak"); + } else if (csq >= 20 && csq <= 24) { + cJSON_AddStringToObject(network, "signal", "medium"); + } else if (csq >= 25 && csq <= 31) { + cJSON_AddStringToObject(network, "signal", "strong"); + } + cJSON_AddItemToObject(root, "network", network); + + auto json_str = cJSON_PrintUnformatted(root); + std::string json(json_str); + cJSON_free(json_str); + cJSON_Delete(root); + return json; +} diff --git a/main/boards/common/ml307_board.h b/main/boards/common/ml307_board.h new file mode 100644 index 0000000..6e66991 --- /dev/null +++ b/main/boards/common/ml307_board.h @@ -0,0 +1,38 @@ +#ifndef ML307_BOARD_H +#define ML307_BOARD_H + +#include +#include +#include "board.h" + + +class Ml307Board : public Board { +protected: + std::unique_ptr modem_; + gpio_num_t tx_pin_; + gpio_num_t rx_pin_; + gpio_num_t dtr_pin_; + NetworkEventCallback network_event_callback_; + + virtual std::string GetBoardJson() override; + + // Internal helper to trigger network event callback + void OnNetworkEvent(NetworkEvent event, const std::string& data = ""); + + // Network initialization task (runs in FreeRTOS task) + static void NetworkTaskEntry(void* arg); + void NetworkTask(); + +public: + Ml307Board(gpio_num_t tx_pin, gpio_num_t rx_pin, gpio_num_t dtr_pin = GPIO_NUM_NC); + virtual std::string GetBoardType() override; + virtual void StartNetwork() override; + virtual void SetNetworkEventCallback(NetworkEventCallback callback) override; + virtual NetworkInterface* GetNetwork() override; + virtual const char* GetNetworkStateIcon() override; + virtual void SetPowerSaveLevel(PowerSaveLevel level) override; + virtual AudioCodec* GetAudioCodec() override { return nullptr; } + virtual std::string GetDeviceStatusJson() override; +}; + +#endif // ML307_BOARD_H diff --git a/main/boards/common/nt26_board.cc b/main/boards/common/nt26_board.cc new file mode 100644 index 0000000..8ca55e5 --- /dev/null +++ b/main/boards/common/nt26_board.cc @@ -0,0 +1,269 @@ +#include "nt26_board.h" +#include "display.h" +#include "application.h" +#include "audio_codec.h" +#include +#include +#include + +#define TAG "Nt26Board" + +Nt26Board::Nt26Board(gpio_num_t tx_pin, gpio_num_t rx_pin, gpio_num_t dtr_pin, gpio_num_t ri_pin, gpio_num_t reset_pin) + : tx_pin_(tx_pin), rx_pin_(rx_pin), dtr_pin_(dtr_pin), ri_pin_(ri_pin), reset_pin_(reset_pin) { + + gpio_install_isr_service(ESP_INTR_FLAG_IRAM); + esp_event_loop_create_default(); + esp_netif_init(); + + // Create PM lock handle + esp_pm_lock_create(ESP_PM_CPU_FREQ_MAX, 0, "nt26_cpu", &pm_lock_cpu_max_); + + // Create network ready timeout timer + esp_timer_create_args_t timer_args = { + .callback = OnNetworkReadyTimeout, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "nt26_net_timer", + .skip_unhandled_events = true + }; + esp_timer_create(&timer_args, &network_ready_timer_); +} + +Nt26Board::~Nt26Board() { + if (current_power_level_ != PowerSaveLevel::LOW_POWER) { + SetPowerSaveLevel(PowerSaveLevel::LOW_POWER); + } + + if (network_ready_timer_) { + esp_timer_stop(network_ready_timer_); + esp_timer_delete(network_ready_timer_); + } + + if (modem_) { + modem_->Stop(); + } + + if (pm_lock_cpu_max_) { + esp_pm_lock_delete(pm_lock_cpu_max_); + } +} + +std::string Nt26Board::GetBoardType() { + return "nt26"; +} + +void Nt26Board::OnNetworkEvent(NetworkEvent event, const std::string& data) { + if (network_event_callback_) { + network_event_callback_(event, data); + } +} + +void Nt26Board::OnNetworkReadyTimeout(void* arg) { + auto* self = static_cast(arg); + ESP_LOGW(TAG, "Network ready timeout"); + self->OnNetworkEvent(NetworkEvent::ModemErrorTimeout, "网络连接超时"); +} + +void Nt26Board::StartNetwork() { + OnNetworkEvent(NetworkEvent::ModemDetecting); + + UartEthModem::Config config = { + .uart_num = UART_NUM_1, + .baud_rate = 3000000, + .tx_pin = tx_pin_, + .rx_pin = rx_pin_, + .mrdy_pin = dtr_pin_, + .srdy_pin = ri_pin_ + }; + + modem_ = std::make_unique(config); + modem_->SetDebug(false); + + modem_->SetNetworkEventCallback([this](UartEthModem::UartEthModemEvent event) { + switch (event) { + case UartEthModem::UartEthModemEvent::Connected: + esp_timer_stop(network_ready_timer_); + OnNetworkEvent(NetworkEvent::Connected); + break; + case UartEthModem::UartEthModemEvent::Disconnected: + OnNetworkEvent(NetworkEvent::Disconnected); + break; + case UartEthModem::UartEthModemEvent::ErrorNoSim: + esp_timer_stop(network_ready_timer_); + ScheduleAsyncStop(); + OnNetworkEvent(NetworkEvent::ModemErrorNoSim); + break; + case UartEthModem::UartEthModemEvent::ErrorRegistrationDenied: + esp_timer_stop(network_ready_timer_); + ScheduleAsyncStop(); + OnNetworkEvent(NetworkEvent::ModemErrorRegDenied); + break; + case UartEthModem::UartEthModemEvent::Connecting: + OnNetworkEvent(NetworkEvent::Connecting); + break; + case UartEthModem::UartEthModemEvent::ErrorInitFailed: + case UartEthModem::UartEthModemEvent::ErrorNoCarrier: + esp_timer_stop(network_ready_timer_); + ScheduleAsyncStop(); + OnNetworkEvent(NetworkEvent::ModemErrorInitFailed); + break; + case UartEthModem::UartEthModemEvent::InFlightMode: + ESP_LOGW(TAG, "Modem in flight mode"); + break; + } + }); + + if (modem_->Start() != ESP_OK) { + OnNetworkEvent(NetworkEvent::ModemErrorInitFailed); + return; + } + + esp_timer_start_once(network_ready_timer_, 30000 * 1000ULL); + OnNetworkEvent(NetworkEvent::Connecting); +} + +void Nt26Board::ScheduleAsyncStop() { + Application::GetInstance().Schedule([this]() { + if (modem_) { + modem_->Stop(); + } + }); +} + +void Nt26Board::SetNetworkEventCallback(NetworkEventCallback callback) { + network_event_callback_ = std::move(callback); +} + +NetworkInterface* Nt26Board::GetNetwork() { + static EspNetwork network; + return &network; +} + +const char* Nt26Board::GetNetworkStateIcon() { + if (modem_ == nullptr || !modem_->IsInitialized()) { + return FONT_AWESOME_SIGNAL_OFF; + } + int csq = modem_->GetSignalStrength(); + if (csq == 99 || csq == -1) { + return FONT_AWESOME_SIGNAL_OFF; + } else if (csq >= 0 && csq <= 9) { + return FONT_AWESOME_SIGNAL_WEAK; + } else if (csq >= 10 && csq <= 14) { + return FONT_AWESOME_SIGNAL_FAIR; + } else if (csq >= 15 && csq <= 19) { + return FONT_AWESOME_SIGNAL_GOOD; + } else if (csq >= 20 && csq <= 31) { + return FONT_AWESOME_SIGNAL_STRONG; + } + return FONT_AWESOME_SIGNAL_OFF; +} + +void Nt26Board::SetPowerSaveLevel(PowerSaveLevel level) { + if (level == current_power_level_) return; + + if (current_power_level_ == PowerSaveLevel::BALANCED || + current_power_level_ == PowerSaveLevel::PERFORMANCE) { + if (pm_lock_cpu_max_) { + esp_pm_lock_release(pm_lock_cpu_max_); + } + } + + if (level == PowerSaveLevel::BALANCED || level == PowerSaveLevel::PERFORMANCE) { + if (pm_lock_cpu_max_) { + esp_pm_lock_acquire(pm_lock_cpu_max_); + } + } + + current_power_level_ = level; +} + +std::string Nt26Board::GetBoardJson() { + // Set the board type for OTA + std::string board_json = std::string("{\"type\":\"" BOARD_TYPE "\","); + board_json += "\"name\":\"" BOARD_NAME "\","; + if (modem_) { + board_json += "\"revision\":\"" + modem_->GetModuleRevision() + "\","; + board_json += "\"carrier\":\"" + modem_->GetCarrierName() + "\","; + board_json += "\"csq\":\"" + std::to_string(modem_->GetSignalStrength()) + "\","; + board_json += "\"imei\":\"" + modem_->GetImei() + "\","; + board_json += "\"iccid\":\"" + modem_->GetIccid() + "\","; + board_json += "\"cereg\":" + GetRegistrationState().ToString() + "}"; + } else { + board_json += "\"status\":\"offline\"}"; + } + return board_json; +} + +Nt26CeregState Nt26Board::GetRegistrationState() { + Nt26CeregState state; + if (modem_) { + auto cell_info = modem_->GetCellInfo(); + state.stat = cell_info.stat; + state.tac = cell_info.tac; + state.ci = cell_info.ci; + state.AcT = cell_info.act; + } + return state; +} + +std::string Nt26Board::GetDeviceStatusJson() { + auto& board = Board::GetInstance(); + auto root = cJSON_CreateObject(); + + // Audio speaker + auto audio_speaker = cJSON_CreateObject(); + auto audio_codec = board.GetAudioCodec(); + if (audio_codec) { + cJSON_AddNumberToObject(audio_speaker, "volume", audio_codec->output_volume()); + } + cJSON_AddItemToObject(root, "audio_speaker", audio_speaker); + + // Screen + auto backlight = board.GetBacklight(); + auto screen = cJSON_CreateObject(); + if (backlight) { + cJSON_AddNumberToObject(screen, "brightness", backlight->brightness()); + } + auto display = board.GetDisplay(); + if (display && display->height() > 64) { + auto theme = display->GetTheme(); + if (theme != nullptr) { + cJSON_AddStringToObject(screen, "theme", theme->name().c_str()); + } + } + cJSON_AddItemToObject(root, "screen", screen); + + // Battery + int battery_level = 0; + bool charging = false, discharging = false; + if (board.GetBatteryLevel(battery_level, charging, discharging)) { + auto battery = cJSON_CreateObject(); + cJSON_AddNumberToObject(battery, "level", battery_level); + cJSON_AddBoolToObject(battery, "charging", charging); + cJSON_AddItemToObject(root, "battery", battery); + } + + // Network + auto network = cJSON_CreateObject(); + cJSON_AddStringToObject(network, "type", "cellular"); + if (modem_) { + cJSON_AddStringToObject(network, "carrier", modem_->GetCarrierName().c_str()); + int csq = modem_->GetSignalStrength(); + if (csq == 99 || csq == -1) { + cJSON_AddStringToObject(network, "signal", "unknown"); + } else if (csq >= 0 && csq <= 14) { + cJSON_AddStringToObject(network, "signal", "weak"); + } else if (csq >= 15 && csq <= 24) { + cJSON_AddStringToObject(network, "signal", "medium"); + } else if (csq >= 25 && csq <= 31) { + cJSON_AddStringToObject(network, "signal", "strong"); + } + } + cJSON_AddItemToObject(root, "network", network); + + auto json_str = cJSON_PrintUnformatted(root); + std::string json(json_str); + cJSON_free(json_str); + cJSON_Delete(root); + return json; +} diff --git a/main/boards/common/nt26_board.h b/main/boards/common/nt26_board.h new file mode 100644 index 0000000..f897fe9 --- /dev/null +++ b/main/boards/common/nt26_board.h @@ -0,0 +1,62 @@ +#ifndef NT26_BOARD_H +#define NT26_BOARD_H + +#include +#include +#include +#include +#include +#include "board.h" + +struct Nt26CeregState { + int stat = 0; + std::string tac; + std::string ci; + int AcT = -1; + + std::string ToString() const { + std::string json = "{"; + json += "\"stat\":" + std::to_string(stat); + if (!tac.empty()) json += ",\"tac\":\"" + tac + "\""; + if (!ci.empty()) json += ",\"ci\":\"" + ci + "\""; + if (AcT >= 0) json += ",\"AcT\":" + std::to_string(AcT); + json += "}"; + return json; + } +}; + +class Nt26Board : public Board { +protected: + std::unique_ptr modem_; + gpio_num_t tx_pin_; + gpio_num_t rx_pin_; + gpio_num_t dtr_pin_; // mrdy_pin + gpio_num_t ri_pin_; // srdy_pin + gpio_num_t reset_pin_; + + NetworkEventCallback network_event_callback_; + esp_pm_lock_handle_t pm_lock_cpu_max_ = nullptr; + PowerSaveLevel current_power_level_ = PowerSaveLevel::LOW_POWER; + esp_timer_handle_t network_ready_timer_ = nullptr; + + virtual std::string GetBoardJson() override; + + void OnNetworkEvent(NetworkEvent event, const std::string& data = ""); + static void OnNetworkReadyTimeout(void* arg); + void ScheduleAsyncStop(); + +public: + Nt26Board(gpio_num_t tx_pin, gpio_num_t rx_pin, gpio_num_t dtr_pin, gpio_num_t ri_pin, gpio_num_t reset_pin = GPIO_NUM_NC); + virtual ~Nt26Board(); + virtual std::string GetBoardType() override; + virtual void StartNetwork() override; + virtual void SetNetworkEventCallback(NetworkEventCallback callback) override; + virtual NetworkInterface* GetNetwork() override; + virtual const char* GetNetworkStateIcon() override; + virtual void SetPowerSaveLevel(PowerSaveLevel level) override; + virtual AudioCodec* GetAudioCodec() override { return nullptr; } + virtual std::string GetDeviceStatusJson() override; + Nt26CeregState GetRegistrationState(); +}; + +#endif // NT26_BOARD_H diff --git a/main/boards/common/power_save_timer.cc b/main/boards/common/power_save_timer.cc new file mode 100644 index 0000000..522e64c --- /dev/null +++ b/main/boards/common/power_save_timer.cc @@ -0,0 +1,132 @@ +#include "power_save_timer.h" +#include "application.h" +#include "settings.h" + +#include + +#define TAG "PowerSaveTimer" + + +PowerSaveTimer::PowerSaveTimer(int cpu_max_freq, int seconds_to_sleep, int seconds_to_shutdown) + : cpu_max_freq_(cpu_max_freq), seconds_to_sleep_(seconds_to_sleep), seconds_to_shutdown_(seconds_to_shutdown) { + esp_timer_create_args_t timer_args = { + .callback = [](void* arg) { + auto self = static_cast(arg); + self->PowerSaveCheck(); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "power_save_timer", + .skip_unhandled_events = true, + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &power_save_timer_)); +} + +PowerSaveTimer::~PowerSaveTimer() { + esp_timer_stop(power_save_timer_); + esp_timer_delete(power_save_timer_); +} + +void PowerSaveTimer::SetEnabled(bool enabled) { + if (enabled && !enabled_) { + Settings settings("wifi", false); + if (!settings.GetBool("sleep_mode", true)) { + ESP_LOGI(TAG, "Power save timer is disabled by settings"); + return; + } + + ticks_ = 0; + enabled_ = enabled; + ESP_ERROR_CHECK(esp_timer_start_periodic(power_save_timer_, 1000000)); + ESP_LOGI(TAG, "Power save timer enabled"); + } else if (!enabled && enabled_) { + ESP_ERROR_CHECK(esp_timer_stop(power_save_timer_)); + enabled_ = enabled; + WakeUp(); + ESP_LOGI(TAG, "Power save timer disabled"); + } +} + +void PowerSaveTimer::OnEnterSleepMode(std::function callback) { + on_enter_sleep_mode_ = callback; +} + +void PowerSaveTimer::OnExitSleepMode(std::function callback) { + on_exit_sleep_mode_ = callback; +} + +void PowerSaveTimer::OnShutdownRequest(std::function callback) { + on_shutdown_request_ = callback; +} + +void PowerSaveTimer::PowerSaveCheck() { + auto& app = Application::GetInstance(); + if (!in_sleep_mode_ && !app.CanEnterSleepMode()) { + ticks_ = 0; + return; + } + + ticks_++; + if (seconds_to_sleep_ != -1 && ticks_ >= seconds_to_sleep_) { + if (!in_sleep_mode_) { + ESP_LOGI(TAG, "Enabling power save mode"); + in_sleep_mode_ = true; + if (on_enter_sleep_mode_) { + on_enter_sleep_mode_(); + } + + if (cpu_max_freq_ != -1) { + // Disable wake word detection + auto& audio_service = app.GetAudioService(); + is_wake_word_running_ = audio_service.IsWakeWordRunning(); + if (is_wake_word_running_) { + audio_service.EnableWakeWordDetection(false); + vTaskDelay(pdMS_TO_TICKS(100)); + } + // Disable audio input + auto codec = Board::GetInstance().GetAudioCodec(); + if (codec) { + codec->EnableInput(false); + } + + esp_pm_config_t pm_config = { + .max_freq_mhz = cpu_max_freq_, + .min_freq_mhz = 40, + .light_sleep_enable = true, + }; + esp_pm_configure(&pm_config); + } + } + } + if (seconds_to_shutdown_ != -1 && ticks_ >= seconds_to_shutdown_ && on_shutdown_request_) { + on_shutdown_request_(); + } +} + +void PowerSaveTimer::WakeUp() { + ticks_ = 0; + if (in_sleep_mode_) { + ESP_LOGI(TAG, "Exiting power save mode"); + in_sleep_mode_ = false; + + if (cpu_max_freq_ != -1) { + esp_pm_config_t pm_config = { + .max_freq_mhz = cpu_max_freq_, + .min_freq_mhz = cpu_max_freq_, + .light_sleep_enable = false, + }; + esp_pm_configure(&pm_config); + + // Enable wake word detection + auto& app = Application::GetInstance(); + auto& audio_service = app.GetAudioService(); + if (is_wake_word_running_) { + audio_service.EnableWakeWordDetection(true); + } + } + + if (on_exit_sleep_mode_) { + on_exit_sleep_mode_(); + } + } +} diff --git a/main/boards/common/power_save_timer.h b/main/boards/common/power_save_timer.h new file mode 100644 index 0000000..4c95671 --- /dev/null +++ b/main/boards/common/power_save_timer.h @@ -0,0 +1,34 @@ +#pragma once + +#include + +#include +#include + +class PowerSaveTimer { +public: + PowerSaveTimer(int cpu_max_freq, int seconds_to_sleep = 20, int seconds_to_shutdown = -1); + ~PowerSaveTimer(); + + void SetEnabled(bool enabled); + void OnEnterSleepMode(std::function callback); + void OnExitSleepMode(std::function callback); + void OnShutdownRequest(std::function callback); + void WakeUp(); + +private: + void PowerSaveCheck(); + + esp_timer_handle_t power_save_timer_ = nullptr; + bool enabled_ = false; + bool in_sleep_mode_ = false; + bool is_wake_word_running_ = false; + int ticks_ = 0; + int cpu_max_freq_; + int seconds_to_sleep_; + int seconds_to_shutdown_; + + std::function on_enter_sleep_mode_; + std::function on_exit_sleep_mode_; + std::function on_shutdown_request_; +}; diff --git a/main/boards/common/press_to_talk_mcp_tool.cc b/main/boards/common/press_to_talk_mcp_tool.cc new file mode 100644 index 0000000..d6b0bc9 --- /dev/null +++ b/main/boards/common/press_to_talk_mcp_tool.cc @@ -0,0 +1,57 @@ +#include "press_to_talk_mcp_tool.h" +#include + +static const char* TAG = "PressToTalkMcpTool"; + +PressToTalkMcpTool::PressToTalkMcpTool() + : press_to_talk_enabled_(false) { +} + +void PressToTalkMcpTool::Initialize() { + // 从设置中读取当前状态 + Settings settings("vendor"); + press_to_talk_enabled_ = settings.GetInt("press_to_talk", 0) != 0; + + // 注册MCP工具 + auto& mcp_server = McpServer::GetInstance(); + mcp_server.AddTool("self.set_press_to_talk", + "Switch between press to talk mode (长按说话) and click to talk mode (单击说话).\n" + "The mode can be `press_to_talk` or `click_to_talk`.", + PropertyList({ + Property("mode", kPropertyTypeString) + }), + [this](const PropertyList& properties) -> ReturnValue { + return HandleSetPressToTalk(properties); + }); + + ESP_LOGI(TAG, "PressToTalkMcpTool initialized, current mode: %s", + press_to_talk_enabled_ ? "press_to_talk" : "click_to_talk"); +} + +bool PressToTalkMcpTool::IsPressToTalkEnabled() const { + return press_to_talk_enabled_; +} + +ReturnValue PressToTalkMcpTool::HandleSetPressToTalk(const PropertyList& properties) { + auto mode = properties["mode"].value(); + + if (mode == "press_to_talk") { + SetPressToTalkEnabled(true); + ESP_LOGI(TAG, "Switched to press to talk mode"); + return true; + } else if (mode == "click_to_talk") { + SetPressToTalkEnabled(false); + ESP_LOGI(TAG, "Switched to click to talk mode"); + return true; + } + + throw std::runtime_error("Invalid mode: " + mode); +} + +void PressToTalkMcpTool::SetPressToTalkEnabled(bool enabled) { + press_to_talk_enabled_ = enabled; + + Settings settings("vendor", true); + settings.SetInt("press_to_talk", enabled ? 1 : 0); + ESP_LOGI(TAG, "Press to talk enabled: %d", enabled); +} \ No newline at end of file diff --git a/main/boards/common/press_to_talk_mcp_tool.h b/main/boards/common/press_to_talk_mcp_tool.h new file mode 100644 index 0000000..3231a28 --- /dev/null +++ b/main/boards/common/press_to_talk_mcp_tool.h @@ -0,0 +1,29 @@ +#ifndef PRESS_TO_TALK_MCP_TOOL_H +#define PRESS_TO_TALK_MCP_TOOL_H + +#include "mcp_server.h" +#include "settings.h" + +// 可复用的按键说话模式MCP工具类 +class PressToTalkMcpTool { +private: + bool press_to_talk_enabled_; + +public: + PressToTalkMcpTool(); + + // 初始化工具,注册到MCP服务器 + void Initialize(); + + // 获取当前按键说话模式状态 + bool IsPressToTalkEnabled() const; + +private: + // MCP工具的回调函数 + ReturnValue HandleSetPressToTalk(const PropertyList& properties); + + // 内部方法:设置press to talk状态并保存到设置 + void SetPressToTalkEnabled(bool enabled); +}; + +#endif // PRESS_TO_TALK_MCP_TOOL_H \ No newline at end of file diff --git a/main/boards/common/rndis_board.cc b/main/boards/common/rndis_board.cc new file mode 100644 index 0000000..c9c5219 --- /dev/null +++ b/main/boards/common/rndis_board.cc @@ -0,0 +1,247 @@ +#include "rndis_board.h" +#include "display.h" +#include "application.h" +#include "system_info.h" +#include "settings.h" +#include "assets/lang_config.h" + +#include +#include +#include +#include +#include +#include + +#if CONFIG_IDF_TARGET_ESP32P4 || CONFIG_IDF_TARGET_ESP32S3 + +static const char *TAG = "RndisBoard"; +#define EVENT_GOT_IP_BIT (1 << 0) + +RndisBoard::RndisBoard() { +} + +RndisBoard::~RndisBoard() { +} + +std::string RndisBoard::GetBoardType() { + return "rndis"; +} + +void RndisBoard::StartNetwork() { + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + /* NVS partition was truncated and needs to be erased + * Retry nvs_flash_init */ + ESP_ERROR_CHECK(nvs_flash_erase()); + ESP_ERROR_CHECK(nvs_flash_init()); + } + /* Initialize default TCP/IP stack */ + ESP_ERROR_CHECK(esp_netif_init()); + ESP_ERROR_CHECK(esp_event_loop_create_default()); + + s_event_group = xEventGroupCreate(); + esp_event_handler_register(IOT_ETH_EVENT, ESP_EVENT_ANY_ID, iot_event_handle, this); + esp_event_handler_register(IP_EVENT, IP_EVENT_ETH_GOT_IP, iot_event_handle, this); + + // install usbh cdc driver + usbh_cdc_driver_config_t config = { + .task_stack_size = 1024 * 4, + .task_priority = configMAX_PRIORITIES - 1, + .task_coreid = 0, + .skip_init_usb_host_driver = false, + }; + ESP_ERROR_CHECK(usbh_cdc_driver_install(&config)); + + install_rndis(USB_DEVICE_VENDOR_ANY, USB_DEVICE_PRODUCT_ANY, "USB RNDIS0"); + xEventGroupWaitBits(s_event_group, EVENT_GOT_IP_BIT, pdFALSE, pdFALSE, portMAX_DELAY); +} + + +void RndisBoard::iot_event_handle(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data) +{ + if (event_base == IOT_ETH_EVENT) { + switch (event_id) { + case IOT_ETH_EVENT_START: + ESP_LOGI(TAG, "IOT_ETH_EVENT_START"); + break; + case IOT_ETH_EVENT_STOP: + ESP_LOGI(TAG, "IOT_ETH_EVENT_STOP"); + break; + case IOT_ETH_EVENT_CONNECTED: + ESP_LOGI(TAG, "IOT_ETH_EVENT_CONNECTED"); + static_cast(arg)->OnNetworkEvent(NetworkEvent::Connected); + break; + case IOT_ETH_EVENT_DISCONNECTED: + ESP_LOGI(TAG, "IOT_ETH_EVENT_DISCONNECTED"); + xEventGroupClearBits(static_cast(arg)->s_event_group, EVENT_GOT_IP_BIT); + static_cast(arg)->OnNetworkEvent(NetworkEvent::Disconnected); + break; + default: + ESP_LOGI(TAG, "IOT_ETH_EVENT_UNKNOWN"); + break; + } + } else if (event_base == IP_EVENT) { + ESP_LOGI(TAG, "GOT_IP"); + xEventGroupSetBits(static_cast(arg)->s_event_group, EVENT_GOT_IP_BIT); + } +} + + +void RndisBoard::OnNetworkEvent(NetworkEvent event, const std::string& data) { + switch (event) { + case NetworkEvent::Connected: + ESP_LOGI(TAG, "Connected to WiFi: %s", data.c_str()); + break; + case NetworkEvent::Scanning: + ESP_LOGI(TAG, "WiFi scanning"); + break; + case NetworkEvent::Connecting: + ESP_LOGI(TAG, "WiFi connecting to %s", data.c_str()); + break; + case NetworkEvent::Disconnected: + ESP_LOGW(TAG, "WiFi disconnected"); + break; + default: + break; + } + + // Notify external callback if set + if (network_event_callback_) { + network_event_callback_(event, data); + } +} + +void RndisBoard::SetNetworkEventCallback(NetworkEventCallback callback) { + network_event_callback_ = std::move(callback); +} + +void RndisBoard::install_rndis(uint16_t idVendor, uint16_t idProduct, const char *netif_name) +{ + esp_err_t ret = ESP_OK; + iot_eth_handle_t eth_handle = nullptr; + iot_eth_netif_glue_handle_t glue = nullptr; + + usb_device_match_id_t *dev_match_id = (usb_device_match_id_t*)calloc(2, sizeof(usb_device_match_id_t)); + dev_match_id[0].match_flags = USB_DEVICE_ID_MATCH_VID_PID; + dev_match_id[0].idVendor = idVendor; + dev_match_id[0].idProduct = idProduct; + memset(&dev_match_id[1], 0, sizeof(usb_device_match_id_t)); // end of list + iot_usbh_rndis_config_t rndis_cfg = { + .match_id_list = dev_match_id, + }; + + ret = iot_eth_new_usb_rndis(&rndis_cfg, &rndis_eth_driver); + if (ret != ESP_OK || rndis_eth_driver == NULL) { + ESP_LOGE(TAG, "Failed to create USB RNDIS driver"); + return; + } + + iot_eth_config_t eth_cfg = { + .driver = rndis_eth_driver, + .stack_input = NULL, + }; + ret = iot_eth_install(ð_cfg, ð_handle); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to install USB RNDIS driver"); + return; + } + + esp_netif_inherent_config_t _inherent_eth_config = ESP_NETIF_INHERENT_DEFAULT_ETH(); + _inherent_eth_config.if_key = netif_name; + _inherent_eth_config.if_desc = netif_name; + esp_netif_config_t netif_cfg = { + .base = &_inherent_eth_config, + .driver = NULL, + .stack = ESP_NETIF_NETSTACK_DEFAULT_ETH, + }; + s_rndis_netif = esp_netif_new(&netif_cfg); + if (s_rndis_netif == NULL) { + ESP_LOGE(TAG, "Failed to create network interface"); + return; + } + + glue = iot_eth_new_netif_glue(eth_handle); + if (glue == NULL) { + ESP_LOGE(TAG, "Failed to create netif glue"); + return; + } + esp_netif_attach(s_rndis_netif, glue); + iot_eth_start(eth_handle); +} + + +NetworkInterface* RndisBoard::GetNetwork() { + static EspNetwork network; + return &network; +} + +const char* RndisBoard::GetNetworkStateIcon() { + return FONT_AWESOME_SIGNAL_STRONG; +} + +std::string RndisBoard::GetBoardJson() { + + std::string json = R"({"type":")" + std::string(BOARD_TYPE) + R"(",)"; + json += R"("name":")" + std::string(BOARD_NAME) + R"(",)"; + + json += R"("mac":")" + SystemInfo::GetMacAddress() + R"("})"; + return json; +} + +void RndisBoard::SetPowerSaveLevel(PowerSaveLevel level) { + +} + +std::string RndisBoard::GetDeviceStatusJson() { + auto& board = Board::GetInstance(); + auto root = cJSON_CreateObject(); + + // Audio speaker + auto audio_speaker = cJSON_CreateObject(); + if (auto codec = board.GetAudioCodec()) { + cJSON_AddNumberToObject(audio_speaker, "volume", codec->output_volume()); + } + cJSON_AddItemToObject(root, "audio_speaker", audio_speaker); + + // Screen + auto screen = cJSON_CreateObject(); + if (auto backlight = board.GetBacklight()) { + cJSON_AddNumberToObject(screen, "brightness", backlight->brightness()); + } + if (auto display = board.GetDisplay(); display && display->height() > 64) { + if (auto theme = display->GetTheme()) { + cJSON_AddStringToObject(screen, "theme", theme->name().c_str()); + } + } + cJSON_AddItemToObject(root, "screen", screen); + + // Battery + int level = 0; + bool charging = false, discharging = false; + if (board.GetBatteryLevel(level, charging, discharging)) { + auto battery = cJSON_CreateObject(); + cJSON_AddNumberToObject(battery, "level", level); + cJSON_AddBoolToObject(battery, "charging", charging); + cJSON_AddItemToObject(root, "battery", battery); + } + + // Network + auto network = cJSON_CreateObject(); + cJSON_AddStringToObject(network, "type", "rndis"); + cJSON_AddItemToObject(root, "network", network); + + // Chip temperature + float temp = 0.0f; + if (board.GetTemperature(temp)) { + auto chip = cJSON_CreateObject(); + cJSON_AddNumberToObject(chip, "temperature", temp); + cJSON_AddItemToObject(root, "chip", chip); + } + + auto str = cJSON_PrintUnformatted(root); + std::string result(str); + cJSON_free(str); + cJSON_Delete(root); + return result; +} +#endif // CONFIG_IDF_TARGET_ESP32P4 || CONFIG_IDF_TARGET_ESP32S3 \ No newline at end of file diff --git a/main/boards/common/rndis_board.h b/main/boards/common/rndis_board.h new file mode 100644 index 0000000..7e9f752 --- /dev/null +++ b/main/boards/common/rndis_board.h @@ -0,0 +1,74 @@ +#ifndef RNDIS_BOARD_H +#define RNDIS_BOARD_H + +#include "sdkconfig.h" + +#if CONFIG_IDF_TARGET_ESP32P4 || CONFIG_IDF_TARGET_ESP32S3 +#include "board.h" +#include "iot_eth.h" +#include "iot_usbh_rndis.h" +#include "iot_eth_netif_glue.h" +#include +#include +#include +#include +#include + +class RndisBoard : public Board { +private: + EventGroupHandle_t s_event_group = nullptr; + iot_eth_driver_t *rndis_eth_driver = nullptr; + esp_netif_t *s_rndis_netif = nullptr; + + void install_rndis(uint16_t idVendor, uint16_t idProduct, const char *netif_name); + static void iot_event_handle(void *arg, esp_event_base_t event_base, int32_t event_id, void *event_data); +protected: + NetworkEventCallback network_event_callback_ = nullptr; + + virtual std::string GetBoardJson() override; + + /** + * Handle network event (called from WiFi manager callbacks) + * @param event The network event type + * @param data Additional data (e.g., SSID for Connecting/Connected events) + */ + void OnNetworkEvent(NetworkEvent event, const std::string& data = ""); + + /** + * Start WiFi connection attempt + */ + void TryWifiConnect(); + + /** + * Enter WiFi configuration mode + */ + void StartWifiConfigMode(); + + /** + * WiFi connection timeout callback + */ + static void OnWifiConnectTimeout(void* arg); + +public: + RndisBoard(); + virtual ~RndisBoard(); + + virtual std::string GetBoardType() override; + + /** + * Start network connection asynchronously + * This function returns immediately. Network events are notified through the callback set by SetNetworkEventCallback(). + */ + virtual void StartNetwork() override; + + virtual NetworkInterface* GetNetwork() override; + virtual void SetNetworkEventCallback(NetworkEventCallback callback) override; + virtual const char* GetNetworkStateIcon() override; + virtual void SetPowerSaveLevel(PowerSaveLevel level) override; + virtual AudioCodec* GetAudioCodec() override { return nullptr; } + virtual std::string GetDeviceStatusJson() override; + +}; +#endif // CONFIG_IDF_TARGET_ESP32P4 || CONFIG_IDF_TARGET_ESP32S3 + +#endif // RNDIS_BOARD_H diff --git a/main/boards/common/sleep_timer.cc b/main/boards/common/sleep_timer.cc new file mode 100644 index 0000000..3490f6c --- /dev/null +++ b/main/boards/common/sleep_timer.cc @@ -0,0 +1,133 @@ +#include "sleep_timer.h" +#include "application.h" +#include "board.h" +#include "display.h" +#include "settings.h" + +#include +#include +#include + +#define TAG "SleepTimer" + + +SleepTimer::SleepTimer(int seconds_to_light_sleep, int seconds_to_deep_sleep) + : seconds_to_light_sleep_(seconds_to_light_sleep), seconds_to_deep_sleep_(seconds_to_deep_sleep) { + esp_timer_create_args_t timer_args = { + .callback = [](void* arg) { + auto self = static_cast(arg); + self->CheckTimer(); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "sleep_timer", + .skip_unhandled_events = true, + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &sleep_timer_)); +} + +SleepTimer::~SleepTimer() { + esp_timer_stop(sleep_timer_); + esp_timer_delete(sleep_timer_); +} + +void SleepTimer::SetEnabled(bool enabled) { + if (enabled && !enabled_) { + Settings settings("wifi", false); + if (!settings.GetBool("sleep_mode", true)) { + ESP_LOGI(TAG, "Power save timer is disabled by settings"); + return; + } + + ticks_ = 0; + enabled_ = enabled; + ESP_ERROR_CHECK(esp_timer_start_periodic(sleep_timer_, 1000000)); + ESP_LOGI(TAG, "Sleep timer enabled"); + } else if (!enabled && enabled_) { + ESP_ERROR_CHECK(esp_timer_stop(sleep_timer_)); + enabled_ = enabled; + WakeUp(); + ESP_LOGI(TAG, "Sleep timer disabled"); + } +} + +void SleepTimer::OnEnterLightSleepMode(std::function callback) { + on_enter_light_sleep_mode_ = callback; +} + +void SleepTimer::OnExitLightSleepMode(std::function callback) { + on_exit_light_sleep_mode_ = callback; +} + +void SleepTimer::OnEnterDeepSleepMode(std::function callback) { + on_enter_deep_sleep_mode_ = callback; +} + +void SleepTimer::CheckTimer() { + auto& app = Application::GetInstance(); + if (!app.CanEnterSleepMode()) { + ticks_ = 0; + return; + } + + ticks_++; + if (seconds_to_light_sleep_ != -1 && ticks_ >= seconds_to_light_sleep_) { + if (!in_light_sleep_mode_) { + in_light_sleep_mode_ = true; + if (on_enter_light_sleep_mode_) { + on_enter_light_sleep_mode_(); + } + + auto& audio_service = app.GetAudioService(); + bool is_wake_word_running = audio_service.IsWakeWordRunning(); + if (is_wake_word_running) { + audio_service.EnableWakeWordDetection(false); + vTaskDelay(pdMS_TO_TICKS(100)); + } + + app.Schedule([this, &app]() { + while (in_light_sleep_mode_) { + auto& board = Board::GetInstance(); + board.GetDisplay()->UpdateStatusBar(true); + lv_refr_now(nullptr); + lvgl_port_stop(); + + // 配置timer唤醒源(30秒后自动唤醒) + esp_sleep_enable_timer_wakeup(30 * 1000000); + + // 进入light sleep模式 + esp_light_sleep_start(); + lvgl_port_resume(); + + auto wakeup_reason = esp_sleep_get_wakeup_cause(); + ESP_LOGI(TAG, "Wake up from light sleep, wakeup_reason: %d", wakeup_reason); + if (wakeup_reason != ESP_SLEEP_WAKEUP_TIMER) { + break; + } + } + WakeUp(); + }); + + if (is_wake_word_running) { + audio_service.EnableWakeWordDetection(true); + } + } + } + if (seconds_to_deep_sleep_ != -1 && ticks_ >= seconds_to_deep_sleep_) { + if (on_enter_deep_sleep_mode_) { + on_enter_deep_sleep_mode_(); + } + + esp_deep_sleep_start(); + } +} + +void SleepTimer::WakeUp() { + ticks_ = 0; + if (in_light_sleep_mode_) { + in_light_sleep_mode_ = false; + if (on_exit_light_sleep_mode_) { + on_exit_light_sleep_mode_(); + } + } +} diff --git a/main/boards/common/sleep_timer.h b/main/boards/common/sleep_timer.h new file mode 100644 index 0000000..159e220 --- /dev/null +++ b/main/boards/common/sleep_timer.h @@ -0,0 +1,32 @@ +#pragma once + +#include + +#include +#include + +class SleepTimer { +public: + SleepTimer(int seconds_to_light_sleep = 20, int seconds_to_deep_sleep = -1); + ~SleepTimer(); + + void SetEnabled(bool enabled); + void OnEnterLightSleepMode(std::function callback); + void OnExitLightSleepMode(std::function callback); + void OnEnterDeepSleepMode(std::function callback); + void WakeUp(); + +private: + void CheckTimer(); + + esp_timer_handle_t sleep_timer_ = nullptr; + bool enabled_ = false; + int ticks_ = 0; + int seconds_to_light_sleep_; + int seconds_to_deep_sleep_; + bool in_light_sleep_mode_ = false; + + std::function on_enter_light_sleep_mode_; + std::function on_exit_light_sleep_mode_; + std::function on_enter_deep_sleep_mode_; +}; diff --git a/main/boards/common/sy6970.cc b/main/boards/common/sy6970.cc new file mode 100644 index 0000000..8a45d75 --- /dev/null +++ b/main/boards/common/sy6970.cc @@ -0,0 +1,65 @@ +#include "sy6970.h" +#include "board.h" +#include "display.h" + +#include + +#define TAG "Sy6970" + +Sy6970::Sy6970(i2c_master_bus_handle_t i2c_bus, uint8_t addr) : I2cDevice(i2c_bus, addr) { +} + +int Sy6970::GetChangingStatus() { + return (ReadReg(0x0B) >> 3) & 0x03; +} + +bool Sy6970::IsCharging() { + return GetChangingStatus() != 0; +} + +bool Sy6970::IsPowerGood() { + return (ReadReg(0x0B) & 0x04) != 0; +} + +bool Sy6970::IsChargingDone() { + return GetChangingStatus() == 3; +} + +int Sy6970::GetBatteryVoltage() { + uint8_t value = ReadReg(0x0E); + value &= 0x7F; + if (value == 0) { + return 0; + } + return value * 20 + 2304; +} + +int Sy6970::GetChargeTargetVoltage() { + uint8_t value = ReadReg(0x06); + value = (value & 0xFC) >> 2; + if (value > 0x30) { + return 4608; + } + return value * 16 + 3840; +} + +int Sy6970::GetBatteryLevel() { + int level = 0; + // 电池所能掉电的最低电压 + int battery_minimum_voltage = 3200; + int battery_voltage = GetBatteryVoltage(); + int charge_voltage_limit = GetChargeTargetVoltage(); + // ESP_LOGI(TAG, "battery_voltage: %d, charge_voltage_limit: %d", battery_voltage, charge_voltage_limit); + if (battery_voltage > battery_minimum_voltage && charge_voltage_limit > battery_minimum_voltage) { + level = (((float) battery_voltage - (float) battery_minimum_voltage) / ((float) charge_voltage_limit - (float) battery_minimum_voltage)) * 100.0; + } + // 不连接电池时读取的充电状态不稳定且battery_voltage有时会超过charge_voltage_limit + if (level > 100) { + level = 100; + } + return level; +} + +void Sy6970::PowerOff() { + WriteReg(0x09, 0B01100100); +} diff --git a/main/boards/common/sy6970.h b/main/boards/common/sy6970.h new file mode 100644 index 0000000..a2eaaca --- /dev/null +++ b/main/boards/common/sy6970.h @@ -0,0 +1,21 @@ +#ifndef __SY6970_H__ +#define __SY6970_H__ + +#include "i2c_device.h" + +class Sy6970 : public I2cDevice { +public: + Sy6970(i2c_master_bus_handle_t i2c_bus, uint8_t addr); + bool IsCharging(); + bool IsPowerGood(); + bool IsChargingDone(); + int GetBatteryLevel(); + void PowerOff(); + +private: + int GetChangingStatus(); + int GetBatteryVoltage(); + int GetChargeTargetVoltage(); +}; + +#endif \ No newline at end of file diff --git a/main/boards/common/system_reset.cc b/main/boards/common/system_reset.cc new file mode 100644 index 0000000..f51249b --- /dev/null +++ b/main/boards/common/system_reset.cc @@ -0,0 +1,72 @@ +#include "system_reset.h" + +#include +#include +#include +#include +#include +#include + + +#define TAG "SystemReset" + + +SystemReset::SystemReset(gpio_num_t reset_nvs_pin, gpio_num_t reset_factory_pin) : reset_nvs_pin_(reset_nvs_pin), reset_factory_pin_(reset_factory_pin) { + // Configure GPIO1, GPIO2 as INPUT, reset NVS flash if the button is pressed + gpio_config_t io_conf; + io_conf.intr_type = GPIO_INTR_DISABLE; + io_conf.mode = GPIO_MODE_INPUT; + io_conf.pin_bit_mask = (1ULL << reset_nvs_pin_) | (1ULL << reset_factory_pin_); + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + io_conf.pull_up_en = GPIO_PULLUP_ENABLE; + gpio_config(&io_conf); +} + + +void SystemReset::CheckButtons() { + if (gpio_get_level(reset_factory_pin_) == 0) { + ESP_LOGI(TAG, "Button is pressed, reset to factory"); + ResetNvsFlash(); + ResetToFactory(); + } + + if (gpio_get_level(reset_nvs_pin_) == 0) { + ESP_LOGI(TAG, "Button is pressed, reset NVS flash"); + ResetNvsFlash(); + } +} + +void SystemReset::ResetNvsFlash() { + ESP_LOGI(TAG, "Resetting NVS flash"); + esp_err_t ret = nvs_flash_erase(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to erase NVS flash"); + } + ret = nvs_flash_init(); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to initialize NVS flash"); + } +} + +void SystemReset::ResetToFactory() { + ESP_LOGI(TAG, "Resetting to factory"); + // Erase otadata partition + const esp_partition_t* partition = esp_partition_find_first(ESP_PARTITION_TYPE_DATA, ESP_PARTITION_SUBTYPE_DATA_OTA, NULL); + if (partition == NULL) { + ESP_LOGE(TAG, "Failed to find otadata partition"); + return; + } + esp_partition_erase_range(partition, 0, partition->size); + ESP_LOGI(TAG, "Erased otadata partition"); + + // Reboot in 3 seconds + RestartInSeconds(3); +} + +void SystemReset::RestartInSeconds(int seconds) { + for (int i = seconds; i > 0; i--) { + ESP_LOGI(TAG, "Resetting in %d seconds", i); + vTaskDelay(1000 / portTICK_PERIOD_MS); + } + esp_restart(); +} diff --git a/main/boards/common/system_reset.h b/main/boards/common/system_reset.h new file mode 100644 index 0000000..7e78296 --- /dev/null +++ b/main/boards/common/system_reset.h @@ -0,0 +1,21 @@ +#ifndef _SYSTEM_RESET_H +#define _SYSTEM_RESET_H + +#include + +class SystemReset { +public: + SystemReset(gpio_num_t reset_nvs_pin, gpio_num_t reset_factory_pin); // 构造函数私有化 + void CheckButtons(); + +private: + gpio_num_t reset_nvs_pin_; + gpio_num_t reset_factory_pin_; + + void ResetNvsFlash(); + void ResetToFactory(); + void RestartInSeconds(int seconds); +}; + + +#endif diff --git a/main/boards/common/wifi_board.cc b/main/boards/common/wifi_board.cc new file mode 100644 index 0000000..377685b --- /dev/null +++ b/main/boards/common/wifi_board.cc @@ -0,0 +1,357 @@ +#include "wifi_board.h" + +#include "display.h" +#include "application.h" +#include "system_info.h" +#include "settings.h" +#include "assets/lang_config.h" + +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include "afsk_demod.h" +#ifdef CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING +#include "blufi.h" +#endif + +static const char *TAG = "WifiBoard"; + +// Connection timeout in seconds +static constexpr int CONNECT_TIMEOUT_SEC = 60; + +WifiBoard::WifiBoard() { + // Create connection timeout timer + esp_timer_create_args_t timer_args = { + .callback = OnWifiConnectTimeout, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "wifi_connect_timer", + .skip_unhandled_events = true + }; + esp_timer_create(&timer_args, &connect_timer_); +} + +WifiBoard::~WifiBoard() { + if (connect_timer_) { + esp_timer_stop(connect_timer_); + esp_timer_delete(connect_timer_); + } +} + +std::string WifiBoard::GetBoardType() { + return "wifi"; +} + +void WifiBoard::StartNetwork() { + auto& wifi_manager = WifiManager::GetInstance(); + + // Initialize WiFi manager + WifiManagerConfig config; + config.ssid_prefix = "Xiaozhi"; + config.language = Lang::CODE; + wifi_manager.Initialize(config); + + // Set unified event callback - forward to NetworkEvent with SSID data + wifi_manager.SetEventCallback([this](WifiEvent event, const std::string& data) { + switch (event) { + case WifiEvent::Scanning: + OnNetworkEvent(NetworkEvent::Scanning); + break; + case WifiEvent::Connecting: + OnNetworkEvent(NetworkEvent::Connecting, data); + break; + case WifiEvent::Connected: + OnNetworkEvent(NetworkEvent::Connected, data); + break; + case WifiEvent::Disconnected: + OnNetworkEvent(NetworkEvent::Disconnected); + break; + case WifiEvent::ConfigModeEnter: + OnNetworkEvent(NetworkEvent::WifiConfigModeEnter); + break; + case WifiEvent::ConfigModeExit: + OnNetworkEvent(NetworkEvent::WifiConfigModeExit); + break; + } + }); + + // Try to connect or enter config mode + TryWifiConnect(); +} + +void WifiBoard::TryWifiConnect() { + auto& ssid_manager = SsidManager::GetInstance(); + bool have_ssid = !ssid_manager.GetSsidList().empty(); + + if (have_ssid) { + // Start connection attempt with timeout + ESP_LOGI(TAG, "Starting WiFi connection attempt"); + esp_timer_start_once(connect_timer_, CONNECT_TIMEOUT_SEC * 1000000ULL); + WifiManager::GetInstance().StartStation(); + } else { + // No SSID configured, enter config mode + // Wait for the board version to be shown + vTaskDelay(pdMS_TO_TICKS(1500)); + StartWifiConfigMode(); + } +} + +void WifiBoard::OnNetworkEvent(NetworkEvent event, const std::string& data) { + switch (event) { + case NetworkEvent::Connected: + // Stop timeout timer + esp_timer_stop(connect_timer_); +#ifdef CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING + // make sure blufi resources has been released + Blufi::GetInstance().deinit(); +#endif + in_config_mode_ = false; + ESP_LOGI(TAG, "Connected to WiFi: %s", data.c_str()); + break; + case NetworkEvent::Scanning: + ESP_LOGI(TAG, "WiFi scanning"); + break; + case NetworkEvent::Connecting: + ESP_LOGI(TAG, "WiFi connecting to %s", data.c_str()); + break; + case NetworkEvent::Disconnected: + ESP_LOGW(TAG, "WiFi disconnected"); + break; + case NetworkEvent::WifiConfigModeEnter: + ESP_LOGI(TAG, "WiFi config mode entered"); + in_config_mode_ = true; + break; + case NetworkEvent::WifiConfigModeExit: + ESP_LOGI(TAG, "WiFi config mode exited"); + in_config_mode_ = false; + // Try to connect with the new credentials + TryWifiConnect(); + break; + default: + break; + } + + // Notify external callback if set + if (network_event_callback_) { + network_event_callback_(event, data); + } +} + +void WifiBoard::SetNetworkEventCallback(NetworkEventCallback callback) { + network_event_callback_ = std::move(callback); +} + +void WifiBoard::OnWifiConnectTimeout(void* arg) { + auto* board = static_cast(arg); + ESP_LOGW(TAG, "WiFi connection timeout, entering config mode"); + + WifiManager::GetInstance().StopStation(); + board->StartWifiConfigMode(); +} + +void WifiBoard::StartWifiConfigMode() { + in_config_mode_ = true; + // Transition to wifi configuring state + Application::GetInstance().SetDeviceState(kDeviceStateWifiConfiguring); +#ifdef CONFIG_USE_HOTSPOT_WIFI_PROVISIONING + auto& wifi_manager = WifiManager::GetInstance(); + + wifi_manager.StartConfigAp(); + + // Show config prompt after a short delay + Application::GetInstance().Schedule([&wifi_manager]() { + std::string hint = Lang::Strings::CONNECT_TO_HOTSPOT; + hint += wifi_manager.GetApSsid(); + hint += Lang::Strings::ACCESS_VIA_BROWSER; + hint += wifi_manager.GetApWebUrl(); + + Application::GetInstance().Alert(Lang::Strings::WIFI_CONFIG_MODE, hint.c_str(), "gear", Lang::Sounds::OGG_WIFICONFIG); + }); +#elif CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING + auto &blufi = Blufi::GetInstance(); + // initialize esp-blufi protocol + blufi.init(); +#endif +#if CONFIG_USE_ACOUSTIC_WIFI_PROVISIONING + // Start acoustic provisioning task + auto codec = Board::GetInstance().GetAudioCodec(); + int channel = codec ? codec->input_channels() : 1; + ESP_LOGI(TAG, "Starting acoustic WiFi provisioning, channels: %d", channel); + + xTaskCreate([](void* arg) { + auto ch = reinterpret_cast(arg); + auto& app = Application::GetInstance(); + auto& wifi = WifiManager::GetInstance(); + auto disp = Board::GetInstance().GetDisplay(); + audio_wifi_config::ReceiveWifiCredentialsFromAudio(&app, &wifi, disp, ch); + vTaskDelete(NULL); + }, "acoustic_wifi", 4096, reinterpret_cast(channel), 2, NULL); +#endif +} + +void WifiBoard::EnterWifiConfigMode() { + ESP_LOGI(TAG, "EnterWifiConfigMode called"); + GetDisplay()->ShowNotification(Lang::Strings::ENTERING_WIFI_CONFIG_MODE); + + auto& app = Application::GetInstance(); + auto state = app.GetDeviceState(); + + if (state == kDeviceStateSpeaking || state == kDeviceStateListening || state == kDeviceStateIdle) { + // Reset protocol (close audio channel, reset protocol) + Application::GetInstance().ResetProtocol(); + + xTaskCreate([](void* arg) { + auto* board = static_cast(arg); + + // Wait for 1 second to allow speaking to finish gracefully + vTaskDelay(pdMS_TO_TICKS(1000)); + + // Stop any ongoing connection attempt + esp_timer_stop(board->connect_timer_); + WifiManager::GetInstance().StopStation(); + + // Enter config mode + board->StartWifiConfigMode(); + + vTaskDelete(NULL); + }, "wifi_cfg_delay", 4096, this, 2, NULL); + return; + } + + if (state != kDeviceStateStarting) { + ESP_LOGE(TAG, "EnterWifiConfigMode called but device state is not starting or speaking, device state: %d", state); + return; + } + + // Stop any ongoing connection attempt + esp_timer_stop(connect_timer_); + WifiManager::GetInstance().StopStation(); + + StartWifiConfigMode(); +} + +bool WifiBoard::IsInWifiConfigMode() const { + return WifiManager::GetInstance().IsConfigMode(); +} + +NetworkInterface* WifiBoard::GetNetwork() { + static EspNetwork network; + return &network; +} + +const char* WifiBoard::GetNetworkStateIcon() { + auto& wifi = WifiManager::GetInstance(); + + if (wifi.IsConfigMode()) { + return FONT_AWESOME_WIFI; + } + if (!wifi.IsConnected()) { + return FONT_AWESOME_WIFI_SLASH; + } + + int rssi = wifi.GetRssi(); + if (rssi >= -65) { + return FONT_AWESOME_WIFI; + } else if (rssi >= -75) { + return FONT_AWESOME_WIFI_FAIR; + } + return FONT_AWESOME_WIFI_WEAK; +} + +std::string WifiBoard::GetBoardJson() { + auto& wifi = WifiManager::GetInstance(); + std::string json = R"({"type":")" + std::string(BOARD_TYPE) + R"(",)"; + json += R"("name":")" + std::string(BOARD_NAME) + R"(",)"; + + if (!wifi.IsConfigMode()) { + json += R"("ssid":")" + wifi.GetSsid() + R"(",)"; + json += R"("rssi":)" + std::to_string(wifi.GetRssi()) + R"(,)"; + json += R"("channel":)" + std::to_string(wifi.GetChannel()) + R"(,)"; + json += R"("ip":")" + wifi.GetIpAddress() + R"(",)"; + } + + json += R"("mac":")" + SystemInfo::GetMacAddress() + R"("})"; + return json; +} + +void WifiBoard::SetPowerSaveLevel(PowerSaveLevel level) { + WifiPowerSaveLevel wifi_level; + switch (level) { + case PowerSaveLevel::LOW_POWER: + wifi_level = WifiPowerSaveLevel::LOW_POWER; + break; + case PowerSaveLevel::BALANCED: + wifi_level = WifiPowerSaveLevel::BALANCED; + break; + case PowerSaveLevel::PERFORMANCE: + default: + wifi_level = WifiPowerSaveLevel::PERFORMANCE; + break; + } + WifiManager::GetInstance().SetPowerSaveLevel(wifi_level); +} + +std::string WifiBoard::GetDeviceStatusJson() { + auto& board = Board::GetInstance(); + auto root = cJSON_CreateObject(); + + // Audio speaker + auto audio_speaker = cJSON_CreateObject(); + if (auto codec = board.GetAudioCodec()) { + cJSON_AddNumberToObject(audio_speaker, "volume", codec->output_volume()); + } + cJSON_AddItemToObject(root, "audio_speaker", audio_speaker); + + // Screen + auto screen = cJSON_CreateObject(); + if (auto backlight = board.GetBacklight()) { + cJSON_AddNumberToObject(screen, "brightness", backlight->brightness()); + } + if (auto display = board.GetDisplay(); display && display->height() > 64) { + if (auto theme = display->GetTheme()) { + cJSON_AddStringToObject(screen, "theme", theme->name().c_str()); + } + } + cJSON_AddItemToObject(root, "screen", screen); + + // Battery + int level = 0; + bool charging = false, discharging = false; + if (board.GetBatteryLevel(level, charging, discharging)) { + auto battery = cJSON_CreateObject(); + cJSON_AddNumberToObject(battery, "level", level); + cJSON_AddBoolToObject(battery, "charging", charging); + cJSON_AddItemToObject(root, "battery", battery); + } + + // Network + auto& wifi = WifiManager::GetInstance(); + auto network = cJSON_CreateObject(); + cJSON_AddStringToObject(network, "type", "wifi"); + cJSON_AddStringToObject(network, "ssid", wifi.GetSsid().c_str()); + int rssi = wifi.GetRssi(); + const char* signal = rssi >= -60 ? "strong" : (rssi >= -70 ? "medium" : "weak"); + cJSON_AddStringToObject(network, "signal", signal); + cJSON_AddItemToObject(root, "network", network); + + // Chip temperature + float temp = 0.0f; + if (board.GetTemperature(temp)) { + auto chip = cJSON_CreateObject(); + cJSON_AddNumberToObject(chip, "temperature", temp); + cJSON_AddItemToObject(root, "chip", chip); + } + + auto str = cJSON_PrintUnformatted(root); + std::string result(str); + cJSON_free(str); + cJSON_Delete(root); + return result; +} diff --git a/main/boards/common/wifi_board.h b/main/boards/common/wifi_board.h new file mode 100644 index 0000000..7ee973c --- /dev/null +++ b/main/boards/common/wifi_board.h @@ -0,0 +1,69 @@ +#ifndef WIFI_BOARD_H +#define WIFI_BOARD_H + +#include "board.h" +#include +#include +#include + +class WifiBoard : public Board { +protected: + esp_timer_handle_t connect_timer_ = nullptr; + bool in_config_mode_ = false; + NetworkEventCallback network_event_callback_ = nullptr; + + virtual std::string GetBoardJson() override; + + /** + * Handle network event (called from WiFi manager callbacks) + * @param event The network event type + * @param data Additional data (e.g., SSID for Connecting/Connected events) + */ + void OnNetworkEvent(NetworkEvent event, const std::string& data = ""); + + /** + * Start WiFi connection attempt + */ + void TryWifiConnect(); + + /** + * Enter WiFi configuration mode + */ + void StartWifiConfigMode(); + + /** + * WiFi connection timeout callback + */ + static void OnWifiConnectTimeout(void* arg); + +public: + WifiBoard(); + virtual ~WifiBoard(); + + virtual std::string GetBoardType() override; + + /** + * Start network connection asynchronously + * This function returns immediately. Network events are notified through the callback set by SetNetworkEventCallback(). + */ + virtual void StartNetwork() override; + + virtual NetworkInterface* GetNetwork() override; + virtual void SetNetworkEventCallback(NetworkEventCallback callback) override; + virtual const char* GetNetworkStateIcon() override; + virtual void SetPowerSaveLevel(PowerSaveLevel level) override; + virtual AudioCodec* GetAudioCodec() override { return nullptr; } + virtual std::string GetDeviceStatusJson() override; + + /** + * Enter WiFi configuration mode (thread-safe, can be called from any task) + */ + void EnterWifiConfigMode(); + + /** + * Check if in WiFi config mode + */ + bool IsInWifiConfigMode() const; +}; + +#endif // WIFI_BOARD_H diff --git a/main/boards/zhengchen-cam/README.md b/main/boards/zhengchen-cam/README.md new file mode 100644 index 0000000..919e52b --- /dev/null +++ b/main/boards/zhengchen-cam/README.md @@ -0,0 +1,49 @@ +# 产品相关介绍网址 + +## 简介 +征辰科技 AI camera是小智AI的魔改项目,做了大量创新和优化。 + +## 合并版 +合并版代码在小智AI主项目中维护,跟随主项目的一起版本更新,便于用户自行扩展和第三方固件扩展。支持语音唤醒、语音打断、OTA等功能。 + +## 魔改版 +魔改版由于底层改动太大,代码单独维护,定期合并主项目代码。 + +https://e.tb.cn/h.6Gl2LC7rsrswQZp?tk=qFuaV9hzh0k CZ356 +``` +【淘宝】 「小智AI带摄像头支持识物双麦克风打断 ESP32S3N16R8开发板表情包」 +https://e.tb.cn/h.hBc8Gcx9cUluJJO?tk=YW5C4LPixKg + + + +## 配置、编译命令 + +由于此项目需要配置较多的 sdkconfig 选项,推荐使用编译脚本编译。 + +**编译** + +```bash +python ./scripts/release.py zhengchen-cam +``` + +如需手动编译,请参考 `zhengchen-cam/config.json` 修改 menuconfig 对应选项。 + +**烧录** + +```bash +idf.py flash + + +``` + +MCP Tool: +self.get_device_status +self.audio_speaker.set_volume +self.screen.set_brightness +self.screen.set_theme +self.gif.set_gif_mode +self.display.set_mode +self.camera.take_photo +self.AEC.set_mode +self.AEC.get_mode +self.res.esp_restart \ No newline at end of file diff --git a/main/boards/zhengchen-cam/config.h b/main/boards/zhengchen-cam/config.h new file mode 100644 index 0000000..45f7ca2 --- /dev/null +++ b/main/boards/zhengchen-cam/config.h @@ -0,0 +1,68 @@ +#ifndef _BOARD_CONFIG_H_ +#define _BOARD_CONFIG_H_ + +#include + +#define AUDIO_INPUT_SAMPLE_RATE 24000 +#define AUDIO_OUTPUT_SAMPLE_RATE 24000 + +#define AUDIO_INPUT_REFERENCE true + +#define AUDIO_I2S_GPIO_MCLK GPIO_NUM_38 +#define AUDIO_I2S_GPIO_WS GPIO_NUM_13 +#define AUDIO_I2S_GPIO_BCLK GPIO_NUM_14 +#define AUDIO_I2S_GPIO_DIN GPIO_NUM_12 +#define AUDIO_I2S_GPIO_DOUT GPIO_NUM_45 + +#define AUDIO_CODEC_USE_PCA9557 +#define AUDIO_CODEC_I2C_SDA_PIN GPIO_NUM_1 +#define AUDIO_CODEC_I2C_SCL_PIN GPIO_NUM_2 +#define AUDIO_CODEC_ES8311_ADDR ES8311_CODEC_DEFAULT_ADDR +#define AUDIO_CODEC_ES7210_ADDR 0x82 + +#define BUILTIN_LED_GPIO GPIO_NUM_48 +#define BOOT_BUTTON_GPIO GPIO_NUM_0 +#define VOLUME_UP_BUTTON_GPIO GPIO_NUM_3 +#define VOLUME_DOWN_BUTTON_GPIO GPIO_NUM_46 + +#define DISPLAY_WIDTH 240 +#define DISPLAY_HEIGHT 240 +#define DISPLAY_MIRROR_X true +#define DISPLAY_MIRROR_Y true +#define DISPLAY_SWAP_XY true + +#define DISPLAY_WIDTH_1 240 +#define DISPLAY_HEIGHT_1 240 +#define DISPLAY_MIRROR_X_1 false +#define DISPLAY_MIRROR_Y_1 false +#define DISPLAY_SWAP_XY_1 false + +#define DISPLAY_OFFSET_X 80 +#define DISPLAY_OFFSET_Y 0 + +#define DISPLAY_BACKLIGHT_PIN GPIO_NUM_42 +#define DISPLAY_BACKLIGHT_OUTPUT_INVERT true + + +/* Camera pins */ +#define CAMERA_PIN_PWDN -1 +#define CAMERA_PIN_RESET -1 +#define CAMERA_PIN_XCLK 17 +#define CAMERA_PIN_SIOD 1 +#define CAMERA_PIN_SIOC 2 + +#define CAMERA_PIN_D7 15 +#define CAMERA_PIN_D6 11 +#define CAMERA_PIN_D5 9 +#define CAMERA_PIN_D4 8 +#define CAMERA_PIN_D3 6 +#define CAMERA_PIN_D2 5 +#define CAMERA_PIN_D1 4 +#define CAMERA_PIN_D0 7 +#define CAMERA_PIN_VSYNC 21 +#define CAMERA_PIN_HREF 18 +#define CAMERA_PIN_PCLK 16 + +#define XCLK_FREQ_HZ 24000000 + +#endif // _BOARD_CONFIG_H_ diff --git a/main/boards/zhengchen-cam/config.json b/main/boards/zhengchen-cam/config.json new file mode 100644 index 0000000..e78f51e --- /dev/null +++ b/main/boards/zhengchen-cam/config.json @@ -0,0 +1,17 @@ +{ + "target": "esp32s3", + "builds": [ + { + "name": "zhengchen-cam", + "sdkconfig_append": [ + "CONFIG_USE_DEVICE_AEC=y", + "CONFIG_LWIP_TCPIP_RECVMBOX_SIZE=48", + "CONFIG_ESP_WIFI_DYNAMIC_RX_BUFFER_NUM=32", + "CONFIG_ESP_WIFI_TASK_PINNED_TO_CORE_0=n", + "CONFIG_ESP_WIFI_TASK_PINNED_TO_CORE_1=y", + "CONFIG_LV_USE_FONT_COMPRESSED=y", + "CONFIG_LV_USE_FONT_PLACEHOLDER=y" + ] + } + ] +} \ No newline at end of file diff --git a/main/boards/zhengchen-cam/mcp_controller.cc b/main/boards/zhengchen-cam/mcp_controller.cc new file mode 100644 index 0000000..0f7f073 --- /dev/null +++ b/main/boards/zhengchen-cam/mcp_controller.cc @@ -0,0 +1,94 @@ +#include +#include + +#include + +#include "application.h" +#include "board.h" +#include "config.h" +#include "mcp_server.h" +#include "sdkconfig.h" +#include "settings.h" +#include "display.h" + +#define TAG "MCPController" + +class MCPController { +public: + MCPController() { + RegisterMcpTools(); + ESP_LOGI(TAG, "注册MCP工具"); + } + + void RegisterMcpTools() { + auto& mcp_server = McpServer::GetInstance(); + ESP_LOGI(TAG, "开始注册MCP工具..."); + + mcp_server.AddTool( + "self.AEC.set_mode", + "设置AEC对话打断模式。当用户意图切换对话打断模式时或者用户觉得ai对话容易被打断时或者用户觉得无法实现对话打断时都使用此工具。\n" + "参数:\n" + " `mode`: 对话打断模式,可选值只有`kAecOff`(关闭)和`kAecOnDeviceSide`(开启)\n" + "返回值:\n" + " 反馈状态信息,不需要确认,立即播报相关数据\n", + PropertyList({ + Property("mode", kPropertyTypeString) + }), + [](const PropertyList& properties) -> ReturnValue { + auto mode = properties["mode"].value(); + auto& app = Application::GetInstance(); + vTaskDelay(pdMS_TO_TICKS(2000)); + if (mode == "kAecOff") { + app.SetAecMode(kAecOff); + return "{\"success\": true, \"message\": \"AEC对话打断模式已关闭\"}"; + }else { + auto& board = Board::GetInstance(); + app.SetAecMode(kAecOnDeviceSide); + + return "{\"success\": true, \"message\": \"AEC对话打断模式已开启\"}"; + } + } + ); + + mcp_server.AddTool( + "self.AEC.get_mode", + "获取AEC对话打断模式状态。当用户意图获取对话打断模式状态时使用此工具。\n" + "返回值:\n" + " 反馈状态信息,不需要确认,立即播报相关数据\n", + PropertyList(), + [](const PropertyList&) -> ReturnValue { + auto& app = Application::GetInstance(); + const bool is_currently_off = (app.GetAecMode() == kAecOff); + if (is_currently_off) { + return "{\"success\": true, \"message\": \"AEC对话打断模式处于关闭状态\"}"; + }else { + return "{\"success\": true, \"message\": \"AEC对话打断模式处于开启状态\"}"; + } + } + ); + + mcp_server.AddTool( + "self.res.esp_restart", + "重启设备。当用户意图重启设备时使用此工具。\n", + PropertyList(), + [](const PropertyList&) -> ReturnValue { + vTaskDelay(pdMS_TO_TICKS(1000)); + // Reboot the device + esp_restart(); + return true; + } + ); + + ESP_LOGI(TAG, "MCP工具注册完成"); + } + +}; + +static MCPController* g_mcp_controller = nullptr; + +void InitializeMCPController() { + if (g_mcp_controller == nullptr) { + g_mcp_controller = new MCPController(); + ESP_LOGI(TAG, "注册MCP工具"); + } +} \ No newline at end of file diff --git a/main/boards/zhengchen-cam/power_manager.h b/main/boards/zhengchen-cam/power_manager.h new file mode 100644 index 0000000..f9e31fd --- /dev/null +++ b/main/boards/zhengchen-cam/power_manager.h @@ -0,0 +1,237 @@ +#pragma once +#include +#include +#include +#include +#include +#include +#include +#include "application.h" + +class PowerManager { +private: + // 定时器句柄 + esp_timer_handle_t timer_handle_; + std::function on_charging_status_changed_; + std::function on_low_battery_status_changed_; + std::function on_temperature_changed_; + + gpio_num_t charging_pin_ = GPIO_NUM_NC; + std::vector adc_values_; + uint32_t battery_level_ = 0; + bool is_charging_ = false; + bool is_low_battery_ = false; + float current_temperature_ = 0.0f; + int ticks_ = 0; + const int kBatteryAdcInterval = 60; + const int kBatteryAdcDataCount = 3; + const int kLowBatteryLevel = 20; + const int kTemperatureReadInterval = 10; // 每 10 秒读取一次温度 + + adc_oneshot_unit_handle_t adc_handle_; + temperature_sensor_handle_t temp_sensor_ = NULL; + + void CheckBatteryStatus() { + // Get charging status + bool new_charging_status = gpio_get_level(charging_pin_) == 1; + if (new_charging_status != is_charging_) { + is_charging_ = new_charging_status; + if (on_charging_status_changed_) { + on_charging_status_changed_(is_charging_); + } + ReadBatteryAdcData(); + return; + } + + // 如果电池电量数据不足,则读取电池电量数据 + if (adc_values_.size() < kBatteryAdcDataCount) { + ReadBatteryAdcData(); + return; + } + + // 如果电池电量数据充足,则每 kBatteryAdcInterval 个 tick 读取一次电池电量数据 + ticks_++; + if (ticks_ % kBatteryAdcInterval == 0) { + ReadBatteryAdcData(); + } + + // 新增:周期性读取温度 + if (ticks_ % kTemperatureReadInterval == 0) { + ReadTemperature(); + } + } + + void ReadBatteryAdcData() { + // 读取 ADC 值 + int adc_value; + ESP_ERROR_CHECK(adc_oneshot_read(adc_handle_, ADC_CHANNEL_9, &adc_value)); + + + // 将 ADC 值添加到队列中 + adc_values_.push_back(adc_value); + if (adc_values_.size() > kBatteryAdcDataCount) { + adc_values_.erase(adc_values_.begin()); + } + uint32_t average_adc = 0; + for (auto value : adc_values_) { + average_adc += (value + 80); + } + average_adc /= adc_values_.size(); + + + // 定义电池电量区间 + const struct { + uint16_t adc; + uint8_t level; + } levels[] = { + {2030, 0}, + {2134, 20}, + {2252, 40}, + {2370, 60}, + {2488, 80}, + {2606, 100} + }; + // 低于最低值时 + if (average_adc < levels[0].adc) { + battery_level_ = 0; + } + // 高于最高值时 + else if (average_adc >= levels[5].adc) { + battery_level_ = 100; + } else { + // 线性插值计算中间值 + for (int i = 0; i < 5; i++) { + if (average_adc >= levels[i].adc && average_adc < levels[i+1].adc) { + float ratio = static_cast(average_adc - levels[i].adc) / (levels[i+1].adc - levels[i].adc); + battery_level_ = levels[i].level + ratio * (levels[i+1].level - levels[i].level); + break; + } + } + } + // 检查是否达到低电量阈值 + if (adc_values_.size() >= kBatteryAdcDataCount) { + bool new_low_battery_status = battery_level_ <= kLowBatteryLevel; + if (new_low_battery_status != is_low_battery_) { + is_low_battery_ = new_low_battery_status; + if (on_low_battery_status_changed_) { + on_low_battery_status_changed_(is_low_battery_); + } + } + } + + ESP_LOGI("PowerManager", "ADC value: %d average: %ld level: %ld", adc_value, average_adc, battery_level_); + } + + void ReadTemperature() { + float temperature = 0.0f; + ESP_ERROR_CHECK(temperature_sensor_get_celsius(temp_sensor_, &temperature)); + + if (abs(temperature - current_temperature_) >= 3.5f) { // 温度变化超过3.5°C才触发回调 + current_temperature_ = temperature; + if (on_temperature_changed_) { + on_temperature_changed_(current_temperature_); + } + ESP_LOGI("PowerManager", "Temperature updated: %.1f°C", current_temperature_); + } + } + + +public: + PowerManager(gpio_num_t pin) : charging_pin_(pin) { + + // 初始化充电引脚 + gpio_config_t io_conf = {}; + io_conf.intr_type = GPIO_INTR_DISABLE; + io_conf.mode = GPIO_MODE_INPUT; + io_conf.pin_bit_mask = (1ULL << charging_pin_); + io_conf.pull_down_en = GPIO_PULLDOWN_DISABLE; + io_conf.pull_up_en = GPIO_PULLUP_DISABLE; + gpio_config(&io_conf); + + // 创建电池电量检查定时器 + esp_timer_create_args_t timer_args = { + .callback = [](void* arg) { + PowerManager* self = static_cast(arg); + self->CheckBatteryStatus(); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "battery_check_timer", + .skip_unhandled_events = true, + }; + ESP_ERROR_CHECK(esp_timer_create(&timer_args, &timer_handle_)); + ESP_ERROR_CHECK(esp_timer_start_periodic(timer_handle_, 1000000)); + + // 初始化 ADC + adc_oneshot_unit_init_cfg_t init_config = { + .unit_id = ADC_UNIT_1, + .ulp_mode = ADC_ULP_MODE_DISABLE, + }; + ESP_ERROR_CHECK(adc_oneshot_new_unit(&init_config, &adc_handle_)); + + adc_oneshot_chan_cfg_t chan_config = { + .atten = ADC_ATTEN_DB_12, + .bitwidth = ADC_BITWIDTH_12, + }; + ESP_ERROR_CHECK(adc_oneshot_config_channel(adc_handle_, ADC_CHANNEL_9, &chan_config)); + + // 初始化温度传感器 + temperature_sensor_config_t temp_config = { + .range_min = 10, + .range_max = 80, + .clk_src = TEMPERATURE_SENSOR_CLK_SRC_DEFAULT + }; + ESP_ERROR_CHECK(temperature_sensor_install(&temp_config, &temp_sensor_)); + ESP_ERROR_CHECK(temperature_sensor_enable(temp_sensor_)); + ESP_LOGI("PowerManager", "Temperature sensor initialized (new driver)"); + } + + ~PowerManager() { + if (timer_handle_) { + esp_timer_stop(timer_handle_); + esp_timer_delete(timer_handle_); + } + if (adc_handle_) { + adc_oneshot_del_unit(adc_handle_); + } + + if (temp_sensor_) { + temperature_sensor_disable(temp_sensor_); + temperature_sensor_uninstall(temp_sensor_); + } + + } + + bool IsCharging() { + // 如果电量已经满了,则不再显示充电中 + if (battery_level_ == 100) { + return false; + } + return is_charging_; + } + + bool IsDischarging() { + // 没有区分充电和放电,所以直接返回相反状态 + return !is_charging_; + } + + // 获取电池电量 + uint8_t GetBatteryLevel() { + // 返回电池电量 + return battery_level_; + } + + float GetTemperature() const { return current_temperature_; } // 获取当前温度 + + void OnTemperatureChanged(std::function callback) { + on_temperature_changed_ = callback; + } + + void OnLowBatteryStatusChanged(std::function callback) { + on_low_battery_status_changed_ = callback; + } + + void OnChargingStatusChanged(std::function callback) { + on_charging_status_changed_ = callback; + } +}; diff --git a/main/boards/zhengchen-cam/zhengchen_cam_board.cc b/main/boards/zhengchen-cam/zhengchen_cam_board.cc new file mode 100644 index 0000000..6866a8e --- /dev/null +++ b/main/boards/zhengchen-cam/zhengchen_cam_board.cc @@ -0,0 +1,335 @@ +#include "wifi_board.h" +#include "audio/codecs/box_audio_codec.h" +#include "display/lcd_display.h" +#include "application.h" +#include "button.h" +#include "led/single_led.h" +#include "config.h" +#include "i2c_device.h" +#include "esp32_camera.h" +#include "assets/lang_config.h" + +#include +#include +#include +#include +#include +#include "power_manager.h" +#include +#include +#include "settings.h" + +#define FIRST_BOOT_NS "boot_config" +#define FIRST_BOOT_KEY "is_first" + + +#define TAG "ZhengchenCamBoard" + +//控制器初始化函数声明 +void InitializeMCPController(); + +LV_FONT_DECLARE(font_puhui_20_4); +LV_FONT_DECLARE(font_awesome_20_4); + +class Pca9557 : public I2cDevice { +public: + Pca9557(i2c_master_bus_handle_t i2c_bus, uint8_t addr) : I2cDevice(i2c_bus, addr) { + WriteReg(0x01, 0x03); + WriteReg(0x03, 0xf8); + } + + void SetOutputState(uint8_t bit, uint8_t level) { + uint8_t data = ReadReg(0x01); + data = (data & ~(1 << bit)) | (level << bit); + WriteReg(0x01, data); + } +}; + +class CustomAudioCodec : public BoxAudioCodec { +private: + Pca9557* pca9557_; + +public: + CustomAudioCodec(i2c_master_bus_handle_t i2c_bus, Pca9557* pca9557) + : BoxAudioCodec(i2c_bus, + AUDIO_INPUT_SAMPLE_RATE, + AUDIO_OUTPUT_SAMPLE_RATE, + AUDIO_I2S_GPIO_MCLK, + AUDIO_I2S_GPIO_BCLK, + AUDIO_I2S_GPIO_WS, + AUDIO_I2S_GPIO_DOUT, + AUDIO_I2S_GPIO_DIN, + GPIO_NUM_NC, + AUDIO_CODEC_ES8311_ADDR, + AUDIO_CODEC_ES7210_ADDR, + AUDIO_INPUT_REFERENCE), + pca9557_(pca9557) { + } + + virtual void EnableOutput(bool enable) override { + BoxAudioCodec::EnableOutput(enable); + if (enable) { + pca9557_->SetOutputState(1, 1); + } else { + pca9557_->SetOutputState(1, 0); + } + } +}; + +class ZhengchenCamBoard : public WifiBoard { +private: + i2c_master_bus_handle_t i2c_bus_; + i2c_master_dev_handle_t pca9557_handle_; + Button boot_button_; + Button volume_up_button_; + Button volume_down_button_; + LcdDisplay* display_ = nullptr; + Pca9557* pca9557_; + Esp32Camera* camera_; + PowerManager* power_manager_ = new PowerManager(GPIO_NUM_47); + + void InitializeI2c() { + // Initialize I2C peripheral + i2c_master_bus_config_t i2c_bus_cfg = { + .i2c_port = (i2c_port_t)1, + .sda_io_num = AUDIO_CODEC_I2C_SDA_PIN, + .scl_io_num = AUDIO_CODEC_I2C_SCL_PIN, + .clk_source = I2C_CLK_SRC_DEFAULT, + .glitch_ignore_cnt = 7, + .intr_priority = 0, + .trans_queue_depth = 0, + .flags = { + .enable_internal_pullup = 1, + }, + }; + ESP_ERROR_CHECK(i2c_new_master_bus(&i2c_bus_cfg, &i2c_bus_)); + + // Initialize PCA9557 + pca9557_ = new Pca9557(i2c_bus_, 0x19); + } + + void InitializeSpi() { + spi_bus_config_t buscfg = {}; + buscfg.mosi_io_num = GPIO_NUM_40; + buscfg.miso_io_num = GPIO_NUM_NC; + buscfg.sclk_io_num = GPIO_NUM_41; + buscfg.quadwp_io_num = GPIO_NUM_NC; + buscfg.quadhd_io_num = GPIO_NUM_NC; + buscfg.max_transfer_sz = DISPLAY_WIDTH * DISPLAY_HEIGHT * sizeof(uint16_t); + ESP_ERROR_CHECK(spi_bus_initialize(SPI3_HOST, &buscfg, SPI_DMA_CH_AUTO)); + } + + void InitializeButtons() { + boot_button_.OnClick([this]() { + auto& app = Application::GetInstance(); + // During startup (before connected), pressing BOOT button enters Wi-Fi config mode without reboot + if (app.GetDeviceState() == kDeviceStateStarting) { + EnterWifiConfigMode(); + return; + } + app.ToggleChatState(); + }); + + boot_button_.OnDoubleClick([this]() { + Settings settings(FIRST_BOOT_NS, true); + bool is_first_boot = settings.GetInt(FIRST_BOOT_KEY, 1) != 0; + if (is_first_boot) { + ESP_LOGI(TAG, "首次启动,启用双击拍照功能"); + auto camera = GetCamera(); + if (!camera->Capture()) { + ESP_LOGE(TAG, "Camera capture failed"); + } + settings.SetInt(FIRST_BOOT_KEY, 0); + + + } else { + ESP_LOGI(TAG, "非首次启动,禁用双击拍照功能"); + auto& app = Application::GetInstance(); + if (app.GetDeviceState() == kDeviceStateIdle) { + app.SetAecMode(app.GetAecMode() == kAecOff ? kAecOnDeviceSide : kAecOff); + GetAudioCodec()->SetOutputVolume(60); + } + } + }); + + boot_button_.OnLongPress([this]() { + EnterWifiConfigMode(); + }); + + volume_up_button_.OnClick([this]() { + auto codec = GetAudioCodec(); + auto volume = codec->output_volume() + 10; + if (volume > 100) { + volume = 100; + } + GetDisplay()->ShowNotification(Lang::Strings::VOLUME + std::to_string(volume/10)); + codec->SetOutputVolume(volume); + + }); + + volume_up_button_.OnLongPress([this]() { + GetAudioCodec()->SetOutputVolume(100); + GetDisplay()->ShowNotification(Lang::Strings::MAX_VOLUME); + }); + + volume_down_button_.OnClick([this]() { + auto codec = GetAudioCodec(); + auto volume = codec->output_volume() - 10; + if (volume < 0) { + volume = 0; + } + codec->SetOutputVolume(volume); + GetDisplay()->ShowNotification(Lang::Strings::VOLUME + std::to_string(volume/10)); + }); + + volume_down_button_.OnLongPress([this]() { + GetAudioCodec()->SetOutputVolume(0); + GetDisplay()->ShowNotification(Lang::Strings::MUTED); + }); + } + + void InitializeSt7789Display() { + esp_lcd_panel_io_handle_t panel_io = nullptr; + esp_lcd_panel_handle_t panel = nullptr; + // 液晶屏控制IO初始化 + ESP_LOGD(TAG, "Install panel IO"); + esp_lcd_panel_io_spi_config_t io_config = {}; + io_config.cs_gpio_num = GPIO_NUM_NC; + io_config.dc_gpio_num = GPIO_NUM_39; + io_config.spi_mode = 0; + io_config.pclk_hz = 80 * 1000 * 1000; + io_config.trans_queue_depth = 10; + io_config.lcd_cmd_bits = 8; + io_config.lcd_param_bits = 8; + ESP_ERROR_CHECK(esp_lcd_new_panel_io_spi(SPI3_HOST, &io_config, &panel_io)); + + // 初始化液晶屏驱动芯片ST7789 + ESP_LOGD(TAG, "Install LCD driver"); + esp_lcd_panel_dev_config_t panel_config = {}; + panel_config.reset_gpio_num = GPIO_NUM_NC; + panel_config.rgb_ele_order = LCD_RGB_ELEMENT_ORDER_RGB; + panel_config.bits_per_pixel = 16; + ESP_ERROR_CHECK(esp_lcd_new_panel_st7789(panel_io, &panel_config, &panel)); + + esp_lcd_panel_reset(panel); + pca9557_->SetOutputState(0, 0); + + esp_lcd_panel_init(panel); + esp_lcd_panel_invert_color(panel, false); + + Settings settings("lcd_display", true); + bool is_landscape = settings.GetInt("lcd_mode", 1) != 0; + + if(is_landscape) { + // 横屏模式 + esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY); + esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y); + display_ = new SpiLcdDisplay(panel_io, panel, + DISPLAY_WIDTH, DISPLAY_HEIGHT, DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y, DISPLAY_MIRROR_X, DISPLAY_MIRROR_Y, DISPLAY_SWAP_XY); + + } else { + // 竖屏模式 + esp_lcd_panel_swap_xy(panel, DISPLAY_SWAP_XY_1); + esp_lcd_panel_mirror(panel, DISPLAY_MIRROR_X_1, DISPLAY_MIRROR_Y_1); + display_ = new SpiLcdDisplay(panel_io, panel, + DISPLAY_WIDTH_1, DISPLAY_HEIGHT_1, DISPLAY_OFFSET_X, DISPLAY_OFFSET_Y, DISPLAY_MIRROR_X_1, DISPLAY_MIRROR_Y_1, DISPLAY_SWAP_XY_1); + } + + } + + void InitializeCamera() { + // Open camera power + pca9557_->SetOutputState(2, 0); + + camera_config_t config = {}; + config.ledc_channel = LEDC_CHANNEL_2; // LEDC通道选择 用于生成XCLK时钟 但是S3不用 + config.ledc_timer = LEDC_TIMER_2; // LEDC timer选择 用于生成XCLK时钟 但是S3不用 + config.pin_d0 = CAMERA_PIN_D0; + config.pin_d1 = CAMERA_PIN_D1; + config.pin_d2 = CAMERA_PIN_D2; + config.pin_d3 = CAMERA_PIN_D3; + config.pin_d4 = CAMERA_PIN_D4; + config.pin_d5 = CAMERA_PIN_D5; + config.pin_d6 = CAMERA_PIN_D6; + config.pin_d7 = CAMERA_PIN_D7; + config.pin_xclk = CAMERA_PIN_XCLK; + config.pin_pclk = CAMERA_PIN_PCLK; + config.pin_vsync = CAMERA_PIN_VSYNC; + config.pin_href = CAMERA_PIN_HREF; + config.pin_sccb_sda = -1; // 这里写-1 表示使用已经初始化的I2C接口 + config.pin_sccb_scl = CAMERA_PIN_SIOC; + config.sccb_i2c_port = 1; + config.pin_pwdn = CAMERA_PIN_PWDN; + config.pin_reset = CAMERA_PIN_RESET; + config.xclk_freq_hz = XCLK_FREQ_HZ; + config.pixel_format = PIXFORMAT_RGB565; + config.frame_size = FRAMESIZE_VGA; + config.jpeg_quality = 9; + config.fb_count = 1; + config.fb_location = CAMERA_FB_IN_PSRAM; + config.grab_mode = CAMERA_GRAB_WHEN_EMPTY; + + camera_ = new Esp32Camera(config); + camera_->SetHMirror(true); + camera_->SetVFlip(true); + } + + void InitializeController() { InitializeMCPController(); } + +public: + ZhengchenCamBoard() : + boot_button_(BOOT_BUTTON_GPIO), + volume_up_button_(VOLUME_UP_BUTTON_GPIO), + volume_down_button_(VOLUME_DOWN_BUTTON_GPIO) { + InitializeI2c(); + InitializeSpi(); + InitializeSt7789Display(); + InitializeButtons(); + InitializeCamera(); + InitializeController(); + GetBacklight()->RestoreBrightness(); + } + + virtual AudioCodec* GetAudioCodec() override { + static CustomAudioCodec audio_codec( + i2c_bus_, + pca9557_); + return &audio_codec; + } + + virtual Display* GetDisplay() override { + return display_; + } + + virtual Led* GetLed() override { + static SingleLed led(BUILTIN_LED_GPIO); + return &led; + } + + virtual bool GetBatteryLevel(int& level, bool& charging, bool& discharging) override { + static bool last_discharging = false; + charging = power_manager_->IsCharging(); + discharging = power_manager_->IsDischarging(); + if (discharging != last_discharging) { + last_discharging = discharging; + } + level = std::max(power_manager_->GetBatteryLevel(), 20); + return true; + } + + virtual bool GetTemperature(float& esp32temp) override { + esp32temp = power_manager_->GetTemperature(); + return true; + } + + virtual Backlight* GetBacklight() override { + static PwmBacklight backlight(DISPLAY_BACKLIGHT_PIN, DISPLAY_BACKLIGHT_OUTPUT_INVERT); + return &backlight; + } + + virtual Camera* GetCamera() override { + return camera_; + } +}; + +DECLARE_BOARD(ZhengchenCamBoard); diff --git a/main/device_state.h b/main/device_state.h new file mode 100644 index 0000000..4ffafae --- /dev/null +++ b/main/device_state.h @@ -0,0 +1,18 @@ +#ifndef _DEVICE_STATE_H_ +#define _DEVICE_STATE_H_ + +enum DeviceState { + kDeviceStateUnknown, + kDeviceStateStarting, + kDeviceStateWifiConfiguring, + kDeviceStateIdle, + kDeviceStateConnecting, + kDeviceStateListening, + kDeviceStateSpeaking, + kDeviceStateUpgrading, + kDeviceStateActivating, + kDeviceStateAudioTesting, + kDeviceStateFatalError +}; + +#endif // _DEVICE_STATE_H_ \ No newline at end of file diff --git a/main/device_state_machine.cc b/main/device_state_machine.cc new file mode 100644 index 0000000..30581de --- /dev/null +++ b/main/device_state_machine.cc @@ -0,0 +1,161 @@ +#include "device_state_machine.h" + +#include +#include + +static const char* TAG = "StateMachine"; + +// State name strings for logging +static const char* const STATE_STRINGS[] = { + "unknown", + "starting", + "wifi_configuring", + "idle", + "connecting", + "listening", + "speaking", + "upgrading", + "activating", + "audio_testing", + "fatal_error", + "invalid_state" +}; + +DeviceStateMachine::DeviceStateMachine() { +} + +const char* DeviceStateMachine::GetStateName(DeviceState state) { + if (state >= 0 && state <= kDeviceStateFatalError) { + return STATE_STRINGS[state]; + } + return STATE_STRINGS[kDeviceStateFatalError + 1]; +} + +bool DeviceStateMachine::IsValidTransition(DeviceState from, DeviceState to) const { + // Allow transition to the same state (no-op) + if (from == to) { + return true; + } + + // Define valid state transitions based on the state diagram + switch (from) { + case kDeviceStateUnknown: + // Can only go to starting + return to == kDeviceStateStarting; + + case kDeviceStateStarting: + // Can go to wifi configuring or activating + return to == kDeviceStateWifiConfiguring || + to == kDeviceStateActivating; + + case kDeviceStateWifiConfiguring: + // Can go to activating (after wifi connected) or audio testing + return to == kDeviceStateActivating || + to == kDeviceStateAudioTesting; + + case kDeviceStateAudioTesting: + // Can go back to wifi configuring + return to == kDeviceStateWifiConfiguring; + + case kDeviceStateActivating: + // Can go to upgrading, idle, or back to wifi configuring (on error) + return to == kDeviceStateUpgrading || + to == kDeviceStateIdle || + to == kDeviceStateWifiConfiguring; + + case kDeviceStateUpgrading: + // Can go to idle (upgrade failed) or activating + return to == kDeviceStateIdle || + to == kDeviceStateActivating; + + case kDeviceStateIdle: + // Can go to connecting, listening (manual mode), speaking, activating, upgrading, or wifi configuring + return to == kDeviceStateConnecting || + to == kDeviceStateListening || + to == kDeviceStateSpeaking || + to == kDeviceStateActivating || + to == kDeviceStateUpgrading || + to == kDeviceStateWifiConfiguring; + + case kDeviceStateConnecting: + // Can go to idle (failed) or listening (success) + return to == kDeviceStateIdle || + to == kDeviceStateListening; + + case kDeviceStateListening: + // Can go to speaking or idle + return to == kDeviceStateSpeaking || + to == kDeviceStateIdle; + + case kDeviceStateSpeaking: + // Can go to listening or idle + return to == kDeviceStateListening || + to == kDeviceStateIdle; + + case kDeviceStateFatalError: + // Cannot transition out of fatal error + return false; + + default: + return false; + } +} + +bool DeviceStateMachine::CanTransitionTo(DeviceState target) const { + return IsValidTransition(current_state_.load(), target); +} + +bool DeviceStateMachine::TransitionTo(DeviceState new_state) { + DeviceState old_state = current_state_.load(); + + // No-op if already in the target state + if (old_state == new_state) { + return true; + } + + // Validate transition + if (!IsValidTransition(old_state, new_state)) { + ESP_LOGW(TAG, "Invalid state transition: %s -> %s", + GetStateName(old_state), GetStateName(new_state)); + return false; + } + + // Perform transition + current_state_.store(new_state); + ESP_LOGI(TAG, "State: %s -> %s", + GetStateName(old_state), GetStateName(new_state)); + + // Notify callback + NotifyStateChange(old_state, new_state); + return true; +} + +int DeviceStateMachine::AddStateChangeListener(StateCallback callback) { + std::lock_guard lock(mutex_); + int id = next_listener_id_++; + listeners_.emplace_back(id, std::move(callback)); + return id; +} + +void DeviceStateMachine::RemoveStateChangeListener(int listener_id) { + std::lock_guard lock(mutex_); + listeners_.erase( + std::remove_if(listeners_.begin(), listeners_.end(), + [listener_id](const auto& p) { return p.first == listener_id; }), + listeners_.end()); +} + +void DeviceStateMachine::NotifyStateChange(DeviceState old_state, DeviceState new_state) { + std::vector callbacks_copy; + { + std::lock_guard lock(mutex_); + callbacks_copy.reserve(listeners_.size()); + for (const auto& [id, cb] : listeners_) { + callbacks_copy.push_back(cb); + } + } + + for (const auto& cb : callbacks_copy) { + cb(old_state, new_state); + } +} diff --git a/main/device_state_machine.h b/main/device_state_machine.h new file mode 100644 index 0000000..9566fde --- /dev/null +++ b/main/device_state_machine.h @@ -0,0 +1,83 @@ +#ifndef DEVICE_STATE_MACHINE_H +#define DEVICE_STATE_MACHINE_H + +#include +#include +#include +#include + +#include "device_state.h" + +/** + * DeviceStateMachine - Manages device state transitions with validation + * + * This class ensures strict state transition rules and provides a callback mechanism + * for components to react to state changes. + */ +class DeviceStateMachine { +public: + DeviceStateMachine(); + ~DeviceStateMachine() = default; + + // Delete copy constructor and assignment operator + DeviceStateMachine(const DeviceStateMachine&) = delete; + DeviceStateMachine& operator=(const DeviceStateMachine&) = delete; + + /** + * Get the current device state + */ + DeviceState GetState() const { return current_state_.load(); } + + /** + * Attempt to transition to a new state + * @param new_state The target state + * @return true if transition was successful, false if invalid transition + */ + bool TransitionTo(DeviceState new_state); + + /** + * Check if transition to target state is valid from current state + */ + bool CanTransitionTo(DeviceState target) const; + + /** + * State change callback type + * Parameters: old_state, new_state + */ + using StateCallback = std::function; + + /** + * Add a state change listener (observer pattern) + * Callback is invoked in the context of the caller of TransitionTo() + * @return listener id for removal + */ + int AddStateChangeListener(StateCallback callback); + + /** + * Remove a state change listener by id + */ + void RemoveStateChangeListener(int listener_id); + + /** + * Get state name string for logging + */ + static const char* GetStateName(DeviceState state); + +private: + std::atomic current_state_{kDeviceStateUnknown}; + std::vector> listeners_; + int next_listener_id_{0}; + std::mutex mutex_; + + /** + * Check if transition from source to target is valid + */ + bool IsValidTransition(DeviceState from, DeviceState to) const; + + /** + * Notify callback of state change + */ + void NotifyStateChange(DeviceState old_state, DeviceState new_state); +}; + +#endif // DEVICE_STATE_MACHINE_H diff --git a/main/display/display.cc b/main/display/display.cc new file mode 100644 index 0000000..50fc1c5 --- /dev/null +++ b/main/display/display.cc @@ -0,0 +1,60 @@ +#include +#include +#include +#include +#include +#include + +#include "display.h" +#include "board.h" +#include "application.h" +#include "audio_codec.h" +#include "settings.h" +#include "assets/lang_config.h" + +#define TAG "Display" + +Display::Display() { +} + +Display::~Display() { +} + +void Display::SetStatus(const char* status) { + ESP_LOGW(TAG, "SetStatus: %s", status); +} + +void Display::ShowNotification(const std::string ¬ification, int duration_ms) { + ShowNotification(notification.c_str(), duration_ms); +} + +void Display::ShowNotification(const char* notification, int duration_ms) { + ESP_LOGW(TAG, "ShowNotification: %s", notification); +} + +void Display::UpdateStatusBar(bool update_all) { +} + + +void Display::SetEmotion(const char* emotion) { + ESP_LOGW(TAG, "SetEmotion: %s", emotion); +} + +void Display::SetChatMessage(const char* role, const char* content) { + ESP_LOGW(TAG, "Role:%s", role); + ESP_LOGW(TAG, " %s", content); +} + +void Display::ClearChatMessages() { + // Default empty implementation, override in subclasses if needed +} + +void Display::SetTheme(Theme* theme) { + current_theme_ = theme; + Settings settings("display", true); + settings.SetString("theme", theme->name()); +} + +void Display::SetPowerSaveMode(bool on) { + ESP_LOGW(TAG, "SetPowerSaveMode: %d", on); +} diff --git a/main/display/display.h b/main/display/display.h new file mode 100644 index 0000000..9e73014 --- /dev/null +++ b/main/display/display.h @@ -0,0 +1,87 @@ +#ifndef DISPLAY_H +#define DISPLAY_H + +#include "emoji_collection.h" + +#ifndef CONFIG_USE_EMOTE_MESSAGE_STYLE +#define HAVE_LVGL 1 +#include +#endif + +#include +#include +#include + +#include +#include + +class Theme { +public: + Theme(const std::string& name) : name_(name) {} + virtual ~Theme() = default; + + inline std::string name() const { return name_; } +private: + std::string name_; +}; + +class Display { +public: + Display(); + virtual ~Display(); + + virtual void SetStatus(const char* status); + virtual void ShowNotification(const char* notification, int duration_ms = 3000); + virtual void ShowNotification(const std::string ¬ification, int duration_ms = 3000); + virtual void SetEmotion(const char* emotion); + virtual void SetChatMessage(const char* role, const char* content); + virtual void ClearChatMessages(); + virtual void SetTheme(Theme* theme); + virtual Theme* GetTheme() { return current_theme_; } + virtual void UpdateStatusBar(bool update_all = false); + virtual void SetPowerSaveMode(bool on); + virtual void SetupUI() { + setup_ui_called_ = true; + } + + inline int width() const { return width_; } + inline int height() const { return height_; } + inline bool IsSetupUICalled() const { return setup_ui_called_; } + +protected: + int width_ = 0; + int height_ = 0; + bool setup_ui_called_ = false; // Track if SetupUI() has been called + + Theme* current_theme_ = nullptr; + + friend class DisplayLockGuard; + virtual bool Lock(int timeout_ms = 0) = 0; + virtual void Unlock() = 0; +}; + + +class DisplayLockGuard { +public: + DisplayLockGuard(Display *display) : display_(display) { + if (!display_->Lock(30000)) { + ESP_LOGE("Display", "Failed to lock display"); + } + } + ~DisplayLockGuard() { + display_->Unlock(); + } + +private: + Display *display_; +}; + +class NoDisplay : public Display { +private: + virtual bool Lock(int timeout_ms = 0) override { + return true; + } + virtual void Unlock() override {} +}; + +#endif diff --git a/main/display/emote_display.cc b/main/display/emote_display.cc new file mode 100644 index 0000000..7ed920a --- /dev/null +++ b/main/display/emote_display.cc @@ -0,0 +1,250 @@ +#include "emote_display.h" + +// Standard C++ headers +#include +#include +#include +#include +#include +#include + +// Standard C headers +#include +#include + +// ESP-IDF headers +#include +#include +#include +#include + +// FreeRTOS headers +#include +#include + +// Project headers +#include "assets/lang_config.h" +#include "assets.h" +#include "board.h" +#include "gfx.h" +#include "expression_emote.h" + + +namespace emote { + +// ============================================================================ +// Constants and Type Definitions +// ============================================================================ + +static const char* TAG = "EmoteDisplay"; + +// ============================================================================ +// Forward Declarations +// ============================================================================ + +class EmoteDisplay; + +// ============================================================================ +// Helper Functions +// ============================================================================ + +static bool OnFlushIoReady(const esp_lcd_panel_io_handle_t panel_io, + esp_lcd_panel_io_event_data_t* const edata, void* user_ctx) +{ + emote_handle_t handle = static_cast(user_ctx); + if (handle) { + emote_notify_flush_finished(handle); + } + return true; +} + +// Flush callback for emote +static void OnFlushCallback(int x_start, int y_start, int x_end, int y_end, const void* data, emote_handle_t handle) +{ + esp_lcd_panel_handle_t panel = (esp_lcd_panel_handle_t)emote_get_user_data(handle); + if (panel != nullptr) { + esp_lcd_panel_draw_bitmap(panel, x_start, y_start, x_end, y_end, data); + } +} + +// ============================================================================ +// Graphics Initialization Functions +// ============================================================================ + +static emote_handle_t InitializeEmote(const esp_lcd_panel_handle_t panel, const int width, const int height) +{ + if (!panel) { + ESP_LOGE(TAG, "Invalid panel"); + return nullptr; + } + + emote_config_t emote_cfg = { + .flags = { + .swap = true, + .double_buffer = true, + .buff_dma = false, + }, + .gfx_emote = { + .h_res = width, + .v_res = height, + .fps = 30, + }, + .buffers = { + .buf_pixels = static_cast(width * 16), + }, + .task = { + .task_priority = 5, + .task_stack = 6 * 1024, + .task_affinity = 0, + .task_stack_in_ext = false, + }, + .flush_cb = OnFlushCallback, + .user_data = (void*)panel, + }; + + emote_handle_t emote_handle = emote_init(&emote_cfg); + if (!emote_handle) { + ESP_LOGE(TAG, "Failed to initialize emote"); + return nullptr; + } + + return emote_handle; +} + +// ============================================================================ +// EmoteDisplay Class Implementation +// ============================================================================ + +EmoteDisplay::EmoteDisplay(const esp_lcd_panel_handle_t panel, const esp_lcd_panel_io_handle_t panel_io, + const int width, const int height) +{ + emote_handle_ = InitializeEmote(panel, width, height); + + const esp_lcd_panel_io_callbacks_t cbs = { + .on_color_trans_done = OnFlushIoReady, + }; + esp_lcd_panel_io_register_event_callbacks(panel_io, &cbs, emote_handle_); +} + +EmoteDisplay::~EmoteDisplay() +{ + if (emote_handle_) { + emote_deinit(emote_handle_); + emote_handle_ = nullptr; + } +} + +void EmoteDisplay::SetEmotion(const char* const emotion) +{ + ESP_LOGI(TAG, "SetEmotion: %s", emotion); + if (emote_handle_ && emotion && strlen(emotion) > 0) { + emote_set_anim_emoji(emote_handle_, emotion); + } +} + +void EmoteDisplay::SetChatMessage(const char* const role, const char* const content) +{ + ESP_LOGI(TAG, "SetChatMessage: %s, %s", role, content); + if (emote_handle_ && content && strlen(content) > 0) { + if ((std::strcmp(role, "system") == 0) && std::strstr(content, "xiaozhi.me")) { + size_t len = strlen(content); + char* new_content = new char[len + 1]; + strcpy(new_content, content); + std::replace(new_content, new_content + len, static_cast(0x0A), static_cast(0x20)); + emote_set_event_msg(emote_handle_, EMOTE_MGR_EVT_SYS, new_content); + delete[] new_content; + } else { + emote_set_event_msg(emote_handle_, EMOTE_MGR_EVT_SPEAK, content); + } + } +} + +void EmoteDisplay::SetStatus(const char* const status) +{ + ESP_LOGI(TAG, "SetStatus: %s", status); + if (emote_handle_ && status && strlen(status) > 0) { + if (std::strcmp(status, Lang::Strings::LISTENING) == 0) { + emote_set_event_msg(emote_handle_, EMOTE_MGR_EVT_LISTEN, NULL); + } else if (std::strcmp(status, Lang::Strings::STANDBY) == 0) { + emote_set_event_msg(emote_handle_, EMOTE_MGR_EVT_IDLE, NULL); + } else if (std::strcmp(status, Lang::Strings::SPEAKING) == 0) { + emote_set_event_msg(emote_handle_, EMOTE_MGR_EVT_SPEAK, NULL); + } else if (std::strcmp(status, Lang::Strings::ERROR) == 0) { + emote_set_event_msg(emote_handle_, EMOTE_MGR_EVT_SET, NULL); + } + } +} + +void EmoteDisplay::ShowNotification(const char* notification, int duration_ms) +{ + ESP_LOGI(TAG, "ShowNotification: %s", notification); + if (emote_handle_ && notification && strlen(notification) > 0) { + emote_set_event_msg(emote_handle_, EMOTE_MGR_EVT_SYS, notification); + } +} + +void EmoteDisplay::UpdateStatusBar(bool update_all) +{ + ESP_LOGD(TAG, "UpdateStatusBar: %s", update_all ? "true" : "false"); + if (!emote_handle_) { + return; + } +} + +void EmoteDisplay::SetPowerSaveMode(bool on) +{ + ESP_LOGI(TAG, "SetPowerSaveMode: %s", on ? "ON" : "OFF"); + if (!emote_handle_) { + return; + } +} + +void EmoteDisplay::SetPreviewImage(const void* image) +{ + if (image) { + ESP_LOGI(TAG, "SetPreviewImage: Preview image not supported, using default icon"); + } +} + +void EmoteDisplay::SetTheme(Theme* const theme) +{ + ESP_LOGI(TAG, "SetTheme: %p", theme); +} + +bool EmoteDisplay::Lock(const int timeout_ms) +{ + (void)timeout_ms; + return true; +} + +void EmoteDisplay::Unlock() +{ +} + +bool EmoteDisplay::StopAnimDialog() +{ + ESP_LOGI(TAG, "StopAnimDialog"); + if (emote_handle_) { + return emote_stop_anim_dialog(emote_handle_); + } + return false; +} + +bool EmoteDisplay::InsertAnimDialog(const char* emoji_name, uint32_t duration_ms) +{ + ESP_LOGI(TAG, "InsertAnimDialog: %s, %" PRIu32, emoji_name, duration_ms); + if (emote_handle_ && emoji_name) { + return emote_insert_anim_dialog(emote_handle_, emoji_name, duration_ms); + } + return false; +} + +void EmoteDisplay::RefreshAll() +{ + if (emote_handle_) { + emote_notify_all_refresh(emote_handle_); + return; + } +} + +} // namespace emote \ No newline at end of file diff --git a/main/display/emote_display.h b/main/display/emote_display.h new file mode 100644 index 0000000..20f1870 --- /dev/null +++ b/main/display/emote_display.h @@ -0,0 +1,42 @@ +#pragma once + +#include "display.h" +#include +#include +#include +#include +#include "expression_emote.h" + +namespace emote { + +class EmoteDisplay : public Display { +public: + EmoteDisplay(esp_lcd_panel_handle_t panel, esp_lcd_panel_io_handle_t panel_io, int width, int height); + virtual ~EmoteDisplay(); + + virtual void SetEmotion(const char* emotion) override; + virtual void SetStatus(const char* status) override; + virtual void SetChatMessage(const char* role, const char* content) override; + virtual void SetTheme(Theme* theme) override; + virtual void ShowNotification(const char* notification, int duration_ms = 3000) override; + virtual void UpdateStatusBar(bool update_all = false) override; + virtual void SetPowerSaveMode(bool on) override; + virtual void SetPreviewImage(const void* image); + + bool StopAnimDialog(); + bool InsertAnimDialog(const char* emoji_name, uint32_t duration_ms); + + void RefreshAll(); + + // Get emote handle for internal use + emote_handle_t GetEmoteHandle() const { return emote_handle_; } + +private: + virtual bool Lock(int timeout_ms = 0) override; + virtual void Unlock() override; + + emote_handle_t emote_handle_ = nullptr; + +}; + +} // namespace emote diff --git a/main/display/lcd_display.cc b/main/display/lcd_display.cc new file mode 100644 index 0000000..0303d12 --- /dev/null +++ b/main/display/lcd_display.cc @@ -0,0 +1,1361 @@ +#include "lcd_display.h" +#include "assets/lang_config.h" +#include "gif/lvgl_gif.h" +#include "lvgl_theme.h" +#include "settings.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "board.h" + +#define TAG "LcdDisplay" + +LV_FONT_DECLARE(BUILTIN_TEXT_FONT); +LV_FONT_DECLARE(BUILTIN_ICON_FONT); +LV_FONT_DECLARE(font_awesome_30_4); + +void LcdDisplay::InitializeLcdThemes() { + auto text_font = std::make_shared(&BUILTIN_TEXT_FONT); + auto icon_font = std::make_shared(&BUILTIN_ICON_FONT); + auto large_icon_font = std::make_shared(&font_awesome_30_4); + + // light theme + auto light_theme = new LvglTheme("light"); + light_theme->set_background_color(lv_color_hex(0xFFFFFF)); + light_theme->set_text_color(lv_color_hex(0x000000)); + light_theme->set_chat_background_color(lv_color_hex(0xE0E0E0)); + light_theme->set_user_bubble_color(lv_color_hex(0x00FF00)); + light_theme->set_assistant_bubble_color(lv_color_hex(0xDDDDDD)); + light_theme->set_system_bubble_color(lv_color_hex(0xFFFFFF)); + light_theme->set_system_text_color(lv_color_hex(0x000000)); + light_theme->set_border_color(lv_color_hex(0x000000)); + light_theme->set_low_battery_color(lv_color_hex(0x000000)); + light_theme->set_text_font(text_font); + light_theme->set_icon_font(icon_font); + light_theme->set_large_icon_font(large_icon_font); + + // dark theme + auto dark_theme = new LvglTheme("dark"); + dark_theme->set_background_color(lv_color_hex(0x000000)); + dark_theme->set_text_color(lv_color_hex(0xFFFFFF)); + dark_theme->set_chat_background_color(lv_color_hex(0x1F1F1F)); + dark_theme->set_user_bubble_color(lv_color_hex(0x00FF00)); + dark_theme->set_assistant_bubble_color(lv_color_hex(0x222222)); + dark_theme->set_system_bubble_color(lv_color_hex(0x000000)); + dark_theme->set_system_text_color(lv_color_hex(0xFFFFFF)); + dark_theme->set_border_color(lv_color_hex(0xFFFFFF)); + dark_theme->set_low_battery_color(lv_color_hex(0xFF0000)); + dark_theme->set_text_font(text_font); + dark_theme->set_icon_font(icon_font); + dark_theme->set_large_icon_font(large_icon_font); + + auto& theme_manager = LvglThemeManager::GetInstance(); + theme_manager.RegisterTheme("light", light_theme); + theme_manager.RegisterTheme("dark", dark_theme); +} + +LcdDisplay::LcdDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, int width, + int height) + : panel_io_(panel_io), panel_(panel) { + width_ = width; + height_ = height; + + // Initialize LCD themes + InitializeLcdThemes(); + + // Load theme from settings + Settings settings("display", false); + std::string theme_name = settings.GetString("theme", "light"); + current_theme_ = LvglThemeManager::GetInstance().GetTheme(theme_name); + + // Create a timer to hide the preview image + esp_timer_create_args_t preview_timer_args = { + .callback = + [](void* arg) { + LcdDisplay* display = static_cast(arg); + display->SetPreviewImage(nullptr); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "preview_timer", + .skip_unhandled_events = false, + }; + esp_timer_create(&preview_timer_args, &preview_timer_); +} + +SpiLcdDisplay::SpiLcdDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, + int width, int height, int offset_x, int offset_y, bool mirror_x, + bool mirror_y, bool swap_xy) + : LcdDisplay(panel_io, panel, width, height) { + // draw white + std::vector buffer(width_, 0xFFFF); + for (int y = 0; y < height_; y++) { + esp_lcd_panel_draw_bitmap(panel_, 0, y, width_, y + 1, buffer.data()); + } + + // Set the display to on + ESP_LOGI(TAG, "Turning display on"); + { + esp_err_t __err = esp_lcd_panel_disp_on_off(panel_, true); + if (__err == ESP_ERR_NOT_SUPPORTED) { + ESP_LOGW(TAG, "Panel does not support disp_on_off; assuming ON"); + } else { + ESP_ERROR_CHECK(__err); + } + } + + ESP_LOGI(TAG, "Initialize LVGL library"); + lv_init(); + +#if CONFIG_SPIRAM + // lv image cache, currently only PNG is supported + size_t psram_size_mb = esp_psram_get_size() / 1024 / 1024; + if (psram_size_mb >= 8) { + lv_image_cache_resize(2 * 1024 * 1024, true); + ESP_LOGI(TAG, "Use 2MB of PSRAM for image cache"); + } else if (psram_size_mb >= 2) { + lv_image_cache_resize(512 * 1024, true); + ESP_LOGI(TAG, "Use 512KB of PSRAM for image cache"); + } +#endif + + ESP_LOGI(TAG, "Initialize LVGL port"); + lvgl_port_cfg_t port_cfg = ESP_LVGL_PORT_INIT_CONFIG(); + port_cfg.task_priority = 1; +#if CONFIG_SOC_CPU_CORES_NUM > 1 + port_cfg.task_affinity = 1; +#endif + lvgl_port_init(&port_cfg); + + ESP_LOGI(TAG, "Adding LCD display"); + const lvgl_port_display_cfg_t display_cfg = { + .io_handle = panel_io_, + .panel_handle = panel_, + .control_handle = nullptr, + .buffer_size = static_cast(width_ * 20), + .double_buffer = false, + .trans_size = 0, + .hres = static_cast(width_), + .vres = static_cast(height_), + .monochrome = false, + .rotation = + { + .swap_xy = swap_xy, + .mirror_x = mirror_x, + .mirror_y = mirror_y, + }, + .color_format = LV_COLOR_FORMAT_RGB565, + .flags = + { + .buff_dma = 1, + .buff_spiram = 0, + .sw_rotate = 0, + .swap_bytes = 1, + .full_refresh = 0, + .direct_mode = 0, + }, + }; + + display_ = lvgl_port_add_disp(&display_cfg); + if (display_ == nullptr) { + ESP_LOGE(TAG, "Failed to add display"); + return; + } + + if (offset_x != 0 || offset_y != 0) { + lv_display_set_offset(display_, offset_x, offset_y); + } +} + +// RGB LCD implementation +RgbLcdDisplay::RgbLcdDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, + int width, int height, int offset_x, int offset_y, bool mirror_x, + bool mirror_y, bool swap_xy) + : LcdDisplay(panel_io, panel, width, height) { + // draw white + std::vector buffer(width_, 0xFFFF); + for (int y = 0; y < height_; y++) { + esp_lcd_panel_draw_bitmap(panel_, 0, y, width_, y + 1, buffer.data()); + } + + ESP_LOGI(TAG, "Initialize LVGL library"); + lv_init(); + + ESP_LOGI(TAG, "Initialize LVGL port"); + lvgl_port_cfg_t port_cfg = ESP_LVGL_PORT_INIT_CONFIG(); + port_cfg.task_priority = 1; + port_cfg.timer_period_ms = 50; + lvgl_port_init(&port_cfg); + + ESP_LOGI(TAG, "Adding LCD display"); + const lvgl_port_display_cfg_t display_cfg = { + .io_handle = panel_io_, + .panel_handle = panel_, + .buffer_size = static_cast(width_ * 20), + .double_buffer = true, + .hres = static_cast(width_), + .vres = static_cast(height_), + .rotation = + { + .swap_xy = swap_xy, + .mirror_x = mirror_x, + .mirror_y = mirror_y, + }, + .flags = + { + .buff_dma = 1, + .swap_bytes = 0, + .full_refresh = 1, + .direct_mode = 1, + }, + }; + + const lvgl_port_display_rgb_cfg_t rgb_cfg = {.flags = { + .bb_mode = true, + .avoid_tearing = true, + }}; + + display_ = lvgl_port_add_disp_rgb(&display_cfg, &rgb_cfg); + if (display_ == nullptr) { + ESP_LOGE(TAG, "Failed to add RGB display"); + return; + } + + if (offset_x != 0 || offset_y != 0) { + lv_display_set_offset(display_, offset_x, offset_y); + } +} + +MipiLcdDisplay::MipiLcdDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, + int width, int height, int offset_x, int offset_y, bool mirror_x, + bool mirror_y, bool swap_xy) + : LcdDisplay(panel_io, panel, width, height) { + ESP_LOGI(TAG, "Initialize LVGL library"); + lv_init(); + + ESP_LOGI(TAG, "Initialize LVGL port"); + lvgl_port_cfg_t port_cfg = ESP_LVGL_PORT_INIT_CONFIG(); + lvgl_port_init(&port_cfg); + + ESP_LOGI(TAG, "Adding LCD display"); + const lvgl_port_display_cfg_t disp_cfg = { + .io_handle = panel_io, + .panel_handle = panel, + .control_handle = nullptr, + .buffer_size = static_cast(width_ * 50), + .double_buffer = false, + .hres = static_cast(width_), + .vres = static_cast(height_), + .monochrome = false, + /* Rotation values must be same as used in esp_lcd for initial settings of the screen */ + .rotation = + { + .swap_xy = swap_xy, + .mirror_x = mirror_x, + .mirror_y = mirror_y, + }, + .flags = + { + .buff_dma = true, + .buff_spiram = false, + .sw_rotate = true, + }, + }; + + const lvgl_port_display_dsi_cfg_t dpi_cfg = {.flags = { + .avoid_tearing = false, + }}; + display_ = lvgl_port_add_disp_dsi(&disp_cfg, &dpi_cfg); + if (display_ == nullptr) { + ESP_LOGE(TAG, "Failed to add display"); + return; + } + + if (offset_x != 0 || offset_y != 0) { + lv_display_set_offset(display_, offset_x, offset_y); + } +} + +LcdDisplay::~LcdDisplay() { + SetPreviewImage(nullptr); + + // Clean up GIF controller + if (gif_controller_) { + gif_controller_->Stop(); + gif_controller_.reset(); + } + + if (preview_timer_ != nullptr) { + esp_timer_stop(preview_timer_); + esp_timer_delete(preview_timer_); + } + + if (preview_image_ != nullptr) { + lv_obj_del(preview_image_); + } + if (chat_message_label_ != nullptr) { + lv_obj_del(chat_message_label_); + } + if (emoji_label_ != nullptr) { + lv_obj_del(emoji_label_); + } + if (emoji_image_ != nullptr) { + lv_obj_del(emoji_image_); + } + if (emoji_box_ != nullptr) { + lv_obj_del(emoji_box_); + } + if (content_ != nullptr) { + lv_obj_del(content_); + } + if (bottom_bar_ != nullptr) { + lv_obj_del(bottom_bar_); + } + if (status_bar_ != nullptr) { + lv_obj_del(status_bar_); + status_bar_ = nullptr; + status_label_ = nullptr; + time_label_ = nullptr; + notification_label_ = nullptr; + mute_label_ = nullptr; + } + if (top_bar_ != nullptr) { + lv_obj_del(top_bar_); + top_bar_ = nullptr; + network_label_ = nullptr; + battery_label_ = nullptr; + } + if (side_bar_ != nullptr) { + lv_obj_del(side_bar_); + } + if (container_ != nullptr) { + lv_obj_del(container_); + } + if (display_ != nullptr) { + lv_display_delete(display_); + } + + if (panel_ != nullptr) { + esp_lcd_panel_del(panel_); + } + if (panel_io_ != nullptr) { + esp_lcd_panel_io_del(panel_io_); + } +} + +bool LcdDisplay::Lock(int timeout_ms) { return lvgl_port_lock(timeout_ms); } + +void LcdDisplay::Unlock() { lvgl_port_unlock(); } + +#if CONFIG_USE_WECHAT_MESSAGE_STYLE +void LcdDisplay::SetupUI() { + // Prevent duplicate calls - if already called, return early + if (setup_ui_called_) { + ESP_LOGW(TAG, "SetupUI() called multiple times, skipping duplicate call"); + return; + } + + Display::SetupUI(); // Mark SetupUI as called + DisplayLockGuard lock(this); + + auto lvgl_theme = static_cast(current_theme_); + auto text_font = lvgl_theme->text_font()->font(); + auto icon_font = lvgl_theme->icon_font()->font(); + auto large_icon_font = lvgl_theme->large_icon_font()->font(); + + auto screen = lv_screen_active(); + lv_obj_set_style_text_font(screen, text_font, 0); + lv_obj_set_style_text_color(screen, lvgl_theme->text_color(), 0); + lv_obj_set_style_bg_color(screen, lvgl_theme->background_color(), 0); + + /* Container */ + container_ = lv_obj_create(screen); + lv_obj_set_size(container_, LV_HOR_RES, LV_VER_RES); + lv_obj_set_style_radius(container_, 0, 0); + lv_obj_set_flex_flow(container_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(container_, 0, 0); + lv_obj_set_style_border_width(container_, 0, 0); + lv_obj_set_style_pad_row(container_, 0, 0); + lv_obj_set_style_bg_color(container_, lvgl_theme->background_color(), 0); + lv_obj_set_style_border_color(container_, lvgl_theme->border_color(), 0); + + /* Layer 1: Top bar - for status icons */ + top_bar_ = lv_obj_create(container_); + lv_obj_set_size(top_bar_, LV_HOR_RES, LV_SIZE_CONTENT); + lv_obj_set_style_radius(top_bar_, 0, 0); + lv_obj_set_style_bg_opa(top_bar_, LV_OPA_50, 0); // 50% opacity background + lv_obj_set_style_bg_color(top_bar_, lvgl_theme->background_color(), 0); + lv_obj_set_style_border_width(top_bar_, 0, 0); + lv_obj_set_style_pad_all(top_bar_, 0, 0); + lv_obj_set_style_pad_top(top_bar_, lvgl_theme->spacing(2), 0); + lv_obj_set_style_pad_bottom(top_bar_, lvgl_theme->spacing(2), 0); + lv_obj_set_style_pad_left(top_bar_, lvgl_theme->spacing(4), 0); + lv_obj_set_style_pad_right(top_bar_, lvgl_theme->spacing(4), 0); + lv_obj_set_flex_flow(top_bar_, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(top_bar_, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_scrollbar_mode(top_bar_, LV_SCROLLBAR_MODE_OFF); + + network_label_ = lv_label_create(top_bar_); + lv_label_set_text(network_label_, ""); + lv_obj_set_style_text_font(network_label_, icon_font, 0); + lv_obj_set_style_text_color(network_label_, lvgl_theme->text_color(), 0); + + // Battery stays at the top. + lv_obj_t* right_icons = lv_obj_create(top_bar_); + lv_obj_set_size(right_icons, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(right_icons, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(right_icons, 0, 0); + lv_obj_set_style_pad_all(right_icons, 0, 0); + lv_obj_set_flex_flow(right_icons, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(right_icons, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + + battery_label_ = lv_label_create(right_icons); + lv_label_set_text(battery_label_, ""); + lv_obj_set_style_text_font(battery_label_, icon_font, 0); + lv_obj_set_style_text_color(battery_label_, lvgl_theme->text_color(), 0); + + /* Layer 2: Bottom status bar - for all status items except battery */ + status_bar_ = lv_obj_create(screen); + lv_obj_set_size(status_bar_, LV_HOR_RES, text_font->line_height + lvgl_theme->spacing(4)); + lv_obj_set_style_radius(status_bar_, 0, 0); + lv_obj_set_style_bg_opa(status_bar_, LV_OPA_50, 0); + lv_obj_set_style_bg_color(status_bar_, lvgl_theme->background_color(), 0); + lv_obj_set_style_border_width(status_bar_, 0, 0); + lv_obj_set_style_pad_all(status_bar_, 0, 0); + lv_obj_set_scrollbar_mode(status_bar_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_layout(status_bar_, LV_LAYOUT_NONE, 0); // Use absolute positioning + lv_obj_align(status_bar_, LV_ALIGN_BOTTOM_MID, 0, 0); + + mute_label_ = lv_label_create(status_bar_); + lv_label_set_text(mute_label_, ""); + lv_obj_set_style_text_font(mute_label_, icon_font, 0); + lv_obj_set_style_text_color(mute_label_, lvgl_theme->text_color(), 0); + lv_obj_align(mute_label_, LV_ALIGN_LEFT_MID, lvgl_theme->spacing(4), 0); + + notification_label_ = lv_label_create(status_bar_); + lv_obj_set_width(notification_label_, LV_HOR_RES * 0.5); + lv_obj_set_style_text_align(notification_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(notification_label_, lvgl_theme->text_color(), 0); + lv_label_set_text(notification_label_, ""); + lv_obj_align(notification_label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_flag(notification_label_, LV_OBJ_FLAG_HIDDEN); + + status_label_ = lv_label_create(status_bar_); + lv_obj_set_width(status_label_, LV_HOR_RES * 0.5); + lv_label_set_long_mode(status_label_, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_align(status_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(status_label_, lvgl_theme->text_color(), 0); + lv_label_set_text(status_label_, Lang::Strings::INITIALIZING); + lv_obj_align(status_label_, LV_ALIGN_CENTER, 0, 0); + + /* Content - Chat area */ + content_ = lv_obj_create(container_); + lv_obj_set_style_radius(content_, 0, 0); + lv_obj_set_width(content_, LV_HOR_RES); + lv_obj_set_flex_grow(content_, 1); + lv_obj_set_style_pad_all(content_, lvgl_theme->spacing(4), 0); + lv_obj_set_style_border_width(content_, 0, 0); + lv_obj_set_style_bg_color(content_, lvgl_theme->chat_background_color(), + 0); // Background for chat area + + // Enable scrolling for chat content + lv_obj_set_scrollbar_mode(content_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_scroll_dir(content_, LV_DIR_VER); + + // Create a flex container for chat messages + lv_obj_set_flex_flow(content_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_flex_align(content_, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START, LV_FLEX_ALIGN_START); + lv_obj_set_style_pad_row(content_, lvgl_theme->spacing(4), 0); // Space between messages + + // We'll create chat messages dynamically in SetChatMessage + chat_message_label_ = nullptr; + + time_label_ = lv_label_create(status_bar_); + lv_obj_set_style_text_color(time_label_, lvgl_theme->text_color(), 0); + lv_label_set_text(time_label_, ""); + lv_obj_align(time_label_, LV_ALIGN_RIGHT_MID, -lvgl_theme->spacing(4), 0); + lv_obj_add_flag(time_label_, LV_OBJ_FLAG_HIDDEN); + + low_battery_popup_ = lv_obj_create(screen); + lv_obj_set_scrollbar_mode(low_battery_popup_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_size(low_battery_popup_, LV_HOR_RES * 0.9, text_font->line_height * 2); + lv_obj_align(low_battery_popup_, LV_ALIGN_BOTTOM_MID, 0, -lvgl_theme->spacing(4)); + lv_obj_set_style_bg_color(low_battery_popup_, lvgl_theme->low_battery_color(), 0); + lv_obj_set_style_radius(low_battery_popup_, lvgl_theme->spacing(4), 0); + low_battery_label_ = lv_label_create(low_battery_popup_); + lv_label_set_text(low_battery_label_, Lang::Strings::BATTERY_NEED_CHARGE); + lv_obj_set_style_text_color(low_battery_label_, lv_color_white(), 0); + lv_obj_center(low_battery_label_); + lv_obj_add_flag(low_battery_popup_, LV_OBJ_FLAG_HIDDEN); + + emoji_image_ = lv_img_create(screen); + lv_obj_align(emoji_image_, LV_ALIGN_TOP_MID, 0, + text_font->line_height + lvgl_theme->spacing(8)); + + // Display AI logo while booting + emoji_label_ = lv_label_create(screen); + lv_obj_center(emoji_label_); + lv_obj_set_style_text_font(emoji_label_, large_icon_font, 0); + lv_obj_set_style_text_color(emoji_label_, lvgl_theme->text_color(), 0); + lv_label_set_text(emoji_label_, FONT_AWESOME_MICROCHIP_AI); +} +#if CONFIG_IDF_TARGET_ESP32P4 +#define MAX_MESSAGES 40 +#else +#define MAX_MESSAGES 20 +#endif +void LcdDisplay::SetChatMessage(const char* role, const char* content) { + if (!setup_ui_called_) { + ESP_LOGW(TAG, "SetChatMessage('%s', '%s') called before SetupUI() - message will be lost!", + role, content); + } + DisplayLockGuard lock(this); + if (content_ == nullptr) { + if (setup_ui_called_) { + ESP_LOGW(TAG, + "SetChatMessage('%s', '%s') failed: content_ is nullptr (SetupUI() was called " + "but container not created)", + role, content); + } + return; + } + + // Check if message count exceeds limit + uint32_t child_count = lv_obj_get_child_cnt(content_); + if (child_count >= MAX_MESSAGES) { + // Delete the oldest message (first child object) + lv_obj_t* first_child = lv_obj_get_child(content_, 0); + if (first_child != nullptr) { + lv_obj_del(first_child); + // Refresh child count after deletion + child_count = lv_obj_get_child_cnt(content_); + } + // Scroll to the last message immediately (get last_child after deletion) + if (child_count > 0) { + lv_obj_t* last_child = lv_obj_get_child(content_, child_count - 1); + if (last_child != nullptr && lv_obj_is_valid(last_child)) { + lv_obj_scroll_to_view_recursive(last_child, LV_ANIM_OFF); + } + } + } + + // Collapse system messages (if it's a system message, check if the last message is also a + // system message) + if (strcmp(role, "system") == 0) { + // Refresh child count to get accurate count after potential deletion above + child_count = lv_obj_get_child_cnt(content_); + if (child_count > 0) { + // Get the last message container + lv_obj_t* last_container = lv_obj_get_child(content_, child_count - 1); + if (last_container != nullptr && lv_obj_is_valid(last_container) && + lv_obj_get_child_cnt(last_container) > 0) { + // Get the bubble inside the container + lv_obj_t* last_bubble = lv_obj_get_child(last_container, 0); + if (last_bubble != nullptr && lv_obj_is_valid(last_bubble)) { + // Check if bubble type is system message + void* bubble_type_ptr = lv_obj_get_user_data(last_bubble); + if (bubble_type_ptr != nullptr && + strcmp((const char*)bubble_type_ptr, "system") == 0) { + // If the last message is also a system message, delete it + lv_obj_del(last_container); + } + } + } + } + } else { + // Hide the centered AI logo + lv_obj_add_flag(emoji_label_, LV_OBJ_FLAG_HIDDEN); + } + + // Avoid empty message boxes + if (strlen(content) == 0) { + return; + } + + auto lvgl_theme = static_cast(current_theme_); + + // Create a message bubble + lv_obj_t* msg_bubble = lv_obj_create(content_); + lv_obj_set_style_radius(msg_bubble, 8, 0); + lv_obj_set_scrollbar_mode(msg_bubble, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_border_width(msg_bubble, 0, 0); + lv_obj_set_style_pad_all(msg_bubble, lvgl_theme->spacing(4), 0); + + // Create the message text + lv_obj_t* msg_text = lv_label_create(msg_bubble); + lv_label_set_text(msg_text, content); + + // Calculate bubble width constraints + lv_coord_t max_width = LV_HOR_RES * 85 / 100 - 16; // 85% of screen width + lv_coord_t min_width = 20; + + // Let LVGL calculate the natural text width first + lv_obj_set_width(msg_text, LV_SIZE_CONTENT); + lv_obj_update_layout(msg_text); + lv_coord_t text_width = lv_obj_get_width(msg_text); + + // Ensure text width is not less than minimum width + if (text_width < min_width) { + text_width = min_width; + } + + // Constrain to max width + lv_coord_t bubble_width = (text_width < max_width) ? text_width : max_width; + + // Set message text width + lv_obj_set_width(msg_text, bubble_width); + lv_label_set_long_mode(msg_text, LV_LABEL_LONG_WRAP); + + // Set bubble width + lv_obj_set_width(msg_bubble, bubble_width); + lv_obj_set_height(msg_bubble, LV_SIZE_CONTENT); + + // Set alignment and style based on message role + if (strcmp(role, "user") == 0) { + // User messages are right-aligned with green background + lv_obj_set_style_bg_color(msg_bubble, lvgl_theme->user_bubble_color(), 0); + lv_obj_set_style_bg_opa(msg_bubble, LV_OPA_70, 0); + // Set text color for contrast + lv_obj_set_style_text_color(msg_text, lvgl_theme->text_color(), 0); + + // Set custom attribute to mark bubble type + lv_obj_set_user_data(msg_bubble, (void*)"user"); + + // Set appropriate width for content + lv_obj_set_width(msg_bubble, LV_SIZE_CONTENT); + lv_obj_set_height(msg_bubble, LV_SIZE_CONTENT); + + // Don't grow + lv_obj_set_style_flex_grow(msg_bubble, 0, 0); + } else if (strcmp(role, "assistant") == 0) { + // Assistant messages are left-aligned with white background + lv_obj_set_style_bg_color(msg_bubble, lvgl_theme->assistant_bubble_color(), 0); + lv_obj_set_style_bg_opa(msg_bubble, LV_OPA_70, 0); + // Set text color for contrast + lv_obj_set_style_text_color(msg_text, lvgl_theme->text_color(), 0); + + // Set custom attribute to mark bubble type + lv_obj_set_user_data(msg_bubble, (void*)"assistant"); + + // Set appropriate width for content + lv_obj_set_width(msg_bubble, LV_SIZE_CONTENT); + lv_obj_set_height(msg_bubble, LV_SIZE_CONTENT); + + // Don't grow + lv_obj_set_style_flex_grow(msg_bubble, 0, 0); + } else if (strcmp(role, "system") == 0) { + // System messages are center-aligned with light gray background + lv_obj_set_style_bg_color(msg_bubble, lvgl_theme->system_bubble_color(), 0); + lv_obj_set_style_bg_opa(msg_bubble, LV_OPA_70, 0); + // Set text color for contrast + lv_obj_set_style_text_color(msg_text, lvgl_theme->system_text_color(), 0); + + // Set custom attribute to mark bubble type + lv_obj_set_user_data(msg_bubble, (void*)"system"); + + // Set appropriate width for content + lv_obj_set_width(msg_bubble, LV_SIZE_CONTENT); + lv_obj_set_height(msg_bubble, LV_SIZE_CONTENT); + + // Don't grow + lv_obj_set_style_flex_grow(msg_bubble, 0, 0); + } + + // Create a full-width container for user messages to ensure right alignment + if (strcmp(role, "user") == 0) { + // Create a full-width container + lv_obj_t* container = lv_obj_create(content_); + lv_obj_set_width(container, LV_HOR_RES); + lv_obj_set_height(container, LV_SIZE_CONTENT); + + // Make container transparent and borderless + lv_obj_set_style_bg_opa(container, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(container, 0, 0); + lv_obj_set_style_pad_all(container, 0, 0); + + // Move the message bubble into this container + lv_obj_set_parent(msg_bubble, container); + + // Right align the bubble in the container + lv_obj_align(msg_bubble, LV_ALIGN_RIGHT_MID, -25, 0); + + // Auto-scroll to this container + lv_obj_scroll_to_view_recursive(container, LV_ANIM_ON); + } else if (strcmp(role, "system") == 0) { + // Create full-width container for system messages to ensure center alignment + lv_obj_t* container = lv_obj_create(content_); + lv_obj_set_width(container, LV_HOR_RES); + lv_obj_set_height(container, LV_SIZE_CONTENT); + + lv_obj_set_style_bg_opa(container, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(container, 0, 0); + lv_obj_set_style_pad_all(container, 0, 0); + + lv_obj_set_parent(msg_bubble, container); + lv_obj_align(msg_bubble, LV_ALIGN_CENTER, 0, 0); + lv_obj_scroll_to_view_recursive(container, LV_ANIM_ON); + } else { + // For assistant messages + // Left align assistant messages + lv_obj_align(msg_bubble, LV_ALIGN_LEFT_MID, 0, 0); + + // Auto-scroll to the message bubble + lv_obj_scroll_to_view_recursive(msg_bubble, LV_ANIM_ON); + } + + // Store reference to the latest message label + chat_message_label_ = msg_text; +} + +void LcdDisplay::SetPreviewImage(std::unique_ptr image) { + DisplayLockGuard lock(this); + if (content_ == nullptr) { + return; + } + + if (image == nullptr) { + return; + } + + auto lvgl_theme = static_cast(current_theme_); + // Create a message bubble for image preview + lv_obj_t* img_bubble = lv_obj_create(content_); + lv_obj_set_style_radius(img_bubble, 8, 0); + lv_obj_set_scrollbar_mode(img_bubble, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_border_width(img_bubble, 0, 0); + lv_obj_set_style_pad_all(img_bubble, lvgl_theme->spacing(4), 0); + + // Set image bubble background color (similar to system message) + lv_obj_set_style_bg_color(img_bubble, lvgl_theme->assistant_bubble_color(), 0); + lv_obj_set_style_bg_opa(img_bubble, LV_OPA_70, 0); + + // Set custom attribute to mark bubble type + lv_obj_set_user_data(img_bubble, (void*)"image"); + + // Create the image object inside the bubble + lv_obj_t* preview_image = lv_image_create(img_bubble); + + // Calculate appropriate size for the image + lv_coord_t max_width = LV_HOR_RES * 70 / 100; // 70% of screen width + lv_coord_t max_height = LV_VER_RES * 50 / 100; // 50% of screen height + + // Calculate zoom factor to fit within maximum dimensions + auto img_dsc = image->image_dsc(); + lv_coord_t img_width = img_dsc->header.w; + lv_coord_t img_height = img_dsc->header.h; + if (img_width == 0 || img_height == 0) { + img_width = max_width; + img_height = max_height; + ESP_LOGW(TAG, "Invalid image dimensions: %ld x %ld, using default dimensions: %ld x %ld", + img_width, img_height, max_width, max_height); + } + + lv_coord_t zoom_w = (max_width * 256) / img_width; + lv_coord_t zoom_h = (max_height * 256) / img_height; + lv_coord_t zoom = (zoom_w < zoom_h) ? zoom_w : zoom_h; + + // Ensure zoom doesn't exceed 256 (100%) + if (zoom > 256) + zoom = 256; + + // Set image properties + lv_image_set_src(preview_image, img_dsc); + lv_image_set_scale(preview_image, zoom); + + // Add event handler to clean up LvglImage when image is deleted + // We need to transfer ownership of the unique_ptr to the event callback + LvglImage* raw_image = image.release(); // Release ownership of smart pointer + lv_obj_add_event_cb( + preview_image, + [](lv_event_t* e) { + LvglImage* img = (LvglImage*)lv_event_get_user_data(e); + if (img != nullptr) { + delete img; // Properly release memory by deleting LvglImage object + } + }, + LV_EVENT_DELETE, (void*)raw_image); + + // Calculate actual scaled image dimensions + lv_coord_t scaled_width = (img_width * zoom) / 256; + lv_coord_t scaled_height = (img_height * zoom) / 256; + + // Set bubble size to be 16 pixels larger than the image (8 pixels on each side) + lv_obj_set_width(img_bubble, scaled_width + 16); + lv_obj_set_height(img_bubble, scaled_height + 16); + + // Don't grow in flex layout + lv_obj_set_style_flex_grow(img_bubble, 0, 0); + + // Center the image within the bubble + lv_obj_center(preview_image); + + // Left align the image bubble like assistant messages + lv_obj_align(img_bubble, LV_ALIGN_LEFT_MID, 0, 0); + + // Auto-scroll to the image bubble + lv_obj_scroll_to_view_recursive(img_bubble, LV_ANIM_ON); +} + +void LcdDisplay::ClearChatMessages() { + DisplayLockGuard lock(this); + if (content_ == nullptr) { + return; + } + + // Use lv_obj_clean to delete all children of content_ (chat message bubbles) + lv_obj_clean(content_); + + // Reset chat_message_label_ as it has been deleted + chat_message_label_ = nullptr; + + // Show the centered AI logo (emoji_label_) again + if (emoji_label_ != nullptr) { + lv_obj_remove_flag(emoji_label_, LV_OBJ_FLAG_HIDDEN); + } + + ESP_LOGI(TAG, "Chat messages cleared"); +} +#else +void LcdDisplay::SetupUI() { + // Prevent duplicate calls - if already called, return early + if (setup_ui_called_) { + ESP_LOGW(TAG, "SetupUI() called multiple times, skipping duplicate call"); + return; + } + + Display::SetupUI(); // Mark SetupUI as called + DisplayLockGuard lock(this); + LvglTheme* lvgl_theme = static_cast(current_theme_); + auto text_font = lvgl_theme->text_font()->font(); + auto icon_font = lvgl_theme->icon_font()->font(); + auto large_icon_font = lvgl_theme->large_icon_font()->font(); + + auto screen = lv_screen_active(); + lv_obj_set_style_text_font(screen, text_font, 0); + lv_obj_set_style_text_color(screen, lvgl_theme->text_color(), 0); + lv_obj_set_style_bg_color(screen, lvgl_theme->background_color(), 0); + + /* Container - used as background */ + container_ = lv_obj_create(screen); + lv_obj_set_size(container_, LV_HOR_RES, LV_VER_RES); + lv_obj_set_style_radius(container_, 0, 0); + lv_obj_set_style_pad_all(container_, 0, 0); + lv_obj_set_style_border_width(container_, 0, 0); + lv_obj_set_style_bg_color(container_, lvgl_theme->background_color(), 0); + lv_obj_set_style_border_color(container_, lvgl_theme->border_color(), 0); + + /* Bottom layer: emoji_box_ - centered display */ + emoji_box_ = lv_obj_create(screen); + lv_obj_set_size(emoji_box_, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(emoji_box_, LV_OPA_TRANSP, 0); + lv_obj_set_style_pad_all(emoji_box_, 0, 0); + lv_obj_set_style_border_width(emoji_box_, 0, 0); + lv_obj_align(emoji_box_, LV_ALIGN_CENTER, 0, 0); + + emoji_label_ = lv_label_create(emoji_box_); + lv_obj_set_style_text_font(emoji_label_, large_icon_font, 0); + lv_obj_set_style_text_color(emoji_label_, lvgl_theme->text_color(), 0); + lv_label_set_text(emoji_label_, FONT_AWESOME_MICROCHIP_AI); + + emoji_image_ = lv_img_create(emoji_box_); + lv_obj_center(emoji_image_); + lv_obj_add_flag(emoji_image_, LV_OBJ_FLAG_HIDDEN); + + /* Middle layer: preview_image_ - centered display */ + preview_image_ = lv_image_create(screen); + lv_obj_set_size(preview_image_, width_ / 2, height_ / 2); + lv_obj_align(preview_image_, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_flag(preview_image_, LV_OBJ_FLAG_HIDDEN); + + /* Layer 1: Top bar - for status icons */ + top_bar_ = lv_obj_create(screen); + lv_obj_set_size(top_bar_, LV_HOR_RES, LV_SIZE_CONTENT); + lv_obj_set_style_radius(top_bar_, 0, 0); + lv_obj_set_style_bg_opa(top_bar_, LV_OPA_50, 0); // 50% opacity background + lv_obj_set_style_bg_color(top_bar_, lvgl_theme->background_color(), 0); + lv_obj_set_style_border_width(top_bar_, 0, 0); + lv_obj_set_style_pad_all(top_bar_, 0, 0); + lv_obj_set_style_pad_top(top_bar_, lvgl_theme->spacing(2), 0); + lv_obj_set_style_pad_bottom(top_bar_, lvgl_theme->spacing(2), 0); + lv_obj_set_style_pad_left(top_bar_, lvgl_theme->spacing(4), 0); + lv_obj_set_style_pad_right(top_bar_, lvgl_theme->spacing(4), 0); + lv_obj_set_flex_flow(top_bar_, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(top_bar_, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + lv_obj_set_scrollbar_mode(top_bar_, LV_SCROLLBAR_MODE_OFF); + lv_obj_align(top_bar_, LV_ALIGN_TOP_MID, 0, 0); + + network_label_ = lv_label_create(top_bar_); + lv_label_set_text(network_label_, ""); + lv_obj_set_style_text_font(network_label_, icon_font, 0); + lv_obj_set_style_text_color(network_label_, lvgl_theme->text_color(), 0); + + // Battery stays at the top. + lv_obj_t* right_icons = lv_obj_create(top_bar_); + lv_obj_set_size(right_icons, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(right_icons, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(right_icons, 0, 0); + lv_obj_set_style_pad_all(right_icons, 0, 0); + lv_obj_set_flex_flow(right_icons, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(right_icons, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, + LV_FLEX_ALIGN_CENTER); + + battery_label_ = lv_label_create(right_icons); + lv_label_set_text(battery_label_, ""); + lv_obj_set_style_text_font(battery_label_, icon_font, 0); + lv_obj_set_style_text_color(battery_label_, lvgl_theme->text_color(), 0); + + /* Layer 2: Bottom status bar - for all status items except battery */ + status_bar_ = lv_obj_create(screen); + lv_obj_set_size(status_bar_, LV_HOR_RES, text_font->line_height + lvgl_theme->spacing(4)); + lv_obj_set_style_radius(status_bar_, 0, 0); + lv_obj_set_style_bg_opa(status_bar_, LV_OPA_50, 0); + lv_obj_set_style_bg_color(status_bar_, lvgl_theme->background_color(), 0); + lv_obj_set_style_border_width(status_bar_, 0, 0); + lv_obj_set_style_pad_all(status_bar_, 0, 0); + lv_obj_set_scrollbar_mode(status_bar_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_layout(status_bar_, LV_LAYOUT_NONE, 0); // Use absolute positioning + lv_obj_align(status_bar_, LV_ALIGN_BOTTOM_MID, 0, 0); + + mute_label_ = lv_label_create(status_bar_); + lv_label_set_text(mute_label_, ""); + lv_obj_set_style_text_font(mute_label_, icon_font, 0); + lv_obj_set_style_text_color(mute_label_, lvgl_theme->text_color(), 0); + lv_obj_align(mute_label_, LV_ALIGN_LEFT_MID, lvgl_theme->spacing(4), 0); + + notification_label_ = lv_label_create(status_bar_); + lv_obj_set_width(notification_label_, LV_HOR_RES * 0.5); + lv_obj_set_style_text_align(notification_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(notification_label_, lvgl_theme->text_color(), 0); + lv_label_set_text(notification_label_, ""); + lv_obj_align(notification_label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_flag(notification_label_, LV_OBJ_FLAG_HIDDEN); + + status_label_ = lv_label_create(status_bar_); + lv_obj_set_width(status_label_, LV_HOR_RES * 0.5); + lv_label_set_long_mode(status_label_, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_align(status_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(status_label_, lvgl_theme->text_color(), 0); + lv_label_set_text(status_label_, Lang::Strings::INITIALIZING); + lv_obj_align(status_label_, LV_ALIGN_CENTER, 0, 0); + +#if CONFIG_USE_MULTILINE_CHAT_MESSAGE + /* Bottom bar - auto height, grows upward with wrapped text */ + bottom_bar_ = lv_obj_create(screen); + lv_obj_set_width(bottom_bar_, LV_HOR_RES); + lv_obj_set_height(bottom_bar_, LV_SIZE_CONTENT); + lv_obj_set_style_radius(bottom_bar_, 0, 0); + lv_obj_set_style_bg_color(bottom_bar_, lvgl_theme->background_color(), 0); + lv_obj_set_style_bg_opa(bottom_bar_, LV_OPA_50, 0); + lv_obj_set_style_text_color(bottom_bar_, lvgl_theme->text_color(), 0); + lv_obj_set_style_pad_all(bottom_bar_, lvgl_theme->spacing(4), 0); + lv_obj_set_style_border_width(bottom_bar_, 0, 0); + lv_obj_set_scrollbar_mode(bottom_bar_, LV_SCROLLBAR_MODE_OFF); + lv_obj_align(bottom_bar_, LV_ALIGN_BOTTOM_MID, 0, + -(text_font->line_height + lvgl_theme->spacing(4))); + + /* chat_message_label_ placed in bottom_bar_, multiline wrapped display */ + chat_message_label_ = lv_label_create(bottom_bar_); + lv_label_set_text(chat_message_label_, ""); + lv_obj_set_width(chat_message_label_, LV_HOR_RES - lvgl_theme->spacing(8)); + lv_label_set_long_mode(chat_message_label_, LV_LABEL_LONG_WRAP); + lv_obj_set_style_text_align(chat_message_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(chat_message_label_, lvgl_theme->text_color(), 0); + lv_obj_align(chat_message_label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_flag(bottom_bar_, LV_OBJ_FLAG_HIDDEN); // Hide until there is content +#else + /* Top layer: Bottom bar - fixed height at bottom */ + bottom_bar_ = lv_obj_create(screen); + lv_obj_set_size(bottom_bar_, LV_HOR_RES, text_font->line_height + lvgl_theme->spacing(8)); + lv_obj_set_style_radius(bottom_bar_, 0, 0); + lv_obj_set_style_bg_color(bottom_bar_, lvgl_theme->background_color(), 0); + lv_obj_set_style_text_color(bottom_bar_, lvgl_theme->text_color(), 0); + lv_obj_set_style_pad_all(bottom_bar_, 0, 0); + lv_obj_set_style_pad_left(bottom_bar_, lvgl_theme->spacing(4), 0); + lv_obj_set_style_pad_right(bottom_bar_, lvgl_theme->spacing(4), 0); + lv_obj_set_style_border_width(bottom_bar_, 0, 0); + lv_obj_set_scrollbar_mode(bottom_bar_, LV_SCROLLBAR_MODE_OFF); + lv_obj_align(bottom_bar_, LV_ALIGN_BOTTOM_MID, 0, + -(text_font->line_height + lvgl_theme->spacing(4))); + + /* chat_message_label_ placed in bottom_bar_, single-line horizontal scroll */ + chat_message_label_ = lv_label_create(bottom_bar_); + lv_label_set_text(chat_message_label_, ""); + lv_obj_set_width(chat_message_label_, LV_HOR_RES - lvgl_theme->spacing(8)); + lv_label_set_long_mode(chat_message_label_, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_align(chat_message_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_obj_set_style_text_color(chat_message_label_, lvgl_theme->text_color(), 0); + lv_obj_align(chat_message_label_, LV_ALIGN_CENTER, 0, 0); + + // Start scrolling after a delay (short text won't scroll) + static lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_delay(&a, 1000); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_obj_set_style_anim(chat_message_label_, &a, LV_PART_MAIN); + lv_obj_set_style_anim_duration(chat_message_label_, lv_anim_speed_clamped(60, 300, 60000), + LV_PART_MAIN); + lv_obj_add_flag(bottom_bar_, LV_OBJ_FLAG_HIDDEN); // Hide until there is content +#endif + + time_label_ = lv_label_create(status_bar_); + lv_obj_set_style_text_color(time_label_, lvgl_theme->text_color(), 0); + lv_label_set_text(time_label_, ""); + lv_obj_align(time_label_, LV_ALIGN_RIGHT_MID, -lvgl_theme->spacing(4), 0); + lv_obj_add_flag(time_label_, LV_OBJ_FLAG_HIDDEN); + + low_battery_popup_ = lv_obj_create(screen); + lv_obj_set_scrollbar_mode(low_battery_popup_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_size(low_battery_popup_, LV_HOR_RES * 0.9, text_font->line_height * 2); + lv_obj_align(low_battery_popup_, LV_ALIGN_BOTTOM_MID, 0, -lvgl_theme->spacing(4)); + lv_obj_set_style_bg_color(low_battery_popup_, lvgl_theme->low_battery_color(), 0); + lv_obj_set_style_radius(low_battery_popup_, lvgl_theme->spacing(4), 0); + + low_battery_label_ = lv_label_create(low_battery_popup_); + lv_label_set_text(low_battery_label_, Lang::Strings::BATTERY_NEED_CHARGE); + lv_obj_set_style_text_color(low_battery_label_, lv_color_white(), 0); + lv_obj_center(low_battery_label_); + lv_obj_add_flag(low_battery_popup_, LV_OBJ_FLAG_HIDDEN); +} + +void LcdDisplay::SetPreviewImage(std::unique_ptr image) { + DisplayLockGuard lock(this); + if (preview_image_ == nullptr) { + ESP_LOGE(TAG, "Preview image is not initialized"); + return; + } + + if (image == nullptr) { + esp_timer_stop(preview_timer_); + lv_obj_remove_flag(emoji_box_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(preview_image_, LV_OBJ_FLAG_HIDDEN); + preview_image_cached_.reset(); + if (gif_controller_) { + gif_controller_->Start(); + } + return; + } + + preview_image_cached_ = std::move(image); + auto img_dsc = preview_image_cached_->image_dsc(); + lv_image_set_src(preview_image_, img_dsc); + if (img_dsc->header.w > 0 && img_dsc->header.h > 0) { + // zoom factor 0.5 + lv_image_set_scale(preview_image_, 128 * width_ / img_dsc->header.w); + } + + // Hide emoji_box_ + if (gif_controller_) { + gif_controller_->Stop(); + } + lv_obj_add_flag(emoji_box_, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(preview_image_, LV_OBJ_FLAG_HIDDEN); + esp_timer_stop(preview_timer_); + ESP_ERROR_CHECK(esp_timer_start_once(preview_timer_, PREVIEW_IMAGE_DURATION_MS * 1000)); +} + +void LcdDisplay::SetChatMessage(const char* role, const char* content) { + if (!setup_ui_called_) { + ESP_LOGW(TAG, "SetChatMessage('%s', '%s') called before SetupUI() - message will be lost!", + role, content); + } + DisplayLockGuard lock(this); + if (chat_message_label_ == nullptr) { + if (setup_ui_called_) { + ESP_LOGW(TAG, + "SetChatMessage('%s', '%s') failed: chat_message_label_ is nullptr (SetupUI() " + "was called but label not created)", + role, content); + } + return; + } + lv_label_set_text(chat_message_label_, content); + // Show bottom_bar_ only when there is content (and subtitle is not globally hidden) + if (bottom_bar_ != nullptr) { + if (content == nullptr || content[0] == '\0') { + lv_obj_add_flag(bottom_bar_, LV_OBJ_FLAG_HIDDEN); + } else if (!hide_subtitle_) { + lv_obj_remove_flag(bottom_bar_, LV_OBJ_FLAG_HIDDEN); + } + } +#if CONFIG_USE_MULTILINE_CHAT_MESSAGE + // Re-align bottom_bar_ after text change so it stays anchored to the bottom + // as its height adapts to the wrapped content. + if (bottom_bar_ != nullptr) { + auto lvgl_theme = static_cast(current_theme_); + auto text_font = lvgl_theme->text_font()->font(); + lv_obj_align(bottom_bar_, LV_ALIGN_BOTTOM_MID, 0, + -(text_font->line_height + lvgl_theme->spacing(4))); + } +#endif +} + +void LcdDisplay::ClearChatMessages() { + DisplayLockGuard lock(this); + // In non-wechat mode, just clear the chat message label and hide the bar + if (chat_message_label_ != nullptr) { + lv_label_set_text(chat_message_label_, ""); + } + if (bottom_bar_ != nullptr) { + lv_obj_add_flag(bottom_bar_, LV_OBJ_FLAG_HIDDEN); + } +} +#endif + +void LcdDisplay::SetEmotion(const char* emotion) { + if (!setup_ui_called_) { + ESP_LOGW(TAG, "SetEmotion('%s') called before SetupUI() - emotion will not be displayed!", + emotion); + } + // Stop any running GIF animation + if (gif_controller_) { + DisplayLockGuard lock(this); + gif_controller_->Stop(); + gif_controller_.reset(); + } + + if (emoji_image_ == nullptr) { + if (setup_ui_called_) { + ESP_LOGW(TAG, + "SetEmotion('%s') failed: emoji_image_ is nullptr (SetupUI() was called but " + "emoji image not created)", + emotion); + } + return; + } + + auto emoji_collection = static_cast(current_theme_)->emoji_collection(); + auto image = emoji_collection != nullptr ? emoji_collection->GetEmojiImage(emotion) : nullptr; + if (image == nullptr) { + const char* utf8 = font_awesome_get_utf8(emotion); + if (utf8 != nullptr && emoji_label_ != nullptr) { + DisplayLockGuard lock(this); + lv_label_set_text(emoji_label_, utf8); + lv_obj_add_flag(emoji_image_, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(emoji_label_, LV_OBJ_FLAG_HIDDEN); + } + return; + } + + DisplayLockGuard lock(this); + if (image->IsGif()) { + // Create new GIF controller + gif_controller_ = std::make_unique(image->image_dsc()); + + if (gif_controller_->IsLoaded()) { + // Set up frame update callback + gif_controller_->SetFrameCallback( + [this]() { lv_image_set_src(emoji_image_, gif_controller_->image_dsc()); }); + + // Set initial frame and start animation + lv_image_set_src(emoji_image_, gif_controller_->image_dsc()); + gif_controller_->Start(); + + // Show GIF, hide others + lv_obj_add_flag(emoji_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(emoji_image_, LV_OBJ_FLAG_HIDDEN); + } else { + ESP_LOGE(TAG, "Failed to load GIF for emotion: %s", emotion); + gif_controller_.reset(); + } + } else { + lv_image_set_src(emoji_image_, image->image_dsc()); + lv_obj_add_flag(emoji_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(emoji_image_, LV_OBJ_FLAG_HIDDEN); + } + +#if CONFIG_USE_WECHAT_MESSAGE_STYLE + // In WeChat message style, if emotion is neutral, don't display it + uint32_t child_count = lv_obj_get_child_cnt(content_); + if (strcmp(emotion, "neutral") == 0 && child_count > 0) { + // Stop GIF animation if running + if (gif_controller_) { + gif_controller_->Stop(); + gif_controller_.reset(); + } + + lv_obj_add_flag(emoji_image_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(emoji_label_, LV_OBJ_FLAG_HIDDEN); + } +#endif +} + +void LcdDisplay::SetTheme(Theme* theme) { + DisplayLockGuard lock(this); + + auto lvgl_theme = static_cast(theme); + + // Get the active screen + lv_obj_t* screen = lv_screen_active(); + + // Set font + auto text_font = lvgl_theme->text_font()->font(); + auto icon_font = lvgl_theme->icon_font()->font(); + auto large_icon_font = lvgl_theme->large_icon_font()->font(); + + if (text_font->line_height >= 40) { + lv_obj_set_style_text_font(mute_label_, large_icon_font, 0); + lv_obj_set_style_text_font(battery_label_, large_icon_font, 0); + lv_obj_set_style_text_font(network_label_, large_icon_font, 0); + } else { + lv_obj_set_style_text_font(mute_label_, icon_font, 0); + lv_obj_set_style_text_font(battery_label_, icon_font, 0); + lv_obj_set_style_text_font(network_label_, icon_font, 0); + } + + // Set parent text color + lv_obj_set_style_text_font(screen, text_font, 0); + lv_obj_set_style_text_color(screen, lvgl_theme->text_color(), 0); + + // Set background image + if (lvgl_theme->background_image() != nullptr) { + lv_obj_set_style_bg_image_src(container_, lvgl_theme->background_image()->image_dsc(), 0); + } else { + lv_obj_set_style_bg_image_src(container_, nullptr, 0); + lv_obj_set_style_bg_color(container_, lvgl_theme->background_color(), 0); + } + + // Update top bar background color with 50% opacity + if (top_bar_ != nullptr) { + lv_obj_set_style_bg_opa(top_bar_, LV_OPA_50, 0); + lv_obj_set_style_bg_color(top_bar_, lvgl_theme->background_color(), 0); + } + if (status_bar_ != nullptr) { + lv_obj_set_style_bg_opa(status_bar_, LV_OPA_50, 0); + lv_obj_set_style_bg_color(status_bar_, lvgl_theme->background_color(), 0); + } + + // Update status bar elements + lv_obj_set_style_text_color(network_label_, lvgl_theme->text_color(), 0); + lv_obj_set_style_text_color(status_label_, lvgl_theme->text_color(), 0); + if (time_label_ != nullptr) { + lv_obj_set_style_text_color(time_label_, lvgl_theme->text_color(), 0); + } + lv_obj_set_style_text_color(notification_label_, lvgl_theme->text_color(), 0); + lv_obj_set_style_text_color(mute_label_, lvgl_theme->text_color(), 0); + lv_obj_set_style_text_color(battery_label_, lvgl_theme->text_color(), 0); + lv_obj_set_style_text_color(emoji_label_, lvgl_theme->text_color(), 0); + + // If we have the chat message style, update all message bubbles +#if CONFIG_USE_WECHAT_MESSAGE_STYLE + // Set content background opacity + lv_obj_set_style_bg_opa(content_, LV_OPA_TRANSP, 0); + + // Iterate through all children of content (message containers or bubbles) + uint32_t child_count = lv_obj_get_child_cnt(content_); + for (uint32_t i = 0; i < child_count; i++) { + lv_obj_t* obj = lv_obj_get_child(content_, i); + if (obj == nullptr) + continue; + + lv_obj_t* bubble = nullptr; + + // Check if this object is a container or bubble + // If it's a container (user or system message), get its child as bubble + // If it's a bubble (assistant message), use it directly + if (lv_obj_get_child_cnt(obj) > 0) { + // Might be a container, check if it's a user or system message container + // User and system message containers are transparent + lv_opa_t bg_opa = lv_obj_get_style_bg_opa(obj, LV_PART_MAIN); + if (bg_opa == LV_OPA_TRANSP) { + // This is a user or system message container + bubble = lv_obj_get_child(obj, 0); + } else { + // This might be an assistant message bubble itself + bubble = obj; + } + } else { + // No child elements, might be other UI elements, skip + continue; + } + + if (bubble == nullptr) + continue; + + // Use saved user data to identify bubble type + void* bubble_type_ptr = lv_obj_get_user_data(bubble); + if (bubble_type_ptr != nullptr) { + const char* bubble_type = static_cast(bubble_type_ptr); + + // Apply correct color based on bubble type + if (strcmp(bubble_type, "user") == 0) { + lv_obj_set_style_bg_color(bubble, lvgl_theme->user_bubble_color(), 0); + } else if (strcmp(bubble_type, "assistant") == 0) { + lv_obj_set_style_bg_color(bubble, lvgl_theme->assistant_bubble_color(), 0); + } else if (strcmp(bubble_type, "system") == 0) { + lv_obj_set_style_bg_color(bubble, lvgl_theme->system_bubble_color(), 0); + } else if (strcmp(bubble_type, "image") == 0) { + lv_obj_set_style_bg_color(bubble, lvgl_theme->system_bubble_color(), 0); + } + + // Update border color + lv_obj_set_style_border_color(bubble, lvgl_theme->border_color(), 0); + + // Update text color for the message + if (lv_obj_get_child_cnt(bubble) > 0) { + lv_obj_t* text = lv_obj_get_child(bubble, 0); + if (text != nullptr) { + // Set text color based on bubble type + if (strcmp(bubble_type, "system") == 0) { + lv_obj_set_style_text_color(text, lvgl_theme->system_text_color(), 0); + } else { + lv_obj_set_style_text_color(text, lvgl_theme->text_color(), 0); + } + } + } + } else { + ESP_LOGW(TAG, "child[%lu] Bubble type is not found", i); + } + } +#else + // Simple UI mode - just update the main chat message + if (chat_message_label_ != nullptr) { + lv_obj_set_style_text_color(chat_message_label_, lvgl_theme->text_color(), 0); + } + + if (emoji_label_ != nullptr) { + lv_obj_set_style_text_color(emoji_label_, lvgl_theme->text_color(), 0); + } + + // Update bottom bar background color with 50% opacity + if (bottom_bar_ != nullptr) { + lv_obj_set_style_bg_opa(bottom_bar_, LV_OPA_50, 0); + lv_obj_set_style_bg_color(bottom_bar_, lvgl_theme->background_color(), 0); + } +#endif + + // Update low battery popup + lv_obj_set_style_bg_color(low_battery_popup_, lvgl_theme->low_battery_color(), 0); + + // No errors occurred. Save theme to settings + Display::SetTheme(lvgl_theme); +} + +void LcdDisplay::SetHideSubtitle(bool hide) { + DisplayLockGuard lock(this); + hide_subtitle_ = hide; + + // Immediately update UI visibility based on the setting + if (bottom_bar_ != nullptr) { + if (hide) { + lv_obj_add_flag(bottom_bar_, LV_OBJ_FLAG_HIDDEN); + } else { + // Only show if there is actual content to display + const char* text = + (chat_message_label_ != nullptr) ? lv_label_get_text(chat_message_label_) : nullptr; + if (text != nullptr && text[0] != '\0') { + lv_obj_remove_flag(bottom_bar_, LV_OBJ_FLAG_HIDDEN); + } + } + } +} diff --git a/main/display/lcd_display.h b/main/display/lcd_display.h new file mode 100644 index 0000000..e5be55e --- /dev/null +++ b/main/display/lcd_display.h @@ -0,0 +1,85 @@ +#ifndef LCD_DISPLAY_H +#define LCD_DISPLAY_H + +#include "lvgl_display.h" +#include "gif/lvgl_gif.h" + +#include +#include +#include + +#include +#include + +#define PREVIEW_IMAGE_DURATION_MS 5000 + + +class LcdDisplay : public LvglDisplay { +protected: + esp_lcd_panel_io_handle_t panel_io_ = nullptr; + esp_lcd_panel_handle_t panel_ = nullptr; + + lv_draw_buf_t draw_buf_; + lv_obj_t* top_bar_ = nullptr; + lv_obj_t* status_bar_ = nullptr; + lv_obj_t* content_ = nullptr; + lv_obj_t* container_ = nullptr; + lv_obj_t* side_bar_ = nullptr; + lv_obj_t* bottom_bar_ = nullptr; + lv_obj_t* preview_image_ = nullptr; + lv_obj_t* emoji_label_ = nullptr; + lv_obj_t* emoji_image_ = nullptr; + std::unique_ptr gif_controller_ = nullptr; + lv_obj_t* emoji_box_ = nullptr; + lv_obj_t* chat_message_label_ = nullptr; + esp_timer_handle_t preview_timer_ = nullptr; + std::unique_ptr preview_image_cached_ = nullptr; + bool hide_subtitle_ = false; // Control whether to hide chat messages/subtitles + + void InitializeLcdThemes(); + virtual bool Lock(int timeout_ms = 0) override; + virtual void Unlock() override; + +protected: + // Add protected constructor + LcdDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, int width, int height); + +public: + ~LcdDisplay(); + virtual void SetEmotion(const char* emotion) override; + virtual void SetChatMessage(const char* role, const char* content) override; + virtual void ClearChatMessages() override; + virtual void SetPreviewImage(std::unique_ptr image) override; + virtual void SetupUI() override; + // Add theme switching function + virtual void SetTheme(Theme* theme) override; + + // Set whether to hide chat messages/subtitles + void SetHideSubtitle(bool hide); +}; + +// SPI LCD display +class SpiLcdDisplay : public LcdDisplay { +public: + SpiLcdDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, + int width, int height, int offset_x, int offset_y, + bool mirror_x, bool mirror_y, bool swap_xy); +}; + +// RGB LCD display +class RgbLcdDisplay : public LcdDisplay { +public: + RgbLcdDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, + int width, int height, int offset_x, int offset_y, + bool mirror_x, bool mirror_y, bool swap_xy); +}; + +// MIPI LCD display +class MipiLcdDisplay : public LcdDisplay { +public: + MipiLcdDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, + int width, int height, int offset_x, int offset_y, + bool mirror_x, bool mirror_y, bool swap_xy); +}; + +#endif // LCD_DISPLAY_H diff --git a/main/display/lvgl_display/emoji_collection.cc b/main/display/lvgl_display/emoji_collection.cc new file mode 100644 index 0000000..c9a1a60 --- /dev/null +++ b/main/display/lvgl_display/emoji_collection.cc @@ -0,0 +1,123 @@ +#include "emoji_collection.h" + +#include +#include +#include + +#define TAG "EmojiCollection" + +void EmojiCollection::AddEmoji(const std::string& name, LvglImage* image) { + emoji_collection_[name] = image; +} + +const LvglImage* EmojiCollection::GetEmojiImage(const char* name) { + auto it = emoji_collection_.find(name); + if (it != emoji_collection_.end()) { + return it->second; + } + + ESP_LOGW(TAG, "Emoji not found: %s", name); + return nullptr; +} + +EmojiCollection::~EmojiCollection() { + for (auto it = emoji_collection_.begin(); it != emoji_collection_.end(); ++it) { + delete it->second; + } + emoji_collection_.clear(); +} + +// These are declared in xiaozhi-fonts/src/font_emoji_32.c +extern const lv_image_dsc_t emoji_1f636_32; // neutral +extern const lv_image_dsc_t emoji_1f642_32; // happy +extern const lv_image_dsc_t emoji_1f606_32; // laughing +extern const lv_image_dsc_t emoji_1f602_32; // funny +extern const lv_image_dsc_t emoji_1f614_32; // sad +extern const lv_image_dsc_t emoji_1f620_32; // angry +extern const lv_image_dsc_t emoji_1f62d_32; // crying +extern const lv_image_dsc_t emoji_1f60d_32; // loving +extern const lv_image_dsc_t emoji_1f633_32; // embarrassed +extern const lv_image_dsc_t emoji_1f62f_32; // surprised +extern const lv_image_dsc_t emoji_1f631_32; // shocked +extern const lv_image_dsc_t emoji_1f914_32; // thinking +extern const lv_image_dsc_t emoji_1f609_32; // winking +extern const lv_image_dsc_t emoji_1f60e_32; // cool +extern const lv_image_dsc_t emoji_1f60c_32; // relaxed +extern const lv_image_dsc_t emoji_1f924_32; // delicious +extern const lv_image_dsc_t emoji_1f618_32; // kissy +extern const lv_image_dsc_t emoji_1f60f_32; // confident +extern const lv_image_dsc_t emoji_1f634_32; // sleepy +extern const lv_image_dsc_t emoji_1f61c_32; // silly +extern const lv_image_dsc_t emoji_1f644_32; // confused + +Twemoji32::Twemoji32() { + AddEmoji("neutral", new LvglSourceImage(&emoji_1f636_32)); + AddEmoji("happy", new LvglSourceImage(&emoji_1f642_32)); + AddEmoji("laughing", new LvglSourceImage(&emoji_1f606_32)); + AddEmoji("funny", new LvglSourceImage(&emoji_1f602_32)); + AddEmoji("sad", new LvglSourceImage(&emoji_1f614_32)); + AddEmoji("angry", new LvglSourceImage(&emoji_1f620_32)); + AddEmoji("crying", new LvglSourceImage(&emoji_1f62d_32)); + AddEmoji("loving", new LvglSourceImage(&emoji_1f60d_32)); + AddEmoji("embarrassed", new LvglSourceImage(&emoji_1f633_32)); + AddEmoji("surprised", new LvglSourceImage(&emoji_1f62f_32)); + AddEmoji("shocked", new LvglSourceImage(&emoji_1f631_32)); + AddEmoji("thinking", new LvglSourceImage(&emoji_1f914_32)); + AddEmoji("winking", new LvglSourceImage(&emoji_1f609_32)); + AddEmoji("cool", new LvglSourceImage(&emoji_1f60e_32)); + AddEmoji("relaxed", new LvglSourceImage(&emoji_1f60c_32)); + AddEmoji("delicious", new LvglSourceImage(&emoji_1f924_32)); + AddEmoji("kissy", new LvglSourceImage(&emoji_1f618_32)); + AddEmoji("confident", new LvglSourceImage(&emoji_1f60f_32)); + AddEmoji("sleepy", new LvglSourceImage(&emoji_1f634_32)); + AddEmoji("silly", new LvglSourceImage(&emoji_1f61c_32)); + AddEmoji("confused", new LvglSourceImage(&emoji_1f644_32)); +} + + +// These are declared in xiaozhi-fonts/src/font_emoji_64.c +extern const lv_image_dsc_t emoji_1f636_64; // neutral +extern const lv_image_dsc_t emoji_1f642_64; // happy +extern const lv_image_dsc_t emoji_1f606_64; // laughing +extern const lv_image_dsc_t emoji_1f602_64; // funny +extern const lv_image_dsc_t emoji_1f614_64; // sad +extern const lv_image_dsc_t emoji_1f620_64; // angry +extern const lv_image_dsc_t emoji_1f62d_64; // crying +extern const lv_image_dsc_t emoji_1f60d_64; // loving +extern const lv_image_dsc_t emoji_1f633_64; // embarrassed +extern const lv_image_dsc_t emoji_1f62f_64; // surprised +extern const lv_image_dsc_t emoji_1f631_64; // shocked +extern const lv_image_dsc_t emoji_1f914_64; // thinking +extern const lv_image_dsc_t emoji_1f609_64; // winking +extern const lv_image_dsc_t emoji_1f60e_64; // cool +extern const lv_image_dsc_t emoji_1f60c_64; // relaxed +extern const lv_image_dsc_t emoji_1f924_64; // delicious +extern const lv_image_dsc_t emoji_1f618_64; // kissy +extern const lv_image_dsc_t emoji_1f60f_64; // confident +extern const lv_image_dsc_t emoji_1f634_64; // sleepy +extern const lv_image_dsc_t emoji_1f61c_64; // silly +extern const lv_image_dsc_t emoji_1f644_64; // confused + +Twemoji64::Twemoji64() { + AddEmoji("neutral", new LvglSourceImage(&emoji_1f636_64)); + AddEmoji("happy", new LvglSourceImage(&emoji_1f642_64)); + AddEmoji("laughing", new LvglSourceImage(&emoji_1f606_64)); + AddEmoji("funny", new LvglSourceImage(&emoji_1f602_64)); + AddEmoji("sad", new LvglSourceImage(&emoji_1f614_64)); + AddEmoji("angry", new LvglSourceImage(&emoji_1f620_64)); + AddEmoji("crying", new LvglSourceImage(&emoji_1f62d_64)); + AddEmoji("loving", new LvglSourceImage(&emoji_1f60d_64)); + AddEmoji("embarrassed", new LvglSourceImage(&emoji_1f633_64)); + AddEmoji("surprised", new LvglSourceImage(&emoji_1f62f_64)); + AddEmoji("shocked", new LvglSourceImage(&emoji_1f631_64)); + AddEmoji("thinking", new LvglSourceImage(&emoji_1f914_64)); + AddEmoji("winking", new LvglSourceImage(&emoji_1f609_64)); + AddEmoji("cool", new LvglSourceImage(&emoji_1f60e_64)); + AddEmoji("relaxed", new LvglSourceImage(&emoji_1f60c_64)); + AddEmoji("delicious", new LvglSourceImage(&emoji_1f924_64)); + AddEmoji("kissy", new LvglSourceImage(&emoji_1f618_64)); + AddEmoji("confident", new LvglSourceImage(&emoji_1f60f_64)); + AddEmoji("sleepy", new LvglSourceImage(&emoji_1f634_64)); + AddEmoji("silly", new LvglSourceImage(&emoji_1f61c_64)); + AddEmoji("confused", new LvglSourceImage(&emoji_1f644_64)); +} diff --git a/main/display/lvgl_display/emoji_collection.h b/main/display/lvgl_display/emoji_collection.h new file mode 100644 index 0000000..c9eac05 --- /dev/null +++ b/main/display/lvgl_display/emoji_collection.h @@ -0,0 +1,34 @@ +#ifndef EMOJI_COLLECTION_H +#define EMOJI_COLLECTION_H + +#include "lvgl_image.h" + +#include + +#include +#include +#include + + +// Define interface for emoji collection +class EmojiCollection { +public: + virtual void AddEmoji(const std::string& name, LvglImage* image); + virtual const LvglImage* GetEmojiImage(const char* name); + virtual ~EmojiCollection(); + +private: + std::map emoji_collection_; +}; + +class Twemoji32 : public EmojiCollection { +public: + Twemoji32(); +}; + +class Twemoji64 : public EmojiCollection { +public: + Twemoji64(); +}; + +#endif diff --git a/main/display/lvgl_display/gif/LICENSE.txt b/main/display/lvgl_display/gif/LICENSE.txt new file mode 100644 index 0000000..c53d519 --- /dev/null +++ b/main/display/lvgl_display/gif/LICENSE.txt @@ -0,0 +1,2 @@ +All of the source code and documentation for gifdec is released into the +public domain and provided without warranty of any kind. diff --git a/main/display/lvgl_display/gif/README.md b/main/display/lvgl_display/gif/README.md new file mode 100644 index 0000000..935cfeb --- /dev/null +++ b/main/display/lvgl_display/gif/README.md @@ -0,0 +1,17 @@ +# 说明 / Description + +## 中文 + +本目录代码移植自 LVGL 的 GIF 程序。 + +主要修复和改进: +- 修复了透明背景问题 +- 兼容了 87a 版本的 GIF 格式 + +## English + +The code in this directory is ported from LVGL's GIF program. + +Main fixes and improvements: +- Fixed transparent background issues +- Added compatibility for GIF 87a version format diff --git a/main/display/lvgl_display/gif/gifdec.c b/main/display/lvgl_display/gif/gifdec.c new file mode 100644 index 0000000..cab83b9 --- /dev/null +++ b/main/display/lvgl_display/gif/gifdec.c @@ -0,0 +1,821 @@ +#include "gifdec.h" + +#include +#include +#include +#include + +#define TAG "GIF" + +#define MIN(A, B) ((A) < (B) ? (A) : (B)) +#define MAX(A, B) ((A) > (B) ? (A) : (B)) + +typedef struct Entry { + uint16_t length; + uint16_t prefix; + uint8_t suffix; +} Entry; + +typedef struct Table { + int bulk; + int nentries; + Entry * entries; +} Table; + +#if LV_GIF_CACHE_DECODE_DATA +#define LZW_MAXBITS 12 +#define LZW_TABLE_SIZE (1 << LZW_MAXBITS) +#define LZW_CACHE_SIZE (LZW_TABLE_SIZE * 4) +#endif + +static gd_GIF * gif_open(gd_GIF * gif); +static bool f_gif_open(gd_GIF * gif, const void * path, bool is_file); +static inline void f_gif_read(gd_GIF * gif, void * buf, size_t len); +static inline int f_gif_seek(gd_GIF * gif, size_t pos, int k); +static void f_gif_close(gd_GIF * gif); + +#if LV_USE_DRAW_SW_ASM == LV_DRAW_SW_ASM_HELIUM + #include "gifdec_mve.h" +#endif + +static uint16_t +read_num(gd_GIF * gif) +{ + uint8_t bytes[2]; + + f_gif_read(gif, bytes, 2); + return bytes[0] + (((uint16_t) bytes[1]) << 8); +} + +gd_GIF * +gd_open_gif_file(const char * fname) +{ + gd_GIF gif_base; + memset(&gif_base, 0, sizeof(gif_base)); + + bool res = f_gif_open(&gif_base, fname, true); + if(!res) return NULL; + + return gif_open(&gif_base); +} + +gd_GIF * +gd_open_gif_data(const void * data) +{ + gd_GIF gif_base; + memset(&gif_base, 0, sizeof(gif_base)); + + bool res = f_gif_open(&gif_base, data, false); + if(!res) return NULL; + + return gif_open(&gif_base); +} + +static gd_GIF * gif_open(gd_GIF * gif_base) +{ + uint8_t sigver[3]; + uint16_t width, height, depth; + uint8_t fdsz, bgidx, aspect; + uint8_t * bgcolor; + int gct_sz; + gd_GIF * gif = NULL; + + /* Header */ + f_gif_read(gif_base, sigver, 3); + if(memcmp(sigver, "GIF", 3) != 0) { + ESP_LOGW(TAG, "invalid signature"); + goto fail; + } + /* Version */ + f_gif_read(gif_base, sigver, 3); + if(memcmp(sigver, "89a", 3) != 0 && memcmp(sigver, "87a", 3) != 0) { + ESP_LOGW(TAG, "invalid version"); + goto fail; + } + /* Width x Height */ + width = read_num(gif_base); + height = read_num(gif_base); + /* FDSZ */ + f_gif_read(gif_base, &fdsz, 1); + /* Presence of GCT */ + if(!(fdsz & 0x80)) { + ESP_LOGW(TAG, "no global color table"); + goto fail; + } + /* Color Space's Depth */ + depth = ((fdsz >> 4) & 7) + 1; + /* Ignore Sort Flag. */ + /* GCT Size */ + gct_sz = 1 << ((fdsz & 0x07) + 1); + /* Background Color Index */ + f_gif_read(gif_base, &bgidx, 1); + /* Aspect Ratio */ + f_gif_read(gif_base, &aspect, 1); + /* Create gd_GIF Structure. */ + if(0 == width || 0 == height){ + ESP_LOGW(TAG, "Zero size image"); + goto fail; + } +#if LV_GIF_CACHE_DECODE_DATA + if(0 == (INT_MAX - sizeof(gd_GIF) - LZW_CACHE_SIZE) / width / height / 5){ + ESP_LOGW(TAG, "Image dimensions are too large"); + goto fail; + } + gif = lv_malloc(sizeof(gd_GIF) + 5 * width * height + LZW_CACHE_SIZE); +#else + if(0 == (INT_MAX - sizeof(gd_GIF)) / width / height / 5){ + ESP_LOGW(TAG, "Image dimensions are too large"); + goto fail; + } + gif = lv_malloc(sizeof(gd_GIF) + 5 * width * height); +#endif + if(!gif) goto fail; + memcpy(gif, gif_base, sizeof(gd_GIF)); + gif->width = width; + gif->height = height; + gif->depth = depth; + /* Read GCT */ + gif->gct.size = gct_sz; + f_gif_read(gif, gif->gct.colors, 3 * gif->gct.size); + gif->palette = &gif->gct; + gif->bgindex = bgidx; + gif->canvas = (uint8_t *) &gif[1]; + gif->frame = &gif->canvas[4 * width * height]; + if(gif->bgindex) { + memset(gif->frame, gif->bgindex, gif->width * gif->height); + } + bgcolor = &gif->palette->colors[gif->bgindex * 3]; + #if LV_GIF_CACHE_DECODE_DATA + gif->lzw_cache = gif->frame + width * height; + #endif + +#ifdef GIFDEC_FILL_BG + GIFDEC_FILL_BG(gif->canvas, gif->width * gif->height, 1, gif->width * gif->height, bgcolor, 0x00); +#else + for(int i = 0; i < gif->width * gif->height; i++) { + gif->canvas[i * 4 + 0] = *(bgcolor + 2); + gif->canvas[i * 4 + 1] = *(bgcolor + 1); + gif->canvas[i * 4 + 2] = *(bgcolor + 0); + gif->canvas[i * 4 + 3] = 0x00; // 初始化为透明,让第一帧根据自己的透明度设置来渲染 + } +#endif + gif->anim_start = f_gif_seek(gif, 0, LV_FS_SEEK_CUR); + gif->loop_count = -1; + goto ok; +fail: + f_gif_close(gif_base); +ok: + return gif; +} + +static void +discard_sub_blocks(gd_GIF * gif) +{ + uint8_t size; + + do { + f_gif_read(gif, &size, 1); + f_gif_seek(gif, size, LV_FS_SEEK_CUR); + } while(size); +} + +static void +read_plain_text_ext(gd_GIF * gif) +{ + if(gif->plain_text) { + uint16_t tx, ty, tw, th; + uint8_t cw, ch, fg, bg; + size_t sub_block; + f_gif_seek(gif, 1, LV_FS_SEEK_CUR); /* block size = 12 */ + tx = read_num(gif); + ty = read_num(gif); + tw = read_num(gif); + th = read_num(gif); + f_gif_read(gif, &cw, 1); + f_gif_read(gif, &ch, 1); + f_gif_read(gif, &fg, 1); + f_gif_read(gif, &bg, 1); + sub_block = f_gif_seek(gif, 0, LV_FS_SEEK_CUR); + gif->plain_text(gif, tx, ty, tw, th, cw, ch, fg, bg); + f_gif_seek(gif, sub_block, LV_FS_SEEK_SET); + } + else { + /* Discard plain text metadata. */ + f_gif_seek(gif, 13, LV_FS_SEEK_CUR); + } + /* Discard plain text sub-blocks. */ + discard_sub_blocks(gif); +} + +static void +read_graphic_control_ext(gd_GIF * gif) +{ + uint8_t rdit; + + /* Discard block size (always 0x04). */ + f_gif_seek(gif, 1, LV_FS_SEEK_CUR); + f_gif_read(gif, &rdit, 1); + gif->gce.disposal = (rdit >> 2) & 3; + gif->gce.input = rdit & 2; + gif->gce.transparency = rdit & 1; + gif->gce.delay = read_num(gif); + f_gif_read(gif, &gif->gce.tindex, 1); + /* Skip block terminator. */ + f_gif_seek(gif, 1, LV_FS_SEEK_CUR); +} + +static void +read_comment_ext(gd_GIF * gif) +{ + if(gif->comment) { + size_t sub_block = f_gif_seek(gif, 0, LV_FS_SEEK_CUR); + gif->comment(gif); + f_gif_seek(gif, sub_block, LV_FS_SEEK_SET); + } + /* Discard comment sub-blocks. */ + discard_sub_blocks(gif); +} + +static void +read_application_ext(gd_GIF * gif) +{ + char app_id[8]; + char app_auth_code[3]; + uint16_t loop_count; + + /* Discard block size (always 0x0B). */ + f_gif_seek(gif, 1, LV_FS_SEEK_CUR); + /* Application Identifier. */ + f_gif_read(gif, app_id, 8); + /* Application Authentication Code. */ + f_gif_read(gif, app_auth_code, 3); + if(!strncmp(app_id, "NETSCAPE", sizeof(app_id))) { + /* Discard block size (0x03) and constant byte (0x01). */ + f_gif_seek(gif, 2, LV_FS_SEEK_CUR); + loop_count = read_num(gif); + if(gif->loop_count < 0) { + if(loop_count == 0) { + gif->loop_count = 0; + } + else { + gif->loop_count = loop_count + 1; + } + } + /* Skip block terminator. */ + f_gif_seek(gif, 1, LV_FS_SEEK_CUR); + } + else if(gif->application) { + size_t sub_block = f_gif_seek(gif, 0, LV_FS_SEEK_CUR); + gif->application(gif, app_id, app_auth_code); + f_gif_seek(gif, sub_block, LV_FS_SEEK_SET); + discard_sub_blocks(gif); + } + else { + discard_sub_blocks(gif); + } +} + +static void +read_ext(gd_GIF * gif) +{ + uint8_t label; + + f_gif_read(gif, &label, 1); + switch(label) { + case 0x01: + read_plain_text_ext(gif); + break; + case 0xF9: + read_graphic_control_ext(gif); + break; + case 0xFE: + read_comment_ext(gif); + break; + case 0xFF: + read_application_ext(gif); + break; + default: + ESP_LOGW(TAG, "unknown extension: %02X\n", label); + } +} + +static uint16_t +get_key(gd_GIF *gif, int key_size, uint8_t *sub_len, uint8_t *shift, uint8_t *byte) +{ + int bits_read; + int rpad; + int frag_size; + uint16_t key; + + key = 0; + for (bits_read = 0; bits_read < key_size; bits_read += frag_size) { + rpad = (*shift + bits_read) % 8; + if (rpad == 0) { + /* Update byte. */ + if (*sub_len == 0) { + f_gif_read(gif, sub_len, 1); /* Must be nonzero! */ + if (*sub_len == 0) return 0x1000; + } + f_gif_read(gif, byte, 1); + (*sub_len)--; + } + frag_size = MIN(key_size - bits_read, 8 - rpad); + key |= ((uint16_t) ((*byte) >> rpad)) << bits_read; + } + /* Clear extra bits to the left. */ + key &= (1 << key_size) - 1; + *shift = (*shift + key_size) % 8; + return key; +} + +#if LV_GIF_CACHE_DECODE_DATA +/* Decompress image pixels. + * Return 0 on success or -1 on out-of-memory (w.r.t. LZW code table) or parse error. */ +static int +read_image_data(gd_GIF *gif, int interlace) +{ + uint8_t sub_len, shift, byte; + int ret = 0; + int key_size; + int y, pass, linesize; + uint8_t *ptr = NULL; + uint8_t *ptr_row_start = NULL; + uint8_t *ptr_base = NULL; + size_t start, end; + uint16_t key, clear_code, stop_code, curr_code; + int frm_off, frm_size,curr_size,top_slot,new_codes,slot; + /* The first value of the value sequence corresponding to key */ + int first_value; + int last_key; + uint8_t *sp = NULL; + uint8_t *p_stack = NULL; + uint8_t *p_suffix = NULL; + uint16_t *p_prefix = NULL; + + /* get initial key size and clear code, stop code */ + f_gif_read(gif, &byte, 1); + key_size = (int) byte; + clear_code = 1 << key_size; + stop_code = clear_code + 1; + key = 0; + + start = f_gif_seek(gif, 0, LV_FS_SEEK_CUR); + discard_sub_blocks(gif); + end = f_gif_seek(gif, 0, LV_FS_SEEK_CUR); + f_gif_seek(gif, start, LV_FS_SEEK_SET); + + linesize = gif->width; + ptr_base = &gif->frame[gif->fy * linesize + gif->fx]; + ptr_row_start = ptr_base; + ptr = ptr_row_start; + sub_len = shift = 0; + /* decoder */ + pass = 0; + y = 0; + p_stack = gif->lzw_cache; + p_suffix = gif->lzw_cache + LZW_TABLE_SIZE; + p_prefix = (uint16_t*)(gif->lzw_cache + LZW_TABLE_SIZE * 2); + frm_off = 0; + frm_size = gif->fw * gif->fh; + curr_size = key_size + 1; + top_slot = 1 << curr_size; + new_codes = clear_code + 2; + slot = new_codes; + first_value = -1; + last_key = -1; + sp = p_stack; + + while (frm_off < frm_size) { + /* copy data to frame buffer */ + while (sp > p_stack) { + if(frm_off >= frm_size){ + ESP_LOGW(TAG, "LZW table token overflows the frame buffer"); + return -1; + } + *ptr++ = *(--sp); + frm_off += 1; + /* read one line */ + if ((ptr - ptr_row_start) == gif->fw) { + if (interlace) { + switch(pass) { + case 0: + case 1: + y += 8; + ptr_row_start += linesize * 8; + break; + case 2: + y += 4; + ptr_row_start += linesize * 4; + break; + case 3: + y += 2; + ptr_row_start += linesize * 2; + break; + default: + break; + } + while (y >= gif->fh) { + y = 4 >> pass; + ptr_row_start = ptr_base + linesize * y; + pass++; + } + } else { + ptr_row_start += linesize; + } + ptr = ptr_row_start; + } + } + + key = get_key(gif, curr_size, &sub_len, &shift, &byte); + + if (key == stop_code || key >= LZW_TABLE_SIZE) + break; + + if (key == clear_code) { + curr_size = key_size + 1; + slot = new_codes; + top_slot = 1 << curr_size; + first_value = last_key = -1; + sp = p_stack; + continue; + } + + curr_code = key; + /* + * If the current code is a code that will be added to the decoding + * dictionary, it is composed of the data list corresponding to the + * previous key and its first data. + * */ + if (curr_code == slot && first_value >= 0) { + *sp++ = first_value; + curr_code = last_key; + }else if(curr_code >= slot) + break; + + while (curr_code >= new_codes) { + *sp++ = p_suffix[curr_code]; + curr_code = p_prefix[curr_code]; + } + *sp++ = curr_code; + + /* Add code to decoding dictionary */ + if (slot < top_slot && last_key >= 0) { + p_suffix[slot] = curr_code; + p_prefix[slot++] = last_key; + } + first_value = curr_code; + last_key = key; + if (slot >= top_slot) { + if (curr_size < LZW_MAXBITS) { + top_slot <<= 1; + curr_size += 1; + } + } + } + + if (key == stop_code) f_gif_read(gif, &sub_len, 1); /* Must be zero! */ + f_gif_seek(gif, end, LV_FS_SEEK_SET); + return ret; +} +#else +static Table * +new_table(int key_size) +{ + int key; + int init_bulk = MAX(1 << (key_size + 1), 0x100); + Table * table = lv_malloc(sizeof(*table) + sizeof(Entry) * init_bulk); + if(table) { + table->bulk = init_bulk; + table->nentries = (1 << key_size) + 2; + table->entries = (Entry *) &table[1]; + for(key = 0; key < (1 << key_size); key++) + table->entries[key] = (Entry) { + 1, 0xFFF, key + }; + } + return table; +} + +/* Add table entry. Return value: + * 0 on success + * +1 if key size must be incremented after this addition + * -1 if could not realloc table */ +static int +add_entry(Table ** tablep, uint16_t length, uint16_t prefix, uint8_t suffix) +{ + Table * table = *tablep; + if(table->nentries == table->bulk) { + table->bulk *= 2; + table = lv_realloc(table, sizeof(*table) + sizeof(Entry) * table->bulk); + if(!table) return -1; + table->entries = (Entry *) &table[1]; + *tablep = table; + } + table->entries[table->nentries] = (Entry) { + length, prefix, suffix + }; + table->nentries++; + if((table->nentries & (table->nentries - 1)) == 0) + return 1; + return 0; +} + +/* Compute output index of y-th input line, in frame of height h. */ +static int +interlaced_line_index(int h, int y) +{ + int p; /* number of lines in current pass */ + + p = (h - 1) / 8 + 1; + if(y < p) /* pass 1 */ + return y * 8; + y -= p; + p = (h - 5) / 8 + 1; + if(y < p) /* pass 2 */ + return y * 8 + 4; + y -= p; + p = (h - 3) / 4 + 1; + if(y < p) /* pass 3 */ + return y * 4 + 2; + y -= p; + /* pass 4 */ + return y * 2 + 1; +} + +/* Decompress image pixels. + * Return 0 on success or -1 on out-of-memory (w.r.t. LZW code table) or parse error. */ +static int +read_image_data(gd_GIF * gif, int interlace) +{ + uint8_t sub_len, shift, byte; + int init_key_size, key_size, table_is_full = 0; + int frm_off, frm_size, str_len = 0, i, p, x, y; + uint16_t key, clear, stop; + int ret; + Table * table; + Entry entry = {0}; + size_t start, end; + + f_gif_read(gif, &byte, 1); + key_size = (int) byte; + start = f_gif_seek(gif, 0, LV_FS_SEEK_CUR); + discard_sub_blocks(gif); + end = f_gif_seek(gif, 0, LV_FS_SEEK_CUR); + f_gif_seek(gif, start, LV_FS_SEEK_SET); + clear = 1 << key_size; + stop = clear + 1; + table = new_table(key_size); + key_size++; + init_key_size = key_size; + sub_len = shift = 0; + key = get_key(gif, key_size, &sub_len, &shift, &byte); /* clear code */ + frm_off = 0; + ret = 0; + frm_size = gif->fw * gif->fh; + while(frm_off < frm_size) { + if(key == clear) { + key_size = init_key_size; + table->nentries = (1 << (key_size - 1)) + 2; + table_is_full = 0; + } + else if(!table_is_full) { + ret = add_entry(&table, str_len + 1, key, entry.suffix); + if(ret == -1) { + lv_free(table); + return -1; + } + if(table->nentries == 0x1000) { + ret = 0; + table_is_full = 1; + } + } + key = get_key(gif, key_size, &sub_len, &shift, &byte); + if(key == clear) continue; + if(key == stop || key == 0x1000) break; + if(ret == 1) key_size++; + entry = table->entries[key]; + str_len = entry.length; + if(frm_off + str_len > frm_size){ + ESP_LOGW(TAG, "LZW table token overflows the frame buffer"); + lv_free(table); + return -1; + } + for(i = 0; i < str_len; i++) { + p = frm_off + entry.length - 1; + x = p % gif->fw; + y = p / gif->fw; + if(interlace) + y = interlaced_line_index((int) gif->fh, y); + gif->frame[(gif->fy + y) * gif->width + gif->fx + x] = entry.suffix; + if(entry.prefix == 0xFFF) + break; + else + entry = table->entries[entry.prefix]; + } + frm_off += str_len; + if(key < table->nentries - 1 && !table_is_full) + table->entries[table->nentries - 1].suffix = entry.suffix; + } + lv_free(table); + if(key == stop) f_gif_read(gif, &sub_len, 1); /* Must be zero! */ + f_gif_seek(gif, end, LV_FS_SEEK_SET); + return 0; +} + +#endif + +/* Read image. + * Return 0 on success or -1 on out-of-memory (w.r.t. LZW code table) or parse error. */ +static int +read_image(gd_GIF * gif) +{ + uint8_t fisrz; + int interlace; + + /* Image Descriptor. */ + gif->fx = read_num(gif); + gif->fy = read_num(gif); + gif->fw = read_num(gif); + gif->fh = read_num(gif); + if(gif->fx + (uint32_t)gif->fw > gif->width || gif->fy + (uint32_t)gif->fh > gif->height){ + ESP_LOGW(TAG, "Frame coordinates out of image bounds"); + return -1; + } + f_gif_read(gif, &fisrz, 1); + interlace = fisrz & 0x40; + /* Ignore Sort Flag. */ + /* Local Color Table? */ + if(fisrz & 0x80) { + /* Read LCT */ + gif->lct.size = 1 << ((fisrz & 0x07) + 1); + f_gif_read(gif, gif->lct.colors, 3 * gif->lct.size); + gif->palette = &gif->lct; + } + else + gif->palette = &gif->gct; + /* Image Data. */ + return read_image_data(gif, interlace); +} + +static void +render_frame_rect(gd_GIF * gif, uint8_t * buffer) +{ + int i = gif->fy * gif->width + gif->fx; +#ifdef GIFDEC_RENDER_FRAME + GIFDEC_RENDER_FRAME(&buffer[i * 4], gif->fw, gif->fh, gif->width, + &gif->frame[i], gif->palette->colors, + gif->gce.transparency ? gif->gce.tindex : 0x100); +#else + int j, k; + uint8_t index, * color; + + for(j = 0; j < gif->fh; j++) { + for(k = 0; k < gif->fw; k++) { + index = gif->frame[(gif->fy + j) * gif->width + gif->fx + k]; + color = &gif->palette->colors[index * 3]; + if(!gif->gce.transparency || index != gif->gce.tindex) { + buffer[(i + k) * 4 + 0] = *(color + 2); + buffer[(i + k) * 4 + 1] = *(color + 1); + buffer[(i + k) * 4 + 2] = *(color + 0); + buffer[(i + k) * 4 + 3] = 0xFF; + } + } + i += gif->width; + } +#endif +} + +static void +dispose(gd_GIF * gif) +{ + int i; + uint8_t * bgcolor; + switch(gif->gce.disposal) { + case 2: /* Restore to background color. */ + bgcolor = &gif->palette->colors[gif->bgindex * 3]; + + uint8_t opa = 0xff; + if(gif->gce.transparency) opa = 0x00; + + i = gif->fy * gif->width + gif->fx; +#ifdef GIFDEC_FILL_BG + GIFDEC_FILL_BG(&(gif->canvas[i * 4]), gif->fw, gif->fh, gif->width, bgcolor, opa); +#else + int j, k; + for(j = 0; j < gif->fh; j++) { + for(k = 0; k < gif->fw; k++) { + gif->canvas[(i + k) * 4 + 0] = *(bgcolor + 2); + gif->canvas[(i + k) * 4 + 1] = *(bgcolor + 1); + gif->canvas[(i + k) * 4 + 2] = *(bgcolor + 0); + gif->canvas[(i + k) * 4 + 3] = opa; + } + i += gif->width; + } +#endif + break; + case 3: /* Restore to previous, i.e., don't update canvas.*/ + break; + default: + /* Add frame non-transparent pixels to canvas. */ + render_frame_rect(gif, gif->canvas); + } +} + +/* Return 1 if got a frame; 0 if got GIF trailer; -1 if error. */ +int +gd_get_frame(gd_GIF * gif) +{ + char sep; + + dispose(gif); + f_gif_read(gif, &sep, 1); + while(sep != ',') { + if(sep == ';') { + f_gif_seek(gif, gif->anim_start, LV_FS_SEEK_SET); + if(gif->loop_count == 1 || gif->loop_count < 0) { + return 0; + } + else if(gif->loop_count > 1) { + gif->loop_count--; + } + } + else if(sep == '!') + read_ext(gif); + else return -1; + f_gif_read(gif, &sep, 1); + } + if(read_image(gif) == -1) + return -1; + return 1; +} + +void +gd_render_frame(gd_GIF * gif, uint8_t * buffer) +{ + render_frame_rect(gif, buffer); +} + +void +gd_rewind(gd_GIF * gif) +{ + gif->loop_count = -1; + f_gif_seek(gif, gif->anim_start, LV_FS_SEEK_SET); +} + +void +gd_close_gif(gd_GIF * gif) +{ + f_gif_close(gif); + lv_free(gif); +} + +static bool f_gif_open(gd_GIF * gif, const void * path, bool is_file) +{ + gif->f_rw_p = 0; + gif->data = NULL; + gif->is_file = is_file; + + if(is_file) { + lv_fs_res_t res = lv_fs_open(&gif->fd, path, LV_FS_MODE_RD); + if(res != LV_FS_RES_OK) return false; + else return true; + } + else { + gif->data = path; + return true; + } +} + +static void f_gif_read(gd_GIF * gif, void * buf, size_t len) +{ + if(gif->is_file) { + lv_fs_read(&gif->fd, buf, len, NULL); + } + else { + memcpy(buf, &gif->data[gif->f_rw_p], len); + gif->f_rw_p += len; + } +} + +static int f_gif_seek(gd_GIF * gif, size_t pos, int k) +{ + if(gif->is_file) { + lv_fs_seek(&gif->fd, pos, k); + uint32_t x; + lv_fs_tell(&gif->fd, &x); + return x; + } + else { + if(k == LV_FS_SEEK_CUR) gif->f_rw_p += pos; + else if(k == LV_FS_SEEK_SET) gif->f_rw_p = pos; + return gif->f_rw_p; + } +} + +static void f_gif_close(gd_GIF * gif) +{ + if(gif->is_file) { + lv_fs_close(&gif->fd); + } +} + diff --git a/main/display/lvgl_display/gif/gifdec.h b/main/display/lvgl_display/gif/gifdec.h new file mode 100644 index 0000000..12c171e --- /dev/null +++ b/main/display/lvgl_display/gif/gifdec.h @@ -0,0 +1,68 @@ +#ifndef GIFDEC_H +#define GIFDEC_H + +#ifdef __cplusplus +extern "C" { +#endif + +#include + +#include + +typedef struct _gd_Palette { + int size; + uint8_t colors[0x100 * 3]; +} gd_Palette; + +typedef struct _gd_GCE { + uint16_t delay; + uint8_t tindex; + uint8_t disposal; + int input; + int transparency; +} gd_GCE; + + + +typedef struct _gd_GIF { + lv_fs_file_t fd; + const char * data; + uint8_t is_file; + uint32_t f_rw_p; + int32_t anim_start; + uint16_t width, height; + uint16_t depth; + int32_t loop_count; + gd_GCE gce; + gd_Palette * palette; + gd_Palette lct, gct; + void (*plain_text)( + struct _gd_GIF * gif, uint16_t tx, uint16_t ty, + uint16_t tw, uint16_t th, uint8_t cw, uint8_t ch, + uint8_t fg, uint8_t bg + ); + void (*comment)(struct _gd_GIF * gif); + void (*application)(struct _gd_GIF * gif, char id[8], char auth[3]); + uint16_t fx, fy, fw, fh; + uint8_t bgindex; + uint8_t * canvas, * frame; +#if LV_GIF_CACHE_DECODE_DATA + uint8_t *lzw_cache; +#endif +} gd_GIF; + +gd_GIF * gd_open_gif_file(const char * fname); + +gd_GIF * gd_open_gif_data(const void * data); + +void gd_render_frame(gd_GIF * gif, uint8_t * buffer); + +int gd_get_frame(gd_GIF * gif); +void gd_rewind(gd_GIF * gif); +void gd_close_gif(gd_GIF * gif); + +#ifdef __cplusplus +} /* extern "C" */ +#endif + +#endif /* GIFDEC_H */ diff --git a/main/display/lvgl_display/gif/gifdec_mve.h b/main/display/lvgl_display/gif/gifdec_mve.h new file mode 100644 index 0000000..6d83393 --- /dev/null +++ b/main/display/lvgl_display/gif/gifdec_mve.h @@ -0,0 +1,140 @@ +/** + * @file gifdec_mve.h + * + */ + +#ifndef GIFDEC_MVE_H +#define GIFDEC_MVE_H + +#ifdef __cplusplus +extern "C" { +#endif + +/********************* + * INCLUDES + *********************/ +#include +#include "../../misc/lv_color.h" + +/********************* + * DEFINES + *********************/ + +#define GIFDEC_FILL_BG(dst, w, h, stride, color, opa) \ + _gifdec_fill_bg_mve(dst, w, h, stride, color, opa) + +#define GIFDEC_RENDER_FRAME(dst, w, h, stride, frame, pattern, tindex) \ + _gifdec_render_frame_mve(dst, w, h, stride, frame, pattern, tindex) + +/********************** + * MACROS + **********************/ + +/********************** + * TYPEDEFS + **********************/ + +/********************** + * GLOBAL PROTOTYPES + **********************/ + +static inline void _gifdec_fill_bg_mve(uint8_t * dst, uint16_t w, uint16_t h, uint16_t stride, uint8_t * color, + uint8_t opa) +{ + lv_color32_t c = lv_color32_make(*(color + 0), *(color + 1), *(color + 2), opa); + uint32_t color_32 = *(uint32_t *)&c; + + __asm volatile( + ".p2align 2 \n" + "vdup.32 q0, %[src] \n" + "3: \n" + "mov r0, %[dst] \n" + + "wlstp.32 lr, %[w], 1f \n" + "2: \n" + + "vstrw.32 q0, [r0], #16 \n" + "letp lr, 2b \n" + "1: \n" + "add %[dst], %[iTargetStride] \n" + "subs %[h], #1 \n" + "bne 3b \n" + : [dst] "+r"(dst), + [h] "+r"(h) + : [src] "r"(color_32), + [w] "r"(w), + [iTargetStride] "r"(stride * sizeof(uint32_t)) + : "r0", "q0", "memory", "r14", "cc"); +} + +static inline void _gifdec_render_frame_mve(uint8_t * dst, uint16_t w, uint16_t h, uint16_t stride, uint8_t * frame, + uint8_t * pattern, uint16_t tindex) +{ + if(w == 0 || h == 0) { + return; + } + + __asm volatile( + "vmov.u16 q3, #255 \n" + "vshl.u16 q3, q3, #8 \n" /* left shift 8 for a*/ + + "mov r0, #2 \n" + "vidup.u16 q6, r0, #4 \n" /* [2, 6, 10, 14, 18, 22, 26, 30] */ + "mov r0, #0 \n" + "vidup.u16 q7, r0, #4 \n" /* [0, 4, 8, 12, 16, 20, 24, 28] */ + + "3: \n" + "mov r1, %[dst] \n" + "mov r2, %[frame] \n" + + "wlstp.16 lr, %[w], 1f \n" + "2: \n" + + "mov r0, #3 \n" + "vldrb.u16 q4, [r2], #8 \n" + "vmul.u16 q5, q4, r0 \n" + + "mov r0, #1 \n" + "vldrb.u16 q2, [%[pattern], q5] \n" /* load 8 pixel r*/ + + "vadd.u16 q5, q5, r0 \n" + "vldrb.u16 q1, [%[pattern], q5] \n" /* load 8 pixel g*/ + + "vadd.u16 q5, q5, r0 \n" + "vldrb.u16 q0, [%[pattern], q5] \n" /* load 8 pixel b*/ + + "vshl.u16 q1, q1, #8 \n" /* left shift 8 for g*/ + + "vorr.u16 q0, q0, q1 \n" /* make 8 pixel gb*/ + "vorr.u16 q1, q2, q3 \n" /* make 8 pixel ar*/ + + "vcmp.i16 ne, q4, %[tindex] \n" + "vpstt \n" + "vstrht.16 q0, [r1, q7] \n" + "vstrht.16 q1, [r1, q6] \n" + "add r1, r1, #32 \n" + + "letp lr, 2b \n" + + "1: \n" + "mov r0, %[stride], LSL #2 \n" + "add %[dst], r0 \n" + "add %[frame], %[stride] \n" + "subs %[h], #1 \n" + "bne 3b \n" + + : [dst] "+r"(dst), + [frame] "+r"(frame), + [h] "+r"(h) + : [pattern] "r"(pattern), + [w] "r"(w), + [stride] "r"(stride), + [tindex] "r"(tindex) + : "r0", "r1", "r2", "q0", "q1", "q2", "q3", "q4", "q5", "q6", "q7", "memory", "r14", "cc"); +} + +#ifdef __cplusplus +} /*extern "C"*/ +#endif + +#endif /*GIFDEC_MVE_H*/ diff --git a/main/display/lvgl_display/gif/lvgl_gif.cc b/main/display/lvgl_display/gif/lvgl_gif.cc new file mode 100644 index 0000000..de00257 --- /dev/null +++ b/main/display/lvgl_display/gif/lvgl_gif.cc @@ -0,0 +1,252 @@ +#include "lvgl_gif.h" +#include +#include + +#define TAG "LvglGif" + +LvglGif::LvglGif(const lv_img_dsc_t* img_dsc) + : gif_(nullptr), timer_(nullptr), last_call_(0), playing_(false), loaded_(false), + loop_delay_ms_(0), loop_waiting_(false), loop_wait_start_(0) { + if (!img_dsc || !img_dsc->data) { + ESP_LOGE(TAG, "Invalid image descriptor"); + return; + } + + gif_ = gd_open_gif_data(img_dsc->data); + if (!gif_) { + ESP_LOGE(TAG, "Failed to open GIF from image descriptor"); + return; + } + + // Setup LVGL image descriptor + memset(&img_dsc_, 0, sizeof(img_dsc_)); + img_dsc_.header.magic = LV_IMAGE_HEADER_MAGIC; + img_dsc_.header.flags = LV_IMAGE_FLAGS_MODIFIABLE; + img_dsc_.header.cf = LV_COLOR_FORMAT_ARGB8888; + img_dsc_.header.w = gif_->width; + img_dsc_.header.h = gif_->height; + img_dsc_.header.stride = gif_->width * 4; + img_dsc_.data = gif_->canvas; + img_dsc_.data_size = gif_->width * gif_->height * 4; + + // Render first frame + if (gif_->canvas) { + gd_render_frame(gif_, gif_->canvas); + } + + loaded_ = true; + ESP_LOGD(TAG, "GIF loaded from image descriptor: %dx%d", gif_->width, gif_->height); +} + +// Destructor +LvglGif::~LvglGif() { + Cleanup(); +} + +// LvglImage interface implementation +const lv_img_dsc_t* LvglGif::image_dsc() const { + if (!loaded_) { + return nullptr; + } + return &img_dsc_; +} + +// Animation control methods +void LvglGif::Start() { + if (!loaded_ || !gif_) { + ESP_LOGW(TAG, "GIF not loaded, cannot start"); + return; + } + + if (!timer_) { + timer_ = lv_timer_create([](lv_timer_t* timer) { + LvglGif* gif_obj = static_cast(lv_timer_get_user_data(timer)); + gif_obj->NextFrame(); + }, 10, this); + } + + if (timer_) { + playing_ = true; + loop_waiting_ = false; // Reset loop waiting state + last_call_ = lv_tick_get(); + lv_timer_resume(timer_); + lv_timer_reset(timer_); + + // Render first frame + NextFrame(); + + ESP_LOGD(TAG, "GIF animation started"); + } +} + +void LvglGif::Pause() { + if (timer_) { + playing_ = false; + lv_timer_pause(timer_); + ESP_LOGD(TAG, "GIF animation paused"); + } +} + +void LvglGif::Resume() { + if (!loaded_ || !gif_) { + ESP_LOGW(TAG, "GIF not loaded, cannot resume"); + return; + } + + if (timer_) { + playing_ = true; + lv_timer_resume(timer_); + ESP_LOGD(TAG, "GIF animation resumed"); + } +} + +void LvglGif::Stop() { + if (timer_) { + playing_ = false; + lv_timer_pause(timer_); + } + + // Reset loop waiting state + loop_waiting_ = false; + + if (gif_) { + gd_rewind(gif_); + // Render first frame without advancing + if (gif_->canvas) { + gd_render_frame(gif_, gif_->canvas); + } + ESP_LOGD(TAG, "GIF animation stopped and rewound"); + } +} + +bool LvglGif::IsPlaying() const { + return playing_; +} + +bool LvglGif::IsLoaded() const { + return loaded_; +} + +int32_t LvglGif::GetLoopCount() const { + if (!loaded_ || !gif_) { + return -1; + } + return gif_->loop_count; +} + +void LvglGif::SetLoopCount(int32_t count) { + if (!loaded_ || !gif_) { + ESP_LOGW(TAG, "GIF not loaded, cannot set loop count"); + return; + } + gif_->loop_count = count; +} + +uint32_t LvglGif::GetLoopDelay() const { + return loop_delay_ms_; +} + +void LvglGif::SetLoopDelay(uint32_t delay_ms) { + loop_delay_ms_ = delay_ms; + ESP_LOGD(TAG, "Loop delay set to %lu ms", delay_ms); +} + +uint16_t LvglGif::width() const { + if (!loaded_ || !gif_) { + return 0; + } + return gif_->width; +} + +uint16_t LvglGif::height() const { + if (!loaded_ || !gif_) { + return 0; + } + return gif_->height; +} + +void LvglGif::SetFrameCallback(std::function callback) { + frame_callback_ = callback; +} + +void LvglGif::NextFrame() { + if (!loaded_ || !gif_ || !playing_) { + return; + } + + // Check if we're in loop wait state (only for infinite loop GIFs with delay) + if (loop_waiting_) { + uint32_t wait_elapsed = lv_tick_elaps(loop_wait_start_); + if (wait_elapsed < loop_delay_ms_) { + // Still waiting for loop delay + return; + } + // Loop delay completed, continue playing + loop_waiting_ = false; + ESP_LOGD(TAG, "Loop delay completed, continuing GIF"); + } + + // Check if enough time has passed for the next frame + uint32_t elapsed = lv_tick_elaps(last_call_); + if (elapsed < gif_->gce.delay * 10) { + return; + } + + last_call_ = lv_tick_get(); + + // Save file position before getting next frame to detect loop + uint32_t pos_before = gif_->f_rw_p; + + // Get next frame + int has_next = gd_get_frame(gif_); + if (has_next == 0) { + // Animation truly finished (non-infinite loop) + playing_ = false; + if (timer_) { + lv_timer_pause(timer_); + } + ESP_LOGD(TAG, "GIF animation completed"); + return; + } + + // Detect loop by checking if file position jumped back (rewound to start) + // This works for looping GIFs regardless of when loop_count is set + if (loop_delay_ms_ > 0 && gif_->f_rw_p < pos_before) { + // File position decreased, meaning GIF looped back to beginning + // Start waiting before rendering this frame + loop_waiting_ = true; + loop_wait_start_ = lv_tick_get(); + ESP_LOGD(TAG, "GIF completed one cycle, waiting %lu ms before next loop", loop_delay_ms_); + return; + } + + // Render current frame + if (gif_->canvas) { + gd_render_frame(gif_, gif_->canvas); + + // Call frame callback if set + if (frame_callback_) { + frame_callback_(); + } + } +} + +void LvglGif::Cleanup() { + // Stop and delete timer + if (timer_) { + lv_timer_delete(timer_); + timer_ = nullptr; + } + + // Close GIF decoder + if (gif_) { + gd_close_gif(gif_); + gif_ = nullptr; + } + + playing_ = false; + loaded_ = false; + + // Clear image descriptor + memset(&img_dsc_, 0, sizeof(img_dsc_)); +} diff --git a/main/display/lvgl_display/gif/lvgl_gif.h b/main/display/lvgl_display/gif/lvgl_gif.h new file mode 100644 index 0000000..17b9afd --- /dev/null +++ b/main/display/lvgl_display/gif/lvgl_gif.h @@ -0,0 +1,117 @@ +#pragma once + +#include "../lvgl_image.h" +#include "gifdec.h" +#include +#include +#include + +/** + * C++ implementation of LVGL GIF widget + * Provides GIF animation functionality using gifdec library + */ +class LvglGif { +public: + explicit LvglGif(const lv_img_dsc_t* img_dsc); + virtual ~LvglGif(); + + // LvglImage interface implementation + virtual const lv_img_dsc_t* image_dsc() const; + + /** + * Start/restart GIF animation + */ + void Start(); + + /** + * Pause GIF animation + */ + void Pause(); + + /** + * Resume GIF animation + */ + void Resume(); + + /** + * Stop GIF animation and rewind to first frame + */ + void Stop(); + + /** + * Check if GIF is currently playing + */ + bool IsPlaying() const; + + /** + * Check if GIF was loaded successfully + */ + bool IsLoaded() const; + + /** + * Get loop count + */ + int32_t GetLoopCount() const; + + /** + * Set loop count + */ + void SetLoopCount(int32_t count); + + /** + * Get loop delay in milliseconds (delay between loops) + */ + uint32_t GetLoopDelay() const; + + /** + * Set loop delay in milliseconds (delay between loops) + * @param delay_ms Delay in milliseconds before starting next loop. 0 means no delay. + */ + void SetLoopDelay(uint32_t delay_ms); + + /** + * Get GIF dimensions + */ + uint16_t width() const; + uint16_t height() const; + + /** + * Set frame update callback + */ + void SetFrameCallback(std::function callback); + +private: + // GIF decoder instance + gd_GIF* gif_; + + // LVGL image descriptor + lv_img_dsc_t img_dsc_; + + // Animation timer + lv_timer_t* timer_; + + // Last frame update time + uint32_t last_call_; + + // Animation state + bool playing_; + bool loaded_; + + // Loop delay configuration + uint32_t loop_delay_ms_; // Delay between loops in milliseconds + bool loop_waiting_; // Whether we're waiting for the next loop + uint32_t loop_wait_start_; // Timestamp when loop wait started + + // Frame update callback + std::function frame_callback_; + + /** + * Update to next frame + */ + void NextFrame(); + + /** + * Cleanup resources + */ + void Cleanup(); +}; diff --git a/main/display/lvgl_display/jpg/image_to_jpeg.cpp b/main/display/lvgl_display/jpg/image_to_jpeg.cpp new file mode 100644 index 0000000..485ca8b --- /dev/null +++ b/main/display/lvgl_display/jpg/image_to_jpeg.cpp @@ -0,0 +1,467 @@ +#include +#include +#include +#include +#include +#include + +#include "esp_jpeg_common.h" +#include "esp_jpeg_enc.h" +#include "esp_imgfx_color_convert.h" + +#if CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER +#include "driver/jpeg_encode.h" +#endif +#include "image_to_jpeg.h" + +#define TAG "image_to_jpeg" + +static void* malloc_psram(size_t size) { + void* p = malloc(size); + if (p) + return p; +#if (CONFIG_SPIRAM_SUPPORT && (CONFIG_SPIRAM_USE_CAPS_ALLOC || CONFIG_SPIRAM_USE_MALLOC)) + return heap_caps_malloc(size, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); +#else + return NULL; +#endif +} + +static __always_inline uint8_t expand_5_to_8(uint8_t v) { + return (uint8_t)((v << 3) | (v >> 2)); +} + +static __always_inline uint8_t expand_6_to_8(uint8_t v) { + return (uint8_t)((v << 2) | (v >> 4)); +} + +static uint8_t* convert_input_to_encoder_buf(const uint8_t* src, uint16_t width, uint16_t height, v4l2_pix_fmt_t format, + jpeg_pixel_format_t* out_fmt, int* out_size) { + // GRAY 直接作为 JPEG_PIXEL_FORMAT_GRAY 输入 + if (format == V4L2_PIX_FMT_GREY) { + int sz = (int)width * (int)height; + uint8_t* buf = (uint8_t*)jpeg_calloc_align(sz, 16); + if (!buf) + return NULL; + memcpy(buf, src, sz); + if (out_fmt) + *out_fmt = JPEG_PIXEL_FORMAT_GRAY; + if (out_size) + *out_size = sz; + return buf; + } + + // V4L2 YUYV (Y Cb Y Cr) 可直接作为 JPEG_PIXEL_FORMAT_YCbYCr 输入 + if (format == V4L2_PIX_FMT_YUYV) { + int sz = (int)width * (int)height * 2; + uint8_t* buf = (uint8_t*)jpeg_calloc_align(sz, 16); + if (!buf) + return NULL; + memcpy(buf, src, sz); + if (out_fmt) + *out_fmt = JPEG_PIXEL_FORMAT_YCbYCr; + if (out_size) + *out_size = sz; + return buf; + } + + // V4L2 UYVY (Cb Y Cr Y) -> 重排为 YUYV 再作为 YCbYCr 输入 + // 当前版本暂时不会出现 UYVY 格式 + if (format == V4L2_PIX_FMT_UYVY) [[unlikely]] { + int sz = (int)width * (int)height * 2; + const uint8_t* s = src; + uint8_t* buf = (uint8_t*)jpeg_calloc_align(sz, 16); + if (!buf) + return NULL; + uint8_t* d = buf; + for (int i = 0; i < sz; i += 4) { + // src: Cb, Y0, Cr, Y1 -> dst: Y0, Cb, Y1, Cr + d[0] = s[1]; + d[1] = s[0]; + d[2] = s[3]; + d[3] = s[2]; + s += 4; + d += 4; + } + if (out_fmt) + *out_fmt = JPEG_PIXEL_FORMAT_YCbYCr; + if (out_size) + *out_size = sz; + return buf; + } + + // V4L2 YUV422P (YUV422 Planar) -> 重排为 YUYV (YCbYCr) + // 当前版本暂时不会出现 YUV422P 格式 + if (format == V4L2_PIX_FMT_YUV422P) [[unlikely]] { + int sz = (int)width * (int)height * 2; + const uint8_t* y_plane = src; + const uint8_t* u_plane = y_plane + (int)width * (int)height; + const uint8_t* v_plane = u_plane + ((int)width / 2) * (int)height; + uint8_t* buf = (uint8_t*)jpeg_calloc_align(sz, 16); + if (!buf) + return NULL; + uint8_t* dst = buf; + for (int y = 0; y < height; y++) { + const uint8_t* y_row = y_plane + y * (int)width; + const uint8_t* u_row = u_plane + y * ((int)width / 2); + const uint8_t* v_row = v_plane + y * ((int)width / 2); + for (int x = 0; x < width; x += 2) { + uint8_t y0 = y_row[x + 0]; + uint8_t y1 = y_row[x + 1]; + uint8_t cb = u_row[x / 2]; + uint8_t cr = v_row[x / 2]; + dst[0] = y0; + dst[1] = cb; + dst[2] = y1; + dst[3] = cr; + dst += 4; + } + } + if (out_fmt) + *out_fmt = JPEG_PIXEL_FORMAT_YCbYCr; + if (out_size) + *out_size = sz; + return buf; + } + + // RGB 转换为 YUV422 (YCbYCr) 再输入 + // 见 https://github.com/78/xiaozhi-esp32/issues/1380#issuecomment-3497156378 + else if (format == V4L2_PIX_FMT_RGB24 || format == V4L2_PIX_FMT_RGB565 || format == V4L2_PIX_FMT_RGB565X) { + esp_imgfx_pixel_fmt_t in_pixel_fmt = ESP_IMGFX_PIXEL_FMT_RGB888; + uint32_t src_len = 0; + switch (format) { + case V4L2_PIX_FMT_RGB24: + in_pixel_fmt = ESP_IMGFX_PIXEL_FMT_RGB888; + src_len = static_cast(width * height * 3); + break; + case V4L2_PIX_FMT_RGB565: + in_pixel_fmt = ESP_IMGFX_PIXEL_FMT_RGB565_LE; + src_len = static_cast(width * height * 2); + break; + [[unlikely]] case V4L2_PIX_FMT_RGB565X: // 当前版本暂时不会出现 RGB565X + in_pixel_fmt = ESP_IMGFX_PIXEL_FMT_RGB565_BE; + src_len = static_cast(width * height * 2); + break; + [[unlikely]] default: + ESP_LOGE(TAG, "[Unreachable Case] unsupported format: 0x%08lx", format); + std::unreachable(); + } + int sz = (int)width * (int)height * 2; + uint8_t* buf = (uint8_t*)jpeg_calloc_align(sz, 16); + if (!buf) + return nullptr; + esp_imgfx_color_convert_cfg_t convert_cfg = { + .in_res = {.width = static_cast(width), + .height = static_cast(height)}, + .in_pixel_fmt = in_pixel_fmt, + .out_pixel_fmt = ESP_IMGFX_PIXEL_FMT_YUYV, + .color_space_std = ESP_IMGFX_COLOR_SPACE_STD_BT601, + }; + esp_imgfx_color_convert_handle_t convert_handle = nullptr; + esp_imgfx_err_t err = esp_imgfx_color_convert_open(&convert_cfg, &convert_handle); + if (err != ESP_IMGFX_ERR_OK || convert_handle == nullptr) { + ESP_LOGE(TAG, "esp_imgfx_color_convert_open failed"); + jpeg_free_align(buf); + return nullptr; + } + esp_imgfx_data_t convert_input_data = { + .data = const_cast(src), + .data_len = static_cast(src_len), + }; + esp_imgfx_data_t convert_output_data = { + .data = buf, + .data_len = static_cast(sz), + }; + err = esp_imgfx_color_convert_process(convert_handle, &convert_input_data, &convert_output_data); + if (err != ESP_IMGFX_ERR_OK) { + ESP_LOGE(TAG, "esp_imgfx_color_convert_process failed"); + jpeg_free_align(buf); + return nullptr; + } + esp_imgfx_color_convert_close(convert_handle); + convert_handle = nullptr; + if (out_fmt) + *out_fmt = JPEG_PIXEL_FORMAT_YCbYCr; + if (out_size) + *out_size = sz; + return buf; + } + ESP_LOGE(TAG, "unsupported format: 0x%08lx", format); + if (out_size) + *out_size = 0; + return nullptr; +} + +#if CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER +static jpeg_encoder_handle_t s_hw_jpeg_handle = NULL; + +static bool hw_jpeg_ensure_inited(void) { + if (s_hw_jpeg_handle) { + return true; + } + jpeg_encode_engine_cfg_t eng_cfg = { + .intr_priority = 0, + .timeout_ms = 100, + }; + esp_err_t er = jpeg_new_encoder_engine(&eng_cfg, &s_hw_jpeg_handle); + if (er != ESP_OK) { + ESP_LOGE(TAG, "jpeg_new_encoder_engine failed: %d", (int)er); + s_hw_jpeg_handle = NULL; + return false; + } + return true; +} + +static uint8_t* convert_input_to_hw_encoder_buf(const uint8_t* src, uint16_t width, uint16_t height, v4l2_pix_fmt_t format, + jpeg_enc_input_format_t* out_fmt, int* out_size) { + if (format == V4L2_PIX_FMT_GREY) { + int sz = (int)width * (int)height; + uint8_t* buf = (uint8_t*)malloc_psram(sz); + if (!buf) + return NULL; + memcpy(buf, src, sz); + if (out_fmt) + *out_fmt = JPEG_ENCODE_IN_FORMAT_GRAY; + if (out_size) + *out_size = sz; + return buf; + } + + if (format == V4L2_PIX_FMT_RGB24) { + int sz = (int)width * (int)height * 3; + uint8_t* buf = (uint8_t*)malloc_psram(sz); + if (!buf) { + ESP_LOGE(TAG, "malloc_psram failed"); + return NULL; + } + memcpy(buf, src, sz); + if (out_fmt) + *out_fmt = JPEG_ENCODE_IN_FORMAT_RGB888; + if (out_size) + *out_size = sz; + return buf; + } + + if (format == V4L2_PIX_FMT_RGB565) { + int sz = (int)width * (int)height * 2; + uint8_t* buf = (uint8_t*)malloc_psram(sz); + if (!buf) + return NULL; + memcpy(buf, src, sz); + if (out_fmt) + *out_fmt = JPEG_ENCODE_IN_FORMAT_RGB565; + if (out_size) + *out_size = sz; + return buf; + } + + if (format == V4L2_PIX_FMT_YUYV) { + // 硬件需要 | Y1 V Y0 U | 的“大端”格式,因此需要 bswap16 + int sz = (int)width * (int)height * 2; + uint16_t* buf = (uint16_t*)malloc_psram(sz); + if (!buf) + return NULL; + const uint16_t* bsrc = (const uint16_t*)src; + for (int i = 0; i < sz / 2; i++) { + buf[i] = __builtin_bswap16(bsrc[i]); + } + if (out_fmt) + *out_fmt = JPEG_ENCODE_IN_FORMAT_YUV422; + if (out_size) + *out_size = sz; + return (uint8_t*)buf; + } + + return NULL; +} + +static bool encode_with_hw_jpeg(const uint8_t* src, size_t src_len, uint16_t width, uint16_t height, + v4l2_pix_fmt_t format, uint8_t quality, uint8_t** jpg_out, size_t* jpg_out_len, + jpg_out_cb cb, void* cb_arg) { + if (quality < 1) + quality = 1; + if (quality > 100) + quality = 100; + + jpeg_enc_input_format_t enc_src_type = JPEG_ENCODE_IN_FORMAT_RGB888; + int enc_in_size = 0; + uint8_t* enc_in = convert_input_to_hw_encoder_buf(src, width, height, format, &enc_src_type, &enc_in_size); + if (!enc_in) { + ESP_LOGW(TAG, "hw jpeg: unsupported format, fallback to sw"); + return false; + } + + if (!hw_jpeg_ensure_inited()) { + free(enc_in); + return false; + } + + jpeg_encode_cfg_t enc_cfg = {0}; + enc_cfg.width = width; + enc_cfg.height = height; + enc_cfg.src_type = enc_src_type; + enc_cfg.image_quality = quality; + enc_cfg.sub_sample = (enc_src_type == JPEG_ENCODE_IN_FORMAT_GRAY) ? JPEG_DOWN_SAMPLING_GRAY : JPEG_DOWN_SAMPLING_YUV422; + + size_t out_cap = (size_t)width * (size_t)height * 3 / 2 + 64 * 1024; + if (out_cap < 128 * 1024) + out_cap = 128 * 1024; + jpeg_encode_memory_alloc_cfg_t jpeg_enc_output_mem_cfg = { .buffer_direction = JPEG_ENC_ALLOC_OUTPUT_BUFFER }; + size_t out_cap_aligned = 0; + uint8_t* outbuf = (uint8_t*)jpeg_alloc_encoder_mem(out_cap, &jpeg_enc_output_mem_cfg, &out_cap_aligned); + if (!outbuf) { + free(enc_in); + ESP_LOGE(TAG, "alloc out buffer failed"); + return false; + } + + uint32_t out_len = 0; + esp_err_t er = jpeg_encoder_process(s_hw_jpeg_handle, &enc_cfg, enc_in, (uint32_t)enc_in_size, outbuf, (uint32_t)out_cap_aligned, &out_len); + free(enc_in); + + if (er != ESP_OK) { + free(outbuf); + ESP_LOGE(TAG, "jpeg_encoder_process failed: %d", (int)er); + return false; + } + + if (cb) { + cb(cb_arg, 0, outbuf, (size_t)out_len); + cb(cb_arg, 1, NULL, 0); + free(outbuf); + if (jpg_out) + *jpg_out = NULL; + if (jpg_out_len) + *jpg_out_len = 0; + return true; + } + + if (jpg_out && jpg_out_len) { + *jpg_out = outbuf; + *jpg_out_len = (size_t)out_len; + return true; + } + + free(outbuf); + return true; +} +#endif // CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER + +static bool encode_with_esp_new_jpeg(const uint8_t* src, size_t src_len, uint16_t width, uint16_t height, + v4l2_pix_fmt_t format, uint8_t quality, uint8_t** jpg_out, size_t* jpg_out_len, + jpg_out_cb cb, void* cb_arg) { + if (quality < 1) + quality = 1; + if (quality > 100) + quality = 100; + + jpeg_pixel_format_t enc_src_type = JPEG_PIXEL_FORMAT_RGB888; + int enc_in_size = 0; + uint8_t* enc_in = convert_input_to_encoder_buf(src, width, height, format, &enc_src_type, &enc_in_size); + if (!enc_in) { + ESP_LOGE(TAG, "alloc/convert input failed"); + return false; + } + + jpeg_enc_config_t cfg = DEFAULT_JPEG_ENC_CONFIG(); + cfg.width = width; + cfg.height = height; + cfg.src_type = enc_src_type; + cfg.subsampling = (enc_src_type == JPEG_PIXEL_FORMAT_GRAY) ? JPEG_SUBSAMPLE_GRAY : JPEG_SUBSAMPLE_420; + cfg.quality = quality; + cfg.rotate = JPEG_ROTATE_0D; + cfg.task_enable = false; + + jpeg_enc_handle_t h = NULL; + jpeg_error_t ret = jpeg_enc_open(&cfg, &h); + if (ret != JPEG_ERR_OK) { + jpeg_free_align(enc_in); + ESP_LOGE(TAG, "jpeg_enc_open failed: %d", (int)ret); + return false; + } + + // 估算输出缓冲区:宽高的 1.5 倍 + 64KB + size_t out_cap = (size_t)width * (size_t)height * 3 / 2 + 64 * 1024; + if (out_cap < 128 * 1024) + out_cap = 128 * 1024; + uint8_t* outbuf = (uint8_t*)malloc_psram(out_cap); + if (!outbuf) { + jpeg_enc_close(h); + jpeg_free_align(enc_in); + ESP_LOGE(TAG, "alloc out buffer failed"); + return false; + } + + int out_len = 0; + ret = jpeg_enc_process(h, enc_in, enc_in_size, outbuf, (int)out_cap, &out_len); + jpeg_enc_close(h); + jpeg_free_align(enc_in); + + if (ret != JPEG_ERR_OK) { + free(outbuf); + ESP_LOGE(TAG, "jpeg_enc_process failed: %d", (int)ret); + return false; + } + + if (cb) { + cb(cb_arg, 0, outbuf, (size_t)out_len); + cb(cb_arg, 1, NULL, 0); // 结束信号 + free(outbuf); + if (jpg_out) + *jpg_out = NULL; + if (jpg_out_len) + *jpg_out_len = 0; + return true; + } + + if (jpg_out && jpg_out_len) { + *jpg_out = outbuf; + *jpg_out_len = (size_t)out_len; + return true; + } + + free(outbuf); + return true; +} + +bool image_to_jpeg(uint8_t* src, size_t src_len, uint16_t width, uint16_t height, v4l2_pix_fmt_t format, + uint8_t quality, uint8_t** out, size_t* out_len) { +#ifdef CONFIG_XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + if (format == V4L2_PIX_FMT_JPEG) { + uint8_t * out_data = (uint8_t*)heap_caps_malloc(src_len, MALLOC_CAP_SPIRAM | MALLOC_CAP_8BIT); + if (!out_data) { + ESP_LOGE(TAG, "Failed to allocate memory for JPEG output"); + return false; + } + memcpy(out_data, src, src_len); + *out = out_data; + *out_len = src_len; + return true; + } +#endif // CONFIG_XIAOZHI_CAMERA_ALLOW_JPEG_INPUT +#if CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER + if (encode_with_hw_jpeg(src, src_len, width, height, format, quality, out, out_len, NULL, NULL)) { + return true; + } + // Fallback to esp_new_jpeg +#endif + return encode_with_esp_new_jpeg(src, src_len, width, height, format, quality, out, out_len, NULL, NULL); +} + +bool image_to_jpeg_cb(uint8_t* src, size_t src_len, uint16_t width, uint16_t height, v4l2_pix_fmt_t format, + uint8_t quality, jpg_out_cb cb, void* arg) { +#ifdef CONFIG_XIAOZHI_CAMERA_ALLOW_JPEG_INPUT + if (format == V4L2_PIX_FMT_JPEG) { + cb(arg, 0, src, src_len); + cb(arg, 1, nullptr, 0); // end signal + return true; + } +#endif // CONFIG_XIAOZHI_CAMERA_ALLOW_JPEG_INPUT +#if CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_ENCODER + if (encode_with_hw_jpeg(src, src_len, width, height, format, quality, NULL, NULL, cb, arg)) { + return true; + } + // Fallback to esp_new_jpeg +#endif + return encode_with_esp_new_jpeg(src, src_len, width, height, format, quality, NULL, NULL, cb, arg); +} diff --git a/main/display/lvgl_display/jpg/image_to_jpeg.h b/main/display/lvgl_display/jpg/image_to_jpeg.h new file mode 100644 index 0000000..27c5d31 --- /dev/null +++ b/main/display/lvgl_display/jpg/image_to_jpeg.h @@ -0,0 +1,86 @@ +// image_to_jpeg.h - 图像到JPEG转换的高效编码接口 +// 节省约8KB SRAM的JPEG编码实现 +#pragma once +#include "sdkconfig.h" +#ifndef CONFIG_IDF_TARGET_ESP32 + +#include +#include + +#if defined(CONFIG_IDF_TARGET_ESP32P4) || defined(CONFIG_IDF_TARGET_ESP32S3) +// ESP32-P4 使用 esp_video 组件提供的 V4L2 头文件 +#include +#else +// ESP32-S3 等其他芯片:定义常用的 V4L2 像素格式 +#define V4L2_PIX_FMT_RGB565 0x50424752 // 'RGBP' +#define V4L2_PIX_FMT_RGB565X 0x52474250 // 'PRGB' +#define V4L2_PIX_FMT_RGB24 0x33424752 // 'RGB3' +#define V4L2_PIX_FMT_YUYV 0x56595559 // 'YUYV' +#define V4L2_PIX_FMT_YUV422P 0x36315559 // 'YU16' +#define V4L2_PIX_FMT_YUV420 0x32315559 // 'YU12' +#define V4L2_PIX_FMT_GREY 0x59455247 // 'GREY' +#define V4L2_PIX_FMT_UYVY 0x59565955 // 'UYVY' +#define V4L2_PIX_FMT_JPEG 0x4745504A // 'JPEG' +#endif + +typedef uint32_t v4l2_pix_fmt_t; + +#ifdef __cplusplus +extern "C" +{ +#endif + + // JPEG输出回调函数类型 + // arg: 用户自定义参数, index: 当前数据索引, data: JPEG数据块, len: 数据块长度 + // 返回: 实际处理的字节数 + typedef size_t (*jpg_out_cb)(void *arg, size_t index, const void *data, size_t len); + + /** + * @brief 将图像格式高效转换为JPEG + * + * 这个函数使用优化的JPEG编码器进行编码,主要特点: + * - 节省约8KB的SRAM使用(静态变量改为堆分配) + * - 支持多种图像格式输入 + * - 高质量JPEG输出 + * + * @param src 源图像数据 + * @param src_len 源图像数据长度 + * @param width 图像宽度 + * @param height 图像高度 + * @param format 图像格式 (PIXFORMAT_RGB565, PIXFORMAT_RGB888, 等) + * @param quality JPEG质量 (1-100) + * @param out 输出JPEG数据指针 (需要调用者释放) + * @param out_len 输出JPEG数据长度 + * + * @return true 成功, false 失败 + */ + bool image_to_jpeg(uint8_t *src, size_t src_len, uint16_t width, uint16_t height, + v4l2_pix_fmt_t format, uint8_t quality, uint8_t **out, size_t *out_len); + + /** + * @brief 将图像格式转换为JPEG(回调版本) + * + * 使用回调函数处理JPEG输出数据,适合流式传输或分块处理: + * - 节省约8KB的SRAM使用(静态变量改为堆分配) + * - 支持流式输出,无需预分配大缓冲区 + * - 通过回调函数逐块处理JPEG数据 + * + * @param src 源图像数据 + * @param src_len 源图像数据长度 + * @param width 图像宽度 + * @param height 图像高度 + * @param format 图像格式 + * @param quality JPEG质量 (1-100) + * @param cb 输出回调函数 + * @param arg 传递给回调函数的用户参数 + * + * @return true 成功, false 失败 + */ + bool image_to_jpeg_cb(uint8_t *src, size_t src_len, uint16_t width, uint16_t height, + v4l2_pix_fmt_t format, uint8_t quality, jpg_out_cb cb, void *arg); + +#ifdef __cplusplus +} +#endif + +#endif // ndef CONFIG_IDF_TARGET_ESP32 diff --git a/main/display/lvgl_display/jpg/jpeg_to_image.c b/main/display/lvgl_display/jpg/jpeg_to_image.c new file mode 100644 index 0000000..da92455 --- /dev/null +++ b/main/display/lvgl_display/jpg/jpeg_to_image.c @@ -0,0 +1,264 @@ +#include +#include +#include +#include + +#include "esp_jpeg_common.h" +#include "esp_jpeg_dec.h" + +#include "jpeg_to_image.h" + +#ifdef CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE +#undef LOG_LOCAL_LEVEL +#define LOG_LOCAL_LEVEL MAX(CONFIG_LOG_DEFAULT_LEVEL, ESP_LOG_DEBUG) +#endif // CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE +#include + +#ifdef CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_DECODER +#include "driver/jpeg_decode.h" +#endif + +#define TAG "jpeg_to_image" + +static esp_err_t decode_with_new_jpeg(const uint8_t* src, size_t src_len, uint8_t** out, size_t* out_len, size_t* width, + size_t* height, size_t* stride) { + ESP_LOGD(TAG, "Decoding JPEG with software decoder"); + esp_err_t ret = ESP_OK; + jpeg_error_t jpeg_ret = JPEG_ERR_OK; + uint8_t* out_buf = NULL; + jpeg_dec_io_t jpeg_io = {0}; + jpeg_dec_header_info_t out_info = {0}; + + jpeg_dec_config_t config = DEFAULT_JPEG_DEC_CONFIG(); + config.output_type = JPEG_PIXEL_FORMAT_RGB565_LE; + config.rotate = JPEG_ROTATE_0D; + + jpeg_dec_handle_t jpeg_dec = NULL; + jpeg_ret = jpeg_dec_open(&config, &jpeg_dec); + if (jpeg_ret != JPEG_ERR_OK) { + ESP_LOGE(TAG, "Failed to open JPEG decoder"); + ret = ESP_FAIL; + goto jpeg_dec_failed; + } + + jpeg_io.inbuf = (uint8_t*)src; + jpeg_io.inbuf_len = (int)src_len; + + jpeg_ret = jpeg_dec_parse_header(jpeg_dec, &jpeg_io, &out_info); + if (jpeg_ret != JPEG_ERR_OK) { + ESP_LOGE(TAG, "Failed to parse JPEG header"); + ret = ESP_ERR_INVALID_ARG; + goto jpeg_dec_failed; + } + + ESP_LOGD(TAG, "JPEG header info: width=%d, height=%d", out_info.width, out_info.height); + + out_buf = jpeg_calloc_align(out_info.width * out_info.height * 2, 16); + if (out_buf == NULL) { + ESP_LOGE(TAG, "Failed to allocate memory for JPEG output buffer"); + ret = ESP_ERR_NO_MEM; + goto jpeg_dec_failed; + } + + jpeg_io.outbuf = out_buf; + jpeg_ret = jpeg_dec_process(jpeg_dec, &jpeg_io); + if (jpeg_ret != JPEG_ERR_OK) { + ESP_LOGE(TAG, "Failed to decode JPEG"); + ret = ESP_FAIL; + goto jpeg_dec_failed; + } + + ESP_LOG_BUFFER_HEXDUMP(TAG, out_buf, MIN(out_info.width * out_info.height * 2, 256), ESP_LOG_DEBUG); + + *out = out_buf; + out_buf = NULL; + *out_len = (size_t)(out_info.width * out_info.height * 2); + *width = (size_t)out_info.width; + *height = (size_t)out_info.height; + *stride = (size_t)out_info.width * 2; + jpeg_dec_close(jpeg_dec); + jpeg_dec = NULL; + + return ret; + +jpeg_dec_failed: + if (jpeg_dec) { + jpeg_dec_close(jpeg_dec); + jpeg_dec = NULL; + } + if (out_buf) { + jpeg_free_align(out_buf); + out_buf = NULL; + } + + *out = NULL; + *out_len = 0; + *width = 0; + *height = 0; + *stride = 0; + return ret; +} + +#ifdef CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_DECODER +static esp_err_t decode_with_hardware_jpeg(const uint8_t* src, size_t src_len, uint8_t** out, size_t* out_len, + size_t* width, size_t* height, size_t* stride) { + ESP_LOGD(TAG, "Decoding JPEG with hardware decoder"); + esp_err_t ret = ESP_OK; + + jpeg_decoder_handle_t jpeg_dec = NULL; + uint8_t* bit_stream = NULL; + uint8_t* out_buf = NULL; + size_t out_buf_len = 0; + size_t tx_buffer_size = 0; + size_t rx_buffer_size = 0; + + jpeg_decode_engine_cfg_t eng_cfg = { + .intr_priority = 1, + .timeout_ms = 1000, + }; + + jpeg_decode_cfg_t decode_cfg_rgb = { + .output_format = JPEG_DECODE_OUT_FORMAT_RGB565, + .rgb_order = JPEG_DEC_RGB_ELEMENT_ORDER_BGR, + }; + + ret = jpeg_new_decoder_engine(&eng_cfg, &jpeg_dec); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "Failed to create JPEG decoder engine"); + goto jpeg_hw_dec_failed; + } + + jpeg_decode_memory_alloc_cfg_t tx_mem_cfg = { + .buffer_direction = JPEG_DEC_ALLOC_INPUT_BUFFER, + }; + + jpeg_decode_memory_alloc_cfg_t rx_mem_cfg = { + .buffer_direction = JPEG_DEC_ALLOC_OUTPUT_BUFFER, + }; + + bit_stream = (uint8_t*)jpeg_alloc_decoder_mem(src_len, &tx_mem_cfg, &tx_buffer_size); + if (bit_stream == NULL || tx_buffer_size < src_len) { + ESP_LOGE(TAG, "Failed to allocate memory for JPEG bit stream"); + ret = ESP_ERR_NO_MEM; + goto jpeg_hw_dec_failed; + } + + memcpy(bit_stream, src, src_len); + + jpeg_decode_picture_info_t header_info; + ESP_GOTO_ON_ERROR(jpeg_decoder_get_info(bit_stream, src_len, &header_info), jpeg_hw_dec_failed, TAG, + "Failed to get JPEG header info"); + + ESP_LOGD(TAG, "JPEG header info: width=%d, height=%d, sample_method=%d", header_info.width, header_info.height, + (int)header_info.sample_method); + + switch (header_info.sample_method) { + case JPEG_DOWN_SAMPLING_GRAY: + case JPEG_DOWN_SAMPLING_YUV444: + out_buf_len = header_info.width * header_info.height * 2; + *stride = header_info.width * 2; + break; + case JPEG_DOWN_SAMPLING_YUV422: + case JPEG_DOWN_SAMPLING_YUV420: + out_buf_len = ((header_info.width + 15) & ~15) * ((header_info.height + 15) & ~15) * 2; + *stride = ((header_info.width + 15) & ~15) * 2; + break; + default: + ESP_LOGE(TAG, "Unsupported JPEG sample method"); + ret = ESP_ERR_NOT_SUPPORTED; + goto jpeg_hw_dec_failed; + } + + out_buf = (uint8_t*)jpeg_alloc_decoder_mem(out_buf_len, &rx_mem_cfg, &rx_buffer_size); + if (out_buf == NULL || rx_buffer_size < out_buf_len) { + ESP_LOGE(TAG, "Failed to allocate memory for JPEG output buffer"); + ret = ESP_ERR_NO_MEM; + goto jpeg_hw_dec_failed; + } + + uint32_t out_size = 0; + + ESP_GOTO_ON_ERROR( + jpeg_decoder_process(jpeg_dec, &decode_cfg_rgb, bit_stream, src_len, out_buf, out_buf_len, &out_size), + jpeg_hw_dec_failed, TAG, "Failed to decode JPEG"); + + ESP_LOGD(TAG, "Expected %d bytes, got %" PRIu32 " bytes", out_buf_len, out_size); + + if (out_size != out_buf_len) { + ESP_LOGE(TAG, "Decoded image size mismatch: Expected %zu bytes, got %" PRIu32 " bytes", out_buf_len, out_size); + ret = ESP_ERR_INVALID_SIZE; + goto jpeg_hw_dec_failed; + } + + if (header_info.sample_method == JPEG_DOWN_SAMPLING_GRAY) { + // convert GRAY8 to RGB565 + uint32_t i = header_info.width * header_info.height; + do { + --i; + uint8_t r = (out_buf[i] >> 3) & 0x1F; + uint8_t g = (out_buf[i] >> 2) & 0x3F; + // b is same as r + uint16_t rgb565 = (r << 11) | (g << 5) | r; + out_buf[2 * i + 1] = (rgb565 >> 8) & 0xFF; + out_buf[2 * i] = rgb565 & 0xFF; + } while (i != 0); + out_size = header_info.width * header_info.height * 2; + ESP_LOGD(TAG, "Converted GRAY8 to RGB565, new size: %zu", out_size); + } + + ESP_LOG_BUFFER_HEXDUMP(TAG, out_buf, MIN(out_size, 256), ESP_LOG_DEBUG); + + *out = out_buf; + out_buf = NULL; + *out_len = (size_t)out_size; + jpeg_del_decoder_engine(jpeg_dec); + jpeg_dec = NULL; + heap_caps_free(bit_stream); + bit_stream = NULL; + *width = header_info.width; + *height = header_info.height; + + return ret; + +jpeg_hw_dec_failed: + if (out_buf) { + heap_caps_free(out_buf); + out_buf = NULL; + } + if (bit_stream) { + heap_caps_free(bit_stream); + bit_stream = NULL; + } + if (jpeg_dec) { + jpeg_del_decoder_engine(jpeg_dec); + jpeg_dec = NULL; + } + *out = NULL; + *out_len = 0; + *width = 0; + *height = 0; + *stride = 0; + return ret; +} +#endif // CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_DECODER + +esp_err_t jpeg_to_image(const uint8_t* src, size_t src_len, uint8_t** out, size_t* out_len, size_t* width, + size_t* height, size_t* stride) { +#ifdef CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE + esp_log_level_set(TAG, ESP_LOG_DEBUG); +#endif // CONFIG_XIAOZHI_ENABLE_CAMERA_DEBUG_MODE + if (src == NULL || src_len == 0 || out == NULL || out_len == NULL || width == NULL || height == NULL || + stride == NULL) { + ESP_LOGE(TAG, "Invalid parameters"); + return ESP_ERR_INVALID_ARG; + } +#ifdef CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_DECODER + esp_err_t ret = decode_with_hardware_jpeg(src, src_len, out, out_len, width, height, stride); + if (ret == ESP_OK) { + return ret; + } + ESP_LOGW(TAG, "Failed to decode with hardware JPEG, fallback to software decoder"); + // Fallback to esp_new_jpeg +#endif + return decode_with_new_jpeg(src, src_len, out, out_len, width, height, stride); +} diff --git a/main/display/lvgl_display/jpg/jpeg_to_image.h b/main/display/lvgl_display/jpg/jpeg_to_image.h new file mode 100644 index 0000000..b33dcef --- /dev/null +++ b/main/display/lvgl_display/jpg/jpeg_to_image.h @@ -0,0 +1,62 @@ +#include "sdkconfig.h" +#ifndef CONFIG_IDF_TARGET_ESP32 + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/** + * @brief Decodes a JPEG image from memory to raw RGB565 pixel data + * + * This function attempts to decode a JPEG image using hardware acceleration first (if enabled), + * falling back to a software decoder if hardware decoding fails or is unavailable. + * + * @param[in] src Pointer to the JPEG bitstream in memory + * @param[in] src_len Length of the JPEG bitstream in bytes + * @param[out] out Pointer to a buffer pointer that will be set to the decoded image data. + * This buffer is allocated internally and MUST be freed by the caller using heap_caps_free(). + * @param[out] out_len Pointer to a variable that will receive the size of the decoded image data in bytes + * @param[out] width Pointer to a variable that will receive the image width in pixels + * @param[out] height Pointer to a variable that will receive the image height in pixels + * @param[out] stride Pointer to a variable that will receive the image stride in bytes + * + * @return ESP_OK on successful decoding + * @return ESP_ERR_INVALID_ARG on invalid parameters + * @return ESP_ERR_NO_MEM on memory allocation failure + * @return ESP_FAIL on failure + * + * @attention Memory Management for `*out`: + * - The function allocates memory for the decoded image internally + * - On success, the caller takes ownership of this memory and SHOULD free it using heap_caps_free() + * - On failure, `*out` is guaranteed to be NULL and no freeing is required + * - Example usage: + * @code{.c} + * uint8_t *image = NULL; + * size_t len, width, height; + * if (jpeg_to_image(jpeg_data, jpeg_len, &image, &len, &width, &height)) { + * // Use image data... + * heap_caps_free(image); // Critical: use heap_caps_free + * } + * @endcode + * + * @note Configuration dependency: + * - When CONFIG_XIAOZHI_ENABLE_HARDWARE_JPEG_DECODER is enabled, hardware acceleration is attempted first + * - Both hardware and software paths allocate memory that requires heap_caps_free() for deallocation + * - The decoded image format is always RGB565 (2 bytes per pixel) + * + * @note When using hardware decoder, the decoded image dimensions might be aligned up to 16-byte boundaries. + * For YUV420 or YUV422 compressed images, both width and height will be rounded up to the nearest multiple of 16. + * See details at + * + * + */ +esp_err_t jpeg_to_image(const uint8_t* src, size_t src_len, uint8_t** out, size_t* out_len, size_t* width, + size_t* height, size_t* stride); + +#ifdef __cplusplus +} +#endif + +#endif // CONFIG_IDF_TARGET_ESP32 \ No newline at end of file diff --git a/main/display/lvgl_display/lvgl_display.cc b/main/display/lvgl_display/lvgl_display.cc new file mode 100644 index 0000000..06c4b3c --- /dev/null +++ b/main/display/lvgl_display/lvgl_display.cc @@ -0,0 +1,301 @@ +#include +#include +#include +#include +#include +#include + +#include "lvgl_display.h" +#include "board.h" +#include "application.h" +#include "audio_codec.h" +#include "settings.h" +#include "assets/lang_config.h" +#include "jpg/image_to_jpeg.h" + +#define TAG "Display" + +LvglDisplay::LvglDisplay() { + // Notification timer + esp_timer_create_args_t notification_timer_args = { + .callback = [](void *arg) { + LvglDisplay *display = static_cast(arg); + DisplayLockGuard lock(display); + lv_obj_add_flag(display->notification_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_remove_flag(display->status_label_, LV_OBJ_FLAG_HIDDEN); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "notification_timer", + .skip_unhandled_events = false, + }; + ESP_ERROR_CHECK(esp_timer_create(¬ification_timer_args, ¬ification_timer_)); + + // Create a power management lock + auto ret = esp_pm_lock_create(ESP_PM_APB_FREQ_MAX, 0, "display_update", &pm_lock_); + if (ret == ESP_ERR_NOT_SUPPORTED) { + ESP_LOGI(TAG, "Power management not supported"); + } else { + ESP_ERROR_CHECK(ret); + } +} + +LvglDisplay::~LvglDisplay() { + if (notification_timer_ != nullptr) { + esp_timer_stop(notification_timer_); + esp_timer_delete(notification_timer_); + } + + if (network_label_ != nullptr) { + lv_obj_del(network_label_); + } + if (notification_label_ != nullptr) { + lv_obj_del(notification_label_); + } + if (status_label_ != nullptr) { + lv_obj_del(status_label_); + } + if (time_label_ != nullptr) { + lv_obj_del(time_label_); + } + if (mute_label_ != nullptr) { + lv_obj_del(mute_label_); + } + if (battery_label_ != nullptr) { + lv_obj_del(battery_label_); + } + if( low_battery_popup_ != nullptr ) { + lv_obj_del(low_battery_popup_); + } + if (pm_lock_ != nullptr) { + esp_pm_lock_delete(pm_lock_); + } +} + +void LvglDisplay::SetStatus(const char* status) { + if (!setup_ui_called_) { + ESP_LOGW(TAG, "SetStatus('%s') called before SetupUI() - message will be lost!", status); + } + DisplayLockGuard lock(this); + if (status_label_ == nullptr) { + if (setup_ui_called_) { + ESP_LOGW(TAG, "SetStatus('%s') failed: status_label_ is nullptr (SetupUI() was called but label not created)", status); + } + return; + } + if (time_label_ != nullptr && strcmp(status, Lang::Strings::STANDBY) == 0) { + time_t now = time(NULL); + struct tm* tm = localtime(&now); + if (tm->tm_year >= 2025 - 1900) { + char time_str[16]; + strftime(time_str, sizeof(time_str), "%H:%M", tm); + lv_label_set_text(time_label_, time_str); + lv_obj_remove_flag(time_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(status_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(notification_label_, LV_OBJ_FLAG_HIDDEN); + last_status_update_time_ = std::chrono::system_clock::now(); + return; + } + } + if (time_label_ != nullptr) { + lv_obj_add_flag(time_label_, LV_OBJ_FLAG_HIDDEN); + } + lv_label_set_text(status_label_, status); + lv_obj_remove_flag(status_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(notification_label_, LV_OBJ_FLAG_HIDDEN); + + last_status_update_time_ = std::chrono::system_clock::now(); +} + +void LvglDisplay::ShowNotification(const std::string ¬ification, int duration_ms) { + ShowNotification(notification.c_str(), duration_ms); +} + +void LvglDisplay::ShowNotification(const char* notification, int duration_ms) { + if (!setup_ui_called_) { + ESP_LOGW(TAG, "ShowNotification('%s') called before SetupUI() - message will be lost!", notification); + } + DisplayLockGuard lock(this); + if (notification_label_ == nullptr) { + if (setup_ui_called_) { + ESP_LOGW(TAG, "ShowNotification('%s') failed: notification_label_ is nullptr (SetupUI() was called but label not created)", notification); + } + return; + } + lv_label_set_text(notification_label_, notification); + lv_obj_remove_flag(notification_label_, LV_OBJ_FLAG_HIDDEN); + lv_obj_add_flag(status_label_, LV_OBJ_FLAG_HIDDEN); + + esp_timer_stop(notification_timer_); + ESP_ERROR_CHECK(esp_timer_start_once(notification_timer_, duration_ms * 1000)); +} + +void LvglDisplay::UpdateStatusBar(bool update_all) { + auto& app = Application::GetInstance(); + auto& board = Board::GetInstance(); + auto codec = board.GetAudioCodec(); + + // Update mute icon + { + DisplayLockGuard lock(this); + if (mute_label_ == nullptr) { + return; + } + + // Update icon if mute state changes + if (codec->output_volume() == 0 && !muted_) { + muted_ = true; + lv_label_set_text(mute_label_, FONT_AWESOME_VOLUME_XMARK); + } else if (codec->output_volume() > 0 && muted_) { + muted_ = false; + lv_label_set_text(mute_label_, ""); + } + } + + // Update time + if (app.GetDeviceState() == kDeviceStateIdle) { + if (last_status_update_time_ + std::chrono::seconds(10) < std::chrono::system_clock::now()) { + // Set status to clock "HH:MM" + time_t now = time(NULL); + struct tm* tm = localtime(&now); + // Check if the we have already set the time + if (tm->tm_year >= 2025 - 1900) { + char time_str[16]; + strftime(time_str, sizeof(time_str), "%H:%M", tm); + if (time_label_ != nullptr) { + DisplayLockGuard lock(this); + lv_label_set_text(time_label_, time_str); + lv_obj_remove_flag(time_label_, LV_OBJ_FLAG_HIDDEN); + last_status_update_time_ = std::chrono::system_clock::now(); + } else { + SetStatus(time_str); + } + } else { + ESP_LOGW(TAG, "System time is not set, tm_year: %d", tm->tm_year); + } + } + } + + esp_pm_lock_acquire(pm_lock_); + // Update battery icon + int battery_level; + bool charging, discharging; + const char* icon = nullptr; + if (board.GetBatteryLevel(battery_level, charging, discharging)) { + if (charging) { + icon = FONT_AWESOME_BATTERY_BOLT; + } else { + const char* levels[] = { + FONT_AWESOME_BATTERY_EMPTY, // 0-19% + FONT_AWESOME_BATTERY_QUARTER, // 20-39% + FONT_AWESOME_BATTERY_HALF, // 40-59% + FONT_AWESOME_BATTERY_THREE_QUARTERS, // 60-79% + FONT_AWESOME_BATTERY_FULL, // 80-99% + FONT_AWESOME_BATTERY_FULL, // 100% + }; + icon = levels[battery_level / 20]; + } + DisplayLockGuard lock(this); + if (battery_label_ != nullptr && battery_icon_ != icon) { + battery_icon_ = icon; + lv_label_set_text(battery_label_, battery_icon_); + } + + // Check low battery popup only when clock tick event is triggered + // Because when initializing, the battery level is not ready yet. + if (low_battery_popup_ != nullptr && !update_all) { + if (strcmp(icon, FONT_AWESOME_BATTERY_EMPTY) == 0 && discharging) { + if (lv_obj_has_flag(low_battery_popup_, LV_OBJ_FLAG_HIDDEN)) { // Show if low battery popup is hidden + lv_obj_remove_flag(low_battery_popup_, LV_OBJ_FLAG_HIDDEN); + app.Schedule([&app]() { + app.PlaySound(Lang::Sounds::OGG_LOW_BATTERY); + }); + } + } else { + // Hide the low battery popup when the battery is not empty + if (!lv_obj_has_flag(low_battery_popup_, LV_OBJ_FLAG_HIDDEN)) { // Hide if low battery popup is shown + lv_obj_add_flag(low_battery_popup_, LV_OBJ_FLAG_HIDDEN); + } + } + } + } + + // Update network icon every 10 seconds + static int seconds_counter = 0; + if (update_all || seconds_counter++ % 10 == 0) { + // Don't read 4G network status during firmware upgrade to avoid occupying UART resources + auto device_state = Application::GetInstance().GetDeviceState(); + static const std::vector allowed_states = { + kDeviceStateIdle, + kDeviceStateStarting, + kDeviceStateWifiConfiguring, + kDeviceStateListening, + kDeviceStateActivating, + }; + if (std::find(allowed_states.begin(), allowed_states.end(), device_state) != allowed_states.end()) { + icon = board.GetNetworkStateIcon(); + if (network_label_ != nullptr && icon != nullptr && network_icon_ != icon) { + DisplayLockGuard lock(this); + network_icon_ = icon; + lv_label_set_text(network_label_, network_icon_); + } + } + } + + esp_pm_lock_release(pm_lock_); +} + +void LvglDisplay::SetPreviewImage(std::unique_ptr image) { +} + +void LvglDisplay::SetPowerSaveMode(bool on) { + if (on) { + SetChatMessage("system", ""); + SetEmotion("sleepy"); + } else { + SetChatMessage("system", ""); + SetEmotion("neutral"); + } +} + +bool LvglDisplay::SnapshotToJpeg(std::string& jpeg_data, int quality) { +#if CONFIG_LV_USE_SNAPSHOT + DisplayLockGuard lock(this); + + lv_obj_t* screen = lv_screen_active(); + lv_draw_buf_t* draw_buffer = lv_snapshot_take(screen, LV_COLOR_FORMAT_RGB565); + if (draw_buffer == nullptr) { + ESP_LOGE(TAG, "Failed to take snapshot, draw_buffer is nullptr"); + return false; + } + + // swap bytes + uint16_t* data = (uint16_t*)draw_buffer->data; + size_t pixel_count = draw_buffer->data_size / 2; + for (size_t i = 0; i < pixel_count; i++) { + data[i] = __builtin_bswap16(data[i]); + } + + // Clear output string and use callback version to avoid pre-allocating large memory blocks + jpeg_data.clear(); + + // Use callback-based JPEG encoder to further save memory + bool ret = image_to_jpeg_cb((uint8_t*)draw_buffer->data, draw_buffer->data_size, draw_buffer->header.w, draw_buffer->header.h, V4L2_PIX_FMT_RGB565, quality, + [](void *arg, size_t index, const void *data, size_t len) -> size_t { + std::string* output = static_cast(arg); + if (data && len > 0) { + output->append(static_cast(data), len); + } + return len; + }, &jpeg_data); + if (!ret) { + ESP_LOGE(TAG, "Failed to convert image to JPEG"); + } + + lv_draw_buf_destroy(draw_buffer); + return ret; +#else + ESP_LOGE(TAG, "LV_USE_SNAPSHOT is not enabled"); + return false; +#endif +} diff --git a/main/display/lvgl_display/lvgl_display.h b/main/display/lvgl_display/lvgl_display.h new file mode 100644 index 0000000..658e707 --- /dev/null +++ b/main/display/lvgl_display/lvgl_display.h @@ -0,0 +1,54 @@ +#ifndef LVGL_DISPLAY_H +#define LVGL_DISPLAY_H + +#include "display.h" +#include "lvgl_image.h" + +#include +#include +#include +#include + +#include +#include + +class LvglDisplay : public Display { +public: + LvglDisplay(); + virtual ~LvglDisplay(); + + virtual void SetStatus(const char* status); + virtual void ShowNotification(const char* notification, int duration_ms = 3000); + virtual void ShowNotification(const std::string ¬ification, int duration_ms = 3000); + virtual void SetPreviewImage(std::unique_ptr image); + virtual void UpdateStatusBar(bool update_all = false); + virtual void SetPowerSaveMode(bool on); + virtual bool SnapshotToJpeg(std::string& jpeg_data, int quality = 80); + +protected: + esp_pm_lock_handle_t pm_lock_ = nullptr; + lv_display_t *display_ = nullptr; + + lv_obj_t *network_label_ = nullptr; + lv_obj_t *status_label_ = nullptr; + lv_obj_t *time_label_ = nullptr; + lv_obj_t *notification_label_ = nullptr; + lv_obj_t *mute_label_ = nullptr; + lv_obj_t *battery_label_ = nullptr; + lv_obj_t* low_battery_popup_ = nullptr; + lv_obj_t* low_battery_label_ = nullptr; + + const char* battery_icon_ = nullptr; + const char* network_icon_ = nullptr; + bool muted_ = false; + + std::chrono::system_clock::time_point last_status_update_time_; + esp_timer_handle_t notification_timer_ = nullptr; + + friend class DisplayLockGuard; + virtual bool Lock(int timeout_ms = 0) = 0; + virtual void Unlock() = 0; +}; + + +#endif diff --git a/main/display/lvgl_display/lvgl_font.cc b/main/display/lvgl_display/lvgl_font.cc new file mode 100644 index 0000000..b0a45c7 --- /dev/null +++ b/main/display/lvgl_display/lvgl_font.cc @@ -0,0 +1,13 @@ +#include "lvgl_font.h" +#include + + +LvglCBinFont::LvglCBinFont(void* data) { + font_ = cbin_font_create(static_cast(data)); +} + +LvglCBinFont::~LvglCBinFont() { + if (font_ != nullptr) { + cbin_font_delete(font_); + } +} \ No newline at end of file diff --git a/main/display/lvgl_display/lvgl_font.h b/main/display/lvgl_display/lvgl_font.h new file mode 100644 index 0000000..d539dc0 --- /dev/null +++ b/main/display/lvgl_display/lvgl_font.h @@ -0,0 +1,31 @@ +#pragma once + +#include + + +class LvglFont { +public: + virtual const lv_font_t* font() const = 0; + virtual ~LvglFont() = default; +}; + +// Built-in font +class LvglBuiltInFont : public LvglFont { +public: + LvglBuiltInFont(const lv_font_t* font) : font_(font) {} + virtual const lv_font_t* font() const override { return font_; } + +private: + const lv_font_t* font_; +}; + + +class LvglCBinFont : public LvglFont { +public: + LvglCBinFont(void* data); + virtual ~LvglCBinFont(); + virtual const lv_font_t* font() const override { return font_; } + +private: + lv_font_t* font_; +}; diff --git a/main/display/lvgl_display/lvgl_image.cc b/main/display/lvgl_display/lvgl_image.cc new file mode 100644 index 0000000..eefc031 --- /dev/null +++ b/main/display/lvgl_display/lvgl_image.cc @@ -0,0 +1,64 @@ +#include "lvgl_image.h" +#include + +#include +#include +#include +#include + +#define TAG "LvglImage" + + +LvglRawImage::LvglRawImage(void* data, size_t size) { + bzero(&image_dsc_, sizeof(image_dsc_)); + image_dsc_.data_size = size; + image_dsc_.data = static_cast(data); + image_dsc_.header.magic = LV_IMAGE_HEADER_MAGIC; + image_dsc_.header.cf = LV_COLOR_FORMAT_RAW_ALPHA; + image_dsc_.header.w = 0; + image_dsc_.header.h = 0; +} + +bool LvglRawImage::IsGif() const { + auto ptr = (const uint8_t*)image_dsc_.data; + return ptr[0] == 'G' && ptr[1] == 'I' && ptr[2] == 'F'; +} + +LvglCBinImage::LvglCBinImage(void* data) { + image_dsc_ = cbin_img_dsc_create(static_cast(data)); +} + +LvglCBinImage::~LvglCBinImage() { + if (image_dsc_ != nullptr) { + cbin_img_dsc_delete(image_dsc_); + } +} + +LvglAllocatedImage::LvglAllocatedImage(void* data, size_t size) { + bzero(&image_dsc_, sizeof(image_dsc_)); + image_dsc_.data_size = size; + image_dsc_.data = static_cast(data); + + if (lv_image_decoder_get_info(&image_dsc_, &image_dsc_.header) != LV_RESULT_OK) { + ESP_LOGE(TAG, "Failed to get image info, data: %p size: %u", data, size); + throw std::runtime_error("Failed to get image info"); + } +} + +LvglAllocatedImage::LvglAllocatedImage(void* data, size_t size, int width, int height, int stride, int color_format) { + bzero(&image_dsc_, sizeof(image_dsc_)); + image_dsc_.data_size = size; + image_dsc_.data = static_cast(data); + image_dsc_.header.magic = LV_IMAGE_HEADER_MAGIC; + image_dsc_.header.cf = color_format; + image_dsc_.header.w = width; + image_dsc_.header.h = height; + image_dsc_.header.stride = stride; +} + +LvglAllocatedImage::~LvglAllocatedImage() { + if (image_dsc_.data) { + heap_caps_free((void*)image_dsc_.data); + image_dsc_.data = nullptr; + } +} \ No newline at end of file diff --git a/main/display/lvgl_display/lvgl_image.h b/main/display/lvgl_display/lvgl_image.h new file mode 100644 index 0000000..0bbf39b --- /dev/null +++ b/main/display/lvgl_display/lvgl_image.h @@ -0,0 +1,53 @@ +#pragma once + +#include + + +// Wrap around lv_img_dsc_t +class LvglImage { +public: + virtual const lv_img_dsc_t* image_dsc() const = 0; + virtual bool IsGif() const { return false; } + virtual ~LvglImage() = default; +}; + + +class LvglRawImage : public LvglImage { +public: + LvglRawImage(void* data, size_t size); + virtual const lv_img_dsc_t* image_dsc() const override { return &image_dsc_; } + virtual bool IsGif() const; + +private: + lv_img_dsc_t image_dsc_; +}; + +class LvglCBinImage : public LvglImage { +public: + LvglCBinImage(void* data); + virtual ~LvglCBinImage(); + virtual const lv_img_dsc_t* image_dsc() const override { return image_dsc_; } + +private: + lv_img_dsc_t* image_dsc_ = nullptr; +}; + +class LvglSourceImage : public LvglImage { +public: + LvglSourceImage(const lv_img_dsc_t* image_dsc) : image_dsc_(image_dsc) {} + virtual const lv_img_dsc_t* image_dsc() const override { return image_dsc_; } + +private: + const lv_img_dsc_t* image_dsc_; +}; + +class LvglAllocatedImage : public LvglImage { +public: + LvglAllocatedImage(void* data, size_t size); + LvglAllocatedImage(void* data, size_t size, int width, int height, int stride, int color_format); + virtual ~LvglAllocatedImage(); + virtual const lv_img_dsc_t* image_dsc() const override { return &image_dsc_; } + +private: + lv_img_dsc_t image_dsc_; +}; \ No newline at end of file diff --git a/main/display/lvgl_display/lvgl_theme.cc b/main/display/lvgl_display/lvgl_theme.cc new file mode 100644 index 0000000..f1bdffe --- /dev/null +++ b/main/display/lvgl_display/lvgl_theme.cc @@ -0,0 +1,30 @@ +#include "lvgl_theme.h" + +LvglTheme::LvglTheme(const std::string& name) : Theme(name) { +} + +lv_color_t LvglTheme::ParseColor(const std::string& color) { + if (color.find("#") == 0) { + // Convert #112233 to lv_color_t + uint8_t r = strtol(color.substr(1, 2).c_str(), nullptr, 16); + uint8_t g = strtol(color.substr(3, 2).c_str(), nullptr, 16); + uint8_t b = strtol(color.substr(5, 2).c_str(), nullptr, 16); + return lv_color_make(r, g, b); + } + return lv_color_black(); +} + +LvglThemeManager::LvglThemeManager() { +} + +LvglTheme* LvglThemeManager::GetTheme(const std::string& theme_name) { + auto it = themes_.find(theme_name); + if (it != themes_.end()) { + return it->second; + } + return nullptr; +} + +void LvglThemeManager::RegisterTheme(const std::string& theme_name, LvglTheme* theme) { + themes_[theme_name] = theme; +} diff --git a/main/display/lvgl_display/lvgl_theme.h b/main/display/lvgl_display/lvgl_theme.h new file mode 100644 index 0000000..85527a9 --- /dev/null +++ b/main/display/lvgl_display/lvgl_theme.h @@ -0,0 +1,94 @@ +#pragma once + +#include "display.h" +#include "lvgl_image.h" +#include "lvgl_font.h" +#include "emoji_collection.h" + +#include +#include +#include +#include + + +class LvglTheme : public Theme { +public: + static lv_color_t ParseColor(const std::string& color); + + LvglTheme(const std::string& name); + + // Properties + inline lv_color_t background_color() const { return background_color_; } + inline lv_color_t text_color() const { return text_color_; } + inline lv_color_t chat_background_color() const { return chat_background_color_; } + inline lv_color_t user_bubble_color() const { return user_bubble_color_; } + inline lv_color_t assistant_bubble_color() const { return assistant_bubble_color_; } + inline lv_color_t system_bubble_color() const { return system_bubble_color_; } + inline lv_color_t system_text_color() const { return system_text_color_; } + inline lv_color_t border_color() const { return border_color_; } + inline lv_color_t low_battery_color() const { return low_battery_color_; } + inline std::shared_ptr background_image() const { return background_image_; } + inline std::shared_ptr emoji_collection() const { return emoji_collection_; } + inline std::shared_ptr text_font() const { return text_font_; } + inline std::shared_ptr icon_font() const { return icon_font_; } + inline std::shared_ptr large_icon_font() const { return large_icon_font_; } + inline int spacing(int scale) const { return spacing_ * scale; } + + inline void set_background_color(lv_color_t background) { background_color_ = background; } + inline void set_text_color(lv_color_t text) { text_color_ = text; } + inline void set_chat_background_color(lv_color_t chat_background) { chat_background_color_ = chat_background; } + inline void set_user_bubble_color(lv_color_t user_bubble) { user_bubble_color_ = user_bubble; } + inline void set_assistant_bubble_color(lv_color_t assistant_bubble) { assistant_bubble_color_ = assistant_bubble; } + inline void set_system_bubble_color(lv_color_t system_bubble) { system_bubble_color_ = system_bubble; } + inline void set_system_text_color(lv_color_t system_text) { system_text_color_ = system_text; } + inline void set_border_color(lv_color_t border) { border_color_ = border; } + inline void set_low_battery_color(lv_color_t low_battery) { low_battery_color_ = low_battery; } + inline void set_background_image(std::shared_ptr background_image) { background_image_ = background_image; } + inline void set_emoji_collection(std::shared_ptr emoji_collection) { emoji_collection_ = emoji_collection; } + inline void set_text_font(std::shared_ptr text_font) { text_font_ = text_font; } + inline void set_icon_font(std::shared_ptr icon_font) { icon_font_ = icon_font; } + inline void set_large_icon_font(std::shared_ptr large_icon_font) { large_icon_font_ = large_icon_font; } + +private: + int spacing_ = 2; + + // Colors + lv_color_t background_color_; + lv_color_t text_color_; + lv_color_t chat_background_color_; + lv_color_t user_bubble_color_; + lv_color_t assistant_bubble_color_; + lv_color_t system_bubble_color_; + lv_color_t system_text_color_; + lv_color_t border_color_; + lv_color_t low_battery_color_; + + // Background image + std::shared_ptr background_image_ = nullptr; + + // fonts + std::shared_ptr text_font_ = nullptr; + std::shared_ptr icon_font_ = nullptr; + std::shared_ptr large_icon_font_ = nullptr; + + // Emoji collection + std::shared_ptr emoji_collection_ = nullptr; +}; + + +class LvglThemeManager { +public: + static LvglThemeManager& GetInstance() { + static LvglThemeManager instance; + return instance; + } + + void RegisterTheme(const std::string& theme_name, LvglTheme* theme); + LvglTheme* GetTheme(const std::string& theme_name); + +private: + LvglThemeManager(); + void InitializeDefaultThemes(); + + std::map themes_; +}; diff --git a/main/display/oled_display.cc b/main/display/oled_display.cc new file mode 100644 index 0000000..2083a50 --- /dev/null +++ b/main/display/oled_display.cc @@ -0,0 +1,408 @@ +#include "oled_display.h" +#include "assets/lang_config.h" +#include "lvgl_theme.h" +#include "lvgl_font.h" + +#include +#include + +#include +#include +#include +#include + +#define TAG "OledDisplay" + +LV_FONT_DECLARE(BUILTIN_TEXT_FONT); +LV_FONT_DECLARE(BUILTIN_ICON_FONT); +LV_FONT_DECLARE(font_awesome_30_1); + +OledDisplay::OledDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, + int width, int height, bool mirror_x, bool mirror_y) + : panel_io_(panel_io), panel_(panel) { + width_ = width; + height_ = height; + + auto text_font = std::make_shared(&BUILTIN_TEXT_FONT); + auto icon_font = std::make_shared(&BUILTIN_ICON_FONT); + auto large_icon_font = std::make_shared(&font_awesome_30_1); + + auto dark_theme = new LvglTheme("dark"); + dark_theme->set_text_font(text_font); + dark_theme->set_icon_font(icon_font); + dark_theme->set_large_icon_font(large_icon_font); + + auto& theme_manager = LvglThemeManager::GetInstance(); + theme_manager.RegisterTheme("dark", dark_theme); + current_theme_ = dark_theme; + + ESP_LOGI(TAG, "Initialize LVGL"); + lvgl_port_cfg_t port_cfg = ESP_LVGL_PORT_INIT_CONFIG(); + port_cfg.task_priority = 1; + port_cfg.task_stack = 6144; +#if CONFIG_SOC_CPU_CORES_NUM > 1 + port_cfg.task_affinity = 1; +#endif + lvgl_port_init(&port_cfg); + + ESP_LOGI(TAG, "Adding OLED display"); + const lvgl_port_display_cfg_t display_cfg = { + .io_handle = panel_io_, + .panel_handle = panel_, + .control_handle = nullptr, + .buffer_size = static_cast(width_ * height_), + .double_buffer = false, + .trans_size = 0, + .hres = static_cast(width_), + .vres = static_cast(height_), + .monochrome = true, + .rotation = { + .swap_xy = false, + .mirror_x = mirror_x, + .mirror_y = mirror_y, + }, + .flags = { + .buff_dma = 1, + .buff_spiram = 0, + .sw_rotate = 0, + .full_refresh = 0, + .direct_mode = 0, + }, + }; + + display_ = lvgl_port_add_disp(&display_cfg); + if (display_ == nullptr) { + ESP_LOGE(TAG, "Failed to add display"); + return; + } + + // Note: SetupUI() should be called by Application::Initialize(), not in constructor + // to ensure lvgl objects are created after the display is fully initialized. +} + +void OledDisplay::SetupUI() { + // Prevent duplicate calls - if already called, return early + if (setup_ui_called_) { + ESP_LOGW(TAG, "SetupUI() called multiple times, skipping duplicate call"); + return; + } + + Display::SetupUI(); // Mark SetupUI as called + if (height_ == 64) { + SetupUI_128x64(); + } else { + SetupUI_128x32(); + } +} + +OledDisplay::~OledDisplay() { + if (content_ != nullptr) { + lv_obj_del(content_); + } + + bool is_128x64_layout = (top_bar_ != nullptr); + if (status_bar_ != nullptr && is_128x64_layout) { + status_label_ = nullptr; + notification_label_ = nullptr; + lv_obj_del(status_bar_); + } + if (top_bar_ != nullptr) { + network_label_ = nullptr; + mute_label_ = nullptr; + battery_label_ = nullptr; + lv_obj_del(top_bar_); + } + if (side_bar_ != nullptr) { + if (!is_128x64_layout) { + status_label_ = nullptr; + notification_label_ = nullptr; + network_label_ = nullptr; + mute_label_ = nullptr; + battery_label_ = nullptr; + } + lv_obj_del(side_bar_); + } + if (container_ != nullptr) { + lv_obj_del(container_); + } + + if (panel_ != nullptr) { + esp_lcd_panel_del(panel_); + } + if (panel_io_ != nullptr) { + esp_lcd_panel_io_del(panel_io_); + } + lvgl_port_deinit(); +} + +bool OledDisplay::Lock(int timeout_ms) { + return lvgl_port_lock(timeout_ms); +} + +void OledDisplay::Unlock() { + lvgl_port_unlock(); +} + +void OledDisplay::SetChatMessage(const char* role, const char* content) { + DisplayLockGuard lock(this); + if (chat_message_label_ == nullptr) { + return; + } + + // Replace all newlines with spaces + std::string content_str = content; + std::replace(content_str.begin(), content_str.end(), '\n', ' '); + + if (content_right_ == nullptr) { + lv_label_set_text(chat_message_label_, content_str.c_str()); + } else { + if (content == nullptr || content[0] == '\0') { + lv_obj_add_flag(content_right_, LV_OBJ_FLAG_HIDDEN); + } else { + lv_label_set_text(chat_message_label_, content_str.c_str()); + lv_obj_remove_flag(content_right_, LV_OBJ_FLAG_HIDDEN); + } + } +} + +void OledDisplay::SetupUI_128x64() { + DisplayLockGuard lock(this); + + auto lvgl_theme = static_cast(current_theme_); + auto text_font = lvgl_theme->text_font()->font(); + auto icon_font = lvgl_theme->icon_font()->font(); + auto large_icon_font = lvgl_theme->large_icon_font()->font(); + + auto screen = lv_screen_active(); + lv_obj_set_style_text_font(screen, text_font, 0); + lv_obj_set_style_text_color(screen, lv_color_black(), 0); + + /* Container */ + container_ = lv_obj_create(screen); + lv_obj_set_size(container_, LV_HOR_RES, LV_VER_RES); + lv_obj_set_flex_flow(container_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(container_, 0, 0); + lv_obj_set_style_border_width(container_, 0, 0); + lv_obj_set_style_pad_row(container_, 0, 0); + + /* Layer 1: Top bar - for status icons */ + top_bar_ = lv_obj_create(container_); + lv_obj_set_size(top_bar_, LV_HOR_RES, 16); + lv_obj_set_style_radius(top_bar_, 0, 0); + lv_obj_set_style_bg_opa(top_bar_, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(top_bar_, 0, 0); + lv_obj_set_style_pad_all(top_bar_, 0, 0); + lv_obj_set_flex_flow(top_bar_, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(top_bar_, LV_FLEX_ALIGN_SPACE_BETWEEN, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + lv_obj_set_scrollbar_mode(top_bar_, LV_SCROLLBAR_MODE_OFF); + + network_label_ = lv_label_create(top_bar_); + lv_label_set_text(network_label_, ""); + lv_obj_set_style_text_font(network_label_, icon_font, 0); + + lv_obj_t* right_icons = lv_obj_create(top_bar_); + lv_obj_set_size(right_icons, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_bg_opa(right_icons, LV_OPA_TRANSP, 0); + lv_obj_set_style_border_width(right_icons, 0, 0); + lv_obj_set_style_pad_all(right_icons, 0, 0); + lv_obj_set_flex_flow(right_icons, LV_FLEX_FLOW_ROW); + lv_obj_set_flex_align(right_icons, LV_FLEX_ALIGN_END, LV_FLEX_ALIGN_CENTER, LV_FLEX_ALIGN_CENTER); + + mute_label_ = lv_label_create(right_icons); + lv_label_set_text(mute_label_, ""); + lv_obj_set_style_text_font(mute_label_, icon_font, 0); + + battery_label_ = lv_label_create(right_icons); + lv_label_set_text(battery_label_, ""); + lv_obj_set_style_text_font(battery_label_, icon_font, 0); + + /* Layer 2: Status bar - for center text labels */ + status_bar_ = lv_obj_create(screen); + lv_obj_set_size(status_bar_, LV_HOR_RES, 16); + lv_obj_set_style_radius(status_bar_, 0, 0); + lv_obj_set_style_bg_opa(status_bar_, LV_OPA_TRANSP, 0); // Transparent background + lv_obj_set_style_border_width(status_bar_, 0, 0); + lv_obj_set_style_pad_all(status_bar_, 0, 0); + lv_obj_set_scrollbar_mode(status_bar_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_layout(status_bar_, LV_LAYOUT_NONE, 0); // Use absolute positioning + lv_obj_align(status_bar_, LV_ALIGN_TOP_MID, 0, 0); // Overlap with top_bar_ + + notification_label_ = lv_label_create(status_bar_); + lv_obj_set_width(notification_label_, LV_HOR_RES); + lv_obj_set_style_text_align(notification_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(notification_label_, ""); + lv_obj_align(notification_label_, LV_ALIGN_CENTER, 0, 0); + lv_obj_add_flag(notification_label_, LV_OBJ_FLAG_HIDDEN); + + status_label_ = lv_label_create(status_bar_); + lv_obj_set_width(status_label_, LV_HOR_RES); + lv_label_set_long_mode(status_label_, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_align(status_label_, LV_TEXT_ALIGN_CENTER, 0); + lv_label_set_text(status_label_, Lang::Strings::INITIALIZING); + lv_obj_align(status_label_, LV_ALIGN_CENTER, 0, 0); + + /* Content */ + content_ = lv_obj_create(container_); + lv_obj_set_scrollbar_mode(content_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_style_radius(content_, 0, 0); + lv_obj_set_style_pad_all(content_, 0, 0); + lv_obj_set_width(content_, LV_HOR_RES); + lv_obj_set_flex_grow(content_, 1); + lv_obj_set_flex_flow(content_, LV_FLEX_FLOW_ROW); + lv_obj_set_style_flex_main_place(content_, LV_FLEX_ALIGN_CENTER, 0); + + content_left_ = lv_obj_create(content_); + lv_obj_set_size(content_left_, 32, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(content_left_, 0, 0); + lv_obj_set_style_border_width(content_left_, 0, 0); + + emotion_label_ = lv_label_create(content_left_); + lv_obj_set_style_text_font(emotion_label_, large_icon_font, 0); + lv_label_set_text(emotion_label_, FONT_AWESOME_MICROCHIP_AI); + lv_obj_center(emotion_label_); + lv_obj_set_style_pad_top(emotion_label_, 8, 0); + + content_right_ = lv_obj_create(content_); + lv_obj_set_size(content_right_, LV_SIZE_CONTENT, LV_SIZE_CONTENT); + lv_obj_set_style_pad_all(content_right_, 0, 0); + lv_obj_set_style_border_width(content_right_, 0, 0); + lv_obj_set_flex_grow(content_right_, 1); + lv_obj_add_flag(content_right_, LV_OBJ_FLAG_HIDDEN); + + chat_message_label_ = lv_label_create(content_right_); + lv_label_set_text(chat_message_label_, ""); + lv_label_set_long_mode(chat_message_label_, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_obj_set_style_text_align(chat_message_label_, LV_TEXT_ALIGN_LEFT, 0); + lv_obj_set_width(chat_message_label_, width_ - 32); + lv_obj_set_style_pad_top(chat_message_label_, 14, 0); + + // Start scrolling subtitle after a delay + static lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_delay(&a, 1000); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_obj_set_style_anim(chat_message_label_, &a, LV_PART_MAIN); + lv_obj_set_style_anim_duration(chat_message_label_, lv_anim_speed_clamped(60, 300, 60000), LV_PART_MAIN); + + low_battery_popup_ = lv_obj_create(screen); + lv_obj_set_scrollbar_mode(low_battery_popup_, LV_SCROLLBAR_MODE_OFF); + lv_obj_set_size(low_battery_popup_, LV_HOR_RES * 0.9, text_font->line_height * 2); + lv_obj_align(low_battery_popup_, LV_ALIGN_BOTTOM_MID, 0, 0); + lv_obj_set_style_bg_color(low_battery_popup_, lv_color_black(), 0); + lv_obj_set_style_radius(low_battery_popup_, 10, 0); + low_battery_label_ = lv_label_create(low_battery_popup_); + lv_label_set_text(low_battery_label_, Lang::Strings::BATTERY_NEED_CHARGE); + lv_obj_set_style_text_color(low_battery_label_, lv_color_white(), 0); + lv_obj_center(low_battery_label_); + lv_obj_add_flag(low_battery_popup_, LV_OBJ_FLAG_HIDDEN); +} + +void OledDisplay::SetupUI_128x32() { + DisplayLockGuard lock(this); + + auto lvgl_theme = static_cast(current_theme_); + auto text_font = lvgl_theme->text_font()->font(); + auto icon_font = lvgl_theme->icon_font()->font(); + auto large_icon_font = lvgl_theme->large_icon_font()->font(); + + auto screen = lv_screen_active(); + lv_obj_set_style_text_font(screen, text_font, 0); + + /* Container */ + container_ = lv_obj_create(screen); + lv_obj_set_size(container_, LV_HOR_RES, LV_VER_RES); + lv_obj_set_flex_flow(container_, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_all(container_, 0, 0); + lv_obj_set_style_border_width(container_, 0, 0); + lv_obj_set_style_pad_column(container_, 0, 0); + + /* Emotion label on the left side */ + content_ = lv_obj_create(container_); + lv_obj_set_size(content_, 32, 32); + lv_obj_set_style_pad_all(content_, 0, 0); + lv_obj_set_style_border_width(content_, 0, 0); + lv_obj_set_style_radius(content_, 0, 0); + + emotion_label_ = lv_label_create(content_); + lv_obj_set_style_text_font(emotion_label_, large_icon_font, 0); + lv_label_set_text(emotion_label_, FONT_AWESOME_MICROCHIP_AI); + lv_obj_center(emotion_label_); + + /* Right side */ + side_bar_ = lv_obj_create(container_); + lv_obj_set_size(side_bar_, width_ - 32, 32); + lv_obj_set_flex_flow(side_bar_, LV_FLEX_FLOW_COLUMN); + lv_obj_set_style_pad_all(side_bar_, 0, 0); + lv_obj_set_style_border_width(side_bar_, 0, 0); + lv_obj_set_style_radius(side_bar_, 0, 0); + lv_obj_set_style_pad_row(side_bar_, 0, 0); + + /* Status bar */ + status_bar_ = lv_obj_create(side_bar_); + lv_obj_set_size(status_bar_, width_ - 32, 16); + lv_obj_set_style_radius(status_bar_, 0, 0); + lv_obj_set_flex_flow(status_bar_, LV_FLEX_FLOW_ROW); + lv_obj_set_style_pad_all(status_bar_, 0, 0); + lv_obj_set_style_border_width(status_bar_, 0, 0); + lv_obj_set_style_pad_column(status_bar_, 0, 0); + + status_label_ = lv_label_create(status_bar_); + lv_obj_set_flex_grow(status_label_, 1); + lv_obj_set_style_pad_left(status_label_, 2, 0); + lv_label_set_text(status_label_, Lang::Strings::INITIALIZING); + + notification_label_ = lv_label_create(status_bar_); + lv_obj_set_flex_grow(notification_label_, 1); + lv_obj_set_style_pad_left(notification_label_, 2, 0); + lv_label_set_text(notification_label_, ""); + lv_obj_add_flag(notification_label_, LV_OBJ_FLAG_HIDDEN); + + mute_label_ = lv_label_create(status_bar_); + lv_label_set_text(mute_label_, ""); + lv_obj_set_style_text_font(mute_label_, icon_font, 0); + + network_label_ = lv_label_create(status_bar_); + lv_label_set_text(network_label_, ""); + lv_obj_set_style_text_font(network_label_, icon_font, 0); + + battery_label_ = lv_label_create(status_bar_); + lv_label_set_text(battery_label_, ""); + lv_obj_set_style_text_font(battery_label_, icon_font, 0); + + chat_message_label_ = lv_label_create(side_bar_); + lv_obj_set_size(chat_message_label_, width_ - 32, LV_SIZE_CONTENT); + lv_obj_set_style_pad_left(chat_message_label_, 2, 0); + lv_label_set_long_mode(chat_message_label_, LV_LABEL_LONG_SCROLL_CIRCULAR); + lv_label_set_text(chat_message_label_, ""); + + // Start scrolling subtitle after a delay + static lv_anim_t a; + lv_anim_init(&a); + lv_anim_set_delay(&a, 1000); + lv_anim_set_repeat_count(&a, LV_ANIM_REPEAT_INFINITE); + lv_obj_set_style_anim(chat_message_label_, &a, LV_PART_MAIN); + lv_obj_set_style_anim_duration(chat_message_label_, lv_anim_speed_clamped(60, 300, 60000), LV_PART_MAIN); +} + +void OledDisplay::SetEmotion(const char* emotion) { + const char* utf8 = font_awesome_get_utf8(emotion); + DisplayLockGuard lock(this); + if (emotion_label_ == nullptr) { + return; + } + if (utf8 != nullptr) { + lv_label_set_text(emotion_label_, utf8); + } else { + lv_label_set_text(emotion_label_, FONT_AWESOME_NEUTRAL); + } +} + +void OledDisplay::SetTheme(Theme* theme) { + DisplayLockGuard lock(this); + + auto lvgl_theme = static_cast(theme); + auto text_font = lvgl_theme->text_font()->font(); + + auto screen = lv_screen_active(); + lv_obj_set_style_text_font(screen, text_font, 0); +} diff --git a/main/display/oled_display.h b/main/display/oled_display.h new file mode 100644 index 0000000..40dd184 --- /dev/null +++ b/main/display/oled_display.h @@ -0,0 +1,41 @@ +#ifndef OLED_DISPLAY_H +#define OLED_DISPLAY_H + +#include "lvgl_display.h" + +#include +#include + + +class OledDisplay : public LvglDisplay { +private: + esp_lcd_panel_io_handle_t panel_io_ = nullptr; + esp_lcd_panel_handle_t panel_ = nullptr; + + lv_obj_t* top_bar_ = nullptr; + lv_obj_t* status_bar_ = nullptr; + lv_obj_t* content_ = nullptr; + lv_obj_t* content_left_ = nullptr; + lv_obj_t* content_right_ = nullptr; + lv_obj_t* container_ = nullptr; + lv_obj_t* side_bar_ = nullptr; + lv_obj_t *emotion_label_ = nullptr; + lv_obj_t* chat_message_label_ = nullptr; + + virtual bool Lock(int timeout_ms = 0) override; + virtual void Unlock() override; + + void SetupUI_128x64(); + void SetupUI_128x32(); + +public: + OledDisplay(esp_lcd_panel_io_handle_t panel_io, esp_lcd_panel_handle_t panel, int width, int height, bool mirror_x, bool mirror_y); + ~OledDisplay(); + + virtual void SetupUI() override; + virtual void SetChatMessage(const char* role, const char* content) override; + virtual void SetEmotion(const char* emotion) override; + virtual void SetTheme(Theme* theme) override; +}; + +#endif // OLED_DISPLAY_H diff --git a/main/idf_component.yml b/main/idf_component.yml new file mode 100644 index 0000000..4bb061e --- /dev/null +++ b/main/idf_component.yml @@ -0,0 +1,127 @@ +## IDF Component Manager Manifest File +dependencies: + waveshare/esp_lcd_sh8601: 1.0.2 + espressif/esp_lcd_co5300: ^2.0.3 + espressif/esp_lcd_ili9341: ==1.2.0 + espressif/esp_lcd_gc9a01: ==2.0.1 + espressif/esp_lcd_st77916: ^1.0.1 + espressif/esp_lcd_axs15231b: ^1.0.0 + + espressif/esp_lcd_st7701: + version: ^1.1.4 + rules: + - if: target in [esp32s3, esp32p4] + espressif/esp_lcd_st7796: + version: 1.3.5 + rules: + - if: target in [esp32, esp32s2, esp32s3, esp32p4] + espressif/esp_lcd_spd2010: ==1.0.2 + espressif/esp_io_expander_tca9554: ==2.0.0 + waveshare/custom_io_expander_ch32v003: ^1.0.0 + espressif/esp_lcd_panel_io_additions: ^1.0.1 + 78/esp_lcd_nv3023: ~1.0.0 + 78/esp-wifi-connect: ~3.1.1 + espressif/esp_audio_effects: ~1.2.1 + espressif/esp_audio_codec: ~2.4.1 + 78/esp-ml307: ~3.6.4 + 78/uart-eth-modem: + version: ~0.3.3 + rules: + - if: target not in [esp32] + 78/xiaozhi-fonts: ~1.6.0 + espressif/led_strip: ~3.0.2 + espressif/esp_codec_dev: ~1.5.4 + espressif/esp-sr: ~2.3.0 + espressif/button: ~4.1.5 + espressif/knob: ^1.0.0 + espressif/esp32-camera: + version: ^2.1.4 + rules: + - if: target in [esp32s3] + espressif/esp_video: + 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: + - if: target not in [esp32] + espressif/esp_lcd_touch_ft5x06: ~1.0.7 + espressif/esp_lcd_touch_gt911: ^1 + espressif/esp_lcd_touch_gt1151: ^1 + waveshare/esp_lcd_touch_cst9217: ^1.0.3 + espressif/esp_lcd_touch_cst816s: ^1.0.6 + lvgl/lvgl: ~9.4.0 + esp_lvgl_port: ~2.7.0 + espressif/esp_io_expander_tca95xx_16bit: ^2.0.0 + espressif2022/image_player: ^1.1.1 + espressif2022/esp_emote_expression: ^0.1.0 + espressif/adc_mic: ^0.2.1 + espressif/esp_mmap_assets: '>=1.2' + txp666/otto-emoji-gif-component: + version: ^1.1.1 + rules: + - if: target in [esp32s3] + espressif/adc_battery_estimation: ^0.2.0 + espressif/esp_new_jpeg: ^0.6.1 + + # SenseCAP Watcher Board + wvirgil123/sscma_client: + version: 1.0.2 + rules: + - if: target in [esp32s3] + + tny-robotics/sh1106-esp-idf: ^1.0.0 + espressif/esp_lcd_jd9365: + version: '*' + rules: + - if: target in [esp32p4] + waveshare/esp_lcd_st7703: + version: '*' + rules: + - if: target in [esp32p4] + espressif/esp32_p4_function_ev_board: + version: ^5.0.3 + rules: + - if: target in [esp32p4] + espressif/esp_lcd_ili9881c: + version: ^1.0.1 + rules: + - if: target in [esp32p4] + espressif/esp_lcd_ek79007: + version: ^1.0.3 + rules: + - if: target in [esp32p4] + espressif/esp_hosted: + version: 2.0.17 + rules: + - if: target in [esp32h2, esp32p4] + espressif/esp_wifi_remote: + version: '*' + rules: + - if: target in [esp32p4] + espfriends/servo_dog_ctrl: + version: ^0.1.8 + rules: + - if: target in [esp32c3] + + llgok/cpp_bus_driver: + version: 1.1.0 + rules: + - if: target in [esp32p4] + + espressif/bmi270_sensor: + version: ^0.1.0 + rules: + - if: target in [esp32s3, esp32c5] + + espressif/esp_lcd_touch_st7123: ^1.0.0 + espressif/iot_usbh_rndis: + version: ^0.3.1 + rules: + - if: target in [esp32s3, esp32p4] + + ## Required IDF version + idf: + version: '>=5.5.2' diff --git a/main/led/circular_strip.cc b/main/led/circular_strip.cc new file mode 100644 index 0000000..b4111ba --- /dev/null +++ b/main/led/circular_strip.cc @@ -0,0 +1,245 @@ +#include "circular_strip.h" +#include "application.h" +#include +#include + +#define TAG "CircularStrip" + +#define BLINK_INFINITE -1 + +CircularStrip::CircularStrip(gpio_num_t gpio, uint16_t max_leds) : max_leds_(max_leds) { + // If the gpio is not connected, you should use NoLed class + assert(gpio != GPIO_NUM_NC); + + colors_.resize(max_leds_); + + led_strip_config_t strip_config = {}; + strip_config.strip_gpio_num = gpio; + strip_config.max_leds = max_leds_; + strip_config.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB; + strip_config.led_model = LED_MODEL_WS2812; + + led_strip_rmt_config_t rmt_config = {}; + rmt_config.resolution_hz = 10 * 1000 * 1000; // 10MHz + + ESP_ERROR_CHECK(led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip_)); + led_strip_clear(led_strip_); + + esp_timer_create_args_t strip_timer_args = { + .callback = [](void *arg) { + auto strip = static_cast(arg); + std::lock_guard lock(strip->mutex_); + if (strip->strip_callback_ != nullptr) { + strip->strip_callback_(); + } + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "strip_timer", + .skip_unhandled_events = false, + }; + ESP_ERROR_CHECK(esp_timer_create(&strip_timer_args, &strip_timer_)); +} + +CircularStrip::~CircularStrip() { + esp_timer_stop(strip_timer_); + if (led_strip_ != nullptr) { + led_strip_del(led_strip_); + } +} + + +void CircularStrip::SetAllColor(StripColor color) { + std::lock_guard lock(mutex_); + esp_timer_stop(strip_timer_); + for (int i = 0; i < max_leds_; i++) { + colors_[i] = color; + led_strip_set_pixel(led_strip_, i, color.red, color.green, color.blue); + } + led_strip_refresh(led_strip_); +} + +void CircularStrip::SetSingleColor(uint8_t index, StripColor color) { + std::lock_guard lock(mutex_); + esp_timer_stop(strip_timer_); + colors_[index] = color; + led_strip_set_pixel(led_strip_, index, color.red, color.green, color.blue); + led_strip_refresh(led_strip_); +} + +void CircularStrip::SetMultiColors(const std::vector& colors) { + std::lock_guard lock(mutex_); + esp_timer_stop(strip_timer_); + int count = std::min(max_leds_, static_cast(colors.size())); + for (int i = 0; i < count; i++) { + colors_[i] = colors[i]; + led_strip_set_pixel(led_strip_, i, colors[i].red, colors[i].green, colors[i].blue); + } + led_strip_refresh(led_strip_); +} + +void CircularStrip::Blink(StripColor color, int interval_ms) { + for (int i = 0; i < max_leds_; i++) { + colors_[i] = color; + } + StartStripTask(interval_ms, [this]() { + static bool on = true; + if (on) { + for (int i = 0; i < max_leds_; i++) { + led_strip_set_pixel(led_strip_, i, colors_[i].red, colors_[i].green, colors_[i].blue); + } + led_strip_refresh(led_strip_); + } else { + led_strip_clear(led_strip_); + } + on = !on; + }); +} + +void CircularStrip::FadeOut(int interval_ms) { + StartStripTask(interval_ms, [this]() { + bool all_off = true; + for (int i = 0; i < max_leds_; i++) { + colors_[i].red /= 2; + colors_[i].green /= 2; + colors_[i].blue /= 2; + if (colors_[i].red != 0 || colors_[i].green != 0 || colors_[i].blue != 0) { + all_off = false; + } + led_strip_set_pixel(led_strip_, i, colors_[i].red, colors_[i].green, colors_[i].blue); + } + if (all_off) { + led_strip_clear(led_strip_); + esp_timer_stop(strip_timer_); + } else { + led_strip_refresh(led_strip_); + } + }); +} + +void CircularStrip::Breathe(StripColor low, StripColor high, int interval_ms) { + StartStripTask(interval_ms, [this, low, high]() { + static bool increase = true; + static StripColor color = low; + if (increase) { + if (color.red < high.red) { + color.red++; + } + if (color.green < high.green) { + color.green++; + } + if (color.blue < high.blue) { + color.blue++; + } + if (color.red == high.red && color.green == high.green && color.blue == high.blue) { + increase = false; + } + } else { + if (color.red > low.red) { + color.red--; + } + if (color.green > low.green) { + color.green--; + } + if (color.blue > low.blue) { + color.blue--; + } + if (color.red == low.red && color.green == low.green && color.blue == low.blue) { + increase = true; + } + } + for (int i = 0; i < max_leds_; i++) { + led_strip_set_pixel(led_strip_, i, color.red, color.green, color.blue); + } + led_strip_refresh(led_strip_); + }); +} + +void CircularStrip::Scroll(StripColor low, StripColor high, int length, int interval_ms) { + for (int i = 0; i < max_leds_; i++) { + colors_[i] = low; + } + StartStripTask(interval_ms, [this, low, high, length]() { + static int offset = 0; + for (int i = 0; i < max_leds_; i++) { + colors_[i] = low; + } + for (int j = 0; j < length; j++) { + int i = (offset + j) % max_leds_; + colors_[i] = high; + } + for (int i = 0; i < max_leds_; i++) { + led_strip_set_pixel(led_strip_, i, colors_[i].red, colors_[i].green, colors_[i].blue); + } + led_strip_refresh(led_strip_); + offset = (offset + 1) % max_leds_; + }); +} + +void CircularStrip::StartStripTask(int interval_ms, std::function cb) { + if (led_strip_ == nullptr) { + return; + } + + std::lock_guard lock(mutex_); + esp_timer_stop(strip_timer_); + + strip_callback_ = cb; + esp_timer_start_periodic(strip_timer_, interval_ms * 1000); +} + +void CircularStrip::SetBrightness(uint8_t default_brightness, uint8_t low_brightness) { + default_brightness_ = default_brightness; + low_brightness_ = low_brightness; + OnStateChanged(); +} + +void CircularStrip::OnStateChanged() { + auto& app = Application::GetInstance(); + auto device_state = app.GetDeviceState(); + switch (device_state) { + case kDeviceStateStarting: { + StripColor low = { 0, 0, 0 }; + StripColor high = { low_brightness_, low_brightness_, default_brightness_ }; + Scroll(low, high, 3, 100); + break; + } + case kDeviceStateWifiConfiguring: { + StripColor color = { low_brightness_, low_brightness_, default_brightness_ }; + Blink(color, 500); + break; + } + case kDeviceStateIdle: + FadeOut(50); + break; + case kDeviceStateConnecting: { + StripColor color = { low_brightness_, low_brightness_, default_brightness_ }; + SetAllColor(color); + break; + } + case kDeviceStateListening: + case kDeviceStateAudioTesting: { + StripColor color = { default_brightness_, low_brightness_, low_brightness_ }; + SetAllColor(color); + break; + } + case kDeviceStateSpeaking: { + StripColor color = { low_brightness_, default_brightness_, low_brightness_ }; + SetAllColor(color); + break; + } + case kDeviceStateUpgrading: { + StripColor color = { low_brightness_, default_brightness_, low_brightness_ }; + Blink(color, 100); + break; + } + case kDeviceStateActivating: { + StripColor color = { low_brightness_, default_brightness_, low_brightness_ }; + Blink(color, 500); + break; + } + default: + ESP_LOGW(TAG, "Unknown led strip event: %d", device_state); + return; + } +} diff --git a/main/led/circular_strip.h b/main/led/circular_strip.h new file mode 100644 index 0000000..8d931aa --- /dev/null +++ b/main/led/circular_strip.h @@ -0,0 +1,52 @@ +#ifndef _CIRCULAR_STRIP_H_ +#define _CIRCULAR_STRIP_H_ + +#include "led.h" +#include +#include +#include +#include +#include +#include + +#define DEFAULT_BRIGHTNESS 32 +#define LOW_BRIGHTNESS 4 + +struct StripColor { + uint8_t red = 0, green = 0, blue = 0; +}; + +class CircularStrip : public Led { +public: + CircularStrip(gpio_num_t gpio, uint16_t max_leds); + virtual ~CircularStrip(); + + void OnStateChanged() override; + void SetBrightness(uint8_t default_brightness, uint8_t low_brightness); + void SetAllColor(StripColor color); + void SetSingleColor(uint8_t index, StripColor color); + void SetMultiColors(const std::vector& colors); + void Blink(StripColor color, int interval_ms); + void Breathe(StripColor low, StripColor high, int interval_ms); + void Scroll(StripColor low, StripColor high, int length, int interval_ms); + +private: + std::mutex mutex_; + TaskHandle_t blink_task_ = nullptr; + led_strip_handle_t led_strip_ = nullptr; + int max_leds_ = 0; + std::vector colors_; + int blink_counter_ = 0; + int blink_interval_ms_ = 0; + esp_timer_handle_t strip_timer_ = nullptr; + std::function strip_callback_ = nullptr; + + uint8_t default_brightness_ = DEFAULT_BRIGHTNESS; + uint8_t low_brightness_ = LOW_BRIGHTNESS; + + void StartStripTask(int interval_ms, std::function cb); + void Rainbow(StripColor low, StripColor high, int interval_ms); + void FadeOut(int interval_ms); +}; + +#endif // _CIRCULAR_STRIP_H_ diff --git a/main/led/gpio_led.cc b/main/led/gpio_led.cc new file mode 100644 index 0000000..30eeeb2 --- /dev/null +++ b/main/led/gpio_led.cc @@ -0,0 +1,263 @@ +#include "gpio_led.h" +#include "application.h" +#include "device_state.h" +#include + +#define TAG "GpioLed" + +#define DEFAULT_BRIGHTNESS 50 +#define HIGH_BRIGHTNESS 100 +#define LOW_BRIGHTNESS 10 + +#define IDLE_BRIGHTNESS 5 +#define SPEAKING_BRIGHTNESS 75 +#define UPGRADING_BRIGHTNESS 25 +#define ACTIVATING_BRIGHTNESS 35 + +#define BLINK_INFINITE -1 + +// GPIO_LED +#define LEDC_LS_TIMER LEDC_TIMER_1 +#define LEDC_LS_MODE LEDC_LOW_SPEED_MODE +#define LEDC_LS_CH0_CHANNEL LEDC_CHANNEL_0 + +#define LEDC_DUTY (8191) +#define LEDC_FADE_TIME (1000) +// GPIO_LED + +GpioLed::GpioLed(gpio_num_t gpio) + : GpioLed(gpio, 0, LEDC_LS_TIMER, LEDC_LS_CH0_CHANNEL) { +} + +GpioLed::GpioLed(gpio_num_t gpio, int output_invert) + : GpioLed(gpio, output_invert, LEDC_LS_TIMER, LEDC_LS_CH0_CHANNEL) { +} + +GpioLed::GpioLed(gpio_num_t gpio, int output_invert, ledc_timer_t timer_num, ledc_channel_t channel) { + // If the gpio is not connected, you should use NoLed class + assert(gpio != GPIO_NUM_NC); + + /* + * Prepare and set configuration of timers + * that will be used by LED Controller + */ + ledc_timer_config_t ledc_timer = {}; + ledc_timer.duty_resolution = LEDC_TIMER_13_BIT; // resolution of PWM duty + ledc_timer.freq_hz = 4000; // frequency of PWM signal + ledc_timer.speed_mode = LEDC_LS_MODE; // timer mode + ledc_timer.timer_num = timer_num; // timer index + ledc_timer.clk_cfg = LEDC_AUTO_CLK; // Auto select the source clock + + ESP_ERROR_CHECK(ledc_timer_config(&ledc_timer)); + + ledc_channel_.channel = channel, + ledc_channel_.duty = 0, + ledc_channel_.gpio_num = gpio, + ledc_channel_.speed_mode = LEDC_LS_MODE, + ledc_channel_.hpoint = 0, + ledc_channel_.timer_sel = timer_num, + ledc_channel_.flags.output_invert = output_invert & 0x01, + + // Set LED Controller with previously prepared configuration + ledc_channel_config(&ledc_channel_); + + // Initialize fade service. + ledc_fade_func_install(0); + + // When the callback registered by ledc_cb_degister is called, run led ->OnFadeEnd() + ledc_cbs_t ledc_callbacks = { + .fade_cb = FadeCallback + }; + ledc_cb_register(ledc_channel_.speed_mode, ledc_channel_.channel, &ledc_callbacks, this); + + esp_timer_create_args_t blink_timer_args = { + .callback = [](void *arg) { + auto led = static_cast(arg); + led->OnBlinkTimer(); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "Blink Timer", + .skip_unhandled_events = false, + }; + ESP_ERROR_CHECK(esp_timer_create(&blink_timer_args, &blink_timer_)); + + xTaskCreate(EventTask, "LedEvent", 2048, this, + tskIDLE_PRIORITY + 2, &event_task_handle_); + + ledc_initialized_ = true; +} + +GpioLed::~GpioLed() { + esp_timer_stop(blink_timer_); + if (ledc_initialized_) { + ledc_fade_stop(ledc_channel_.speed_mode, ledc_channel_.channel); + ledc_fade_func_uninstall(); + } +} + + +void GpioLed::SetBrightness(uint8_t brightness) { + if (brightness == 100) { + duty_ = LEDC_DUTY; + } else { + duty_ = brightness * LEDC_DUTY / 100; + } +} + +void GpioLed::TurnOn() { + if (!ledc_initialized_) { + return; + } + + std::lock_guard lock(mutex_); + esp_timer_stop(blink_timer_); + ledc_fade_stop(ledc_channel_.speed_mode, ledc_channel_.channel); + ledc_set_duty(ledc_channel_.speed_mode, ledc_channel_.channel, duty_); + ledc_update_duty(ledc_channel_.speed_mode, ledc_channel_.channel); +} + +void GpioLed::TurnOff() { + if (!ledc_initialized_) { + return; + } + + std::lock_guard lock(mutex_); + esp_timer_stop(blink_timer_); + ledc_fade_stop(ledc_channel_.speed_mode, ledc_channel_.channel); + ledc_set_duty(ledc_channel_.speed_mode, ledc_channel_.channel, 0); + ledc_update_duty(ledc_channel_.speed_mode, ledc_channel_.channel); +} + +void GpioLed::BlinkOnce() { + Blink(1, 100); +} + +void GpioLed::Blink(int times, int interval_ms) { + StartBlinkTask(times, interval_ms); +} + +void GpioLed::StartContinuousBlink(int interval_ms) { + StartBlinkTask(BLINK_INFINITE, interval_ms); +} + +void GpioLed::StartBlinkTask(int times, int interval_ms) { + if (!ledc_initialized_) { + return; + } + + std::lock_guard lock(mutex_); + esp_timer_stop(blink_timer_); + ledc_fade_stop(ledc_channel_.speed_mode, ledc_channel_.channel); + + blink_counter_ = times * 2; + blink_interval_ms_ = interval_ms; + esp_timer_start_periodic(blink_timer_, interval_ms * 1000); +} + +void GpioLed::OnBlinkTimer() { + std::lock_guard lock(mutex_); + blink_counter_--; + if (blink_counter_ & 1) { + ledc_set_duty(ledc_channel_.speed_mode, ledc_channel_.channel, duty_); + } else { + ledc_set_duty(ledc_channel_.speed_mode, ledc_channel_.channel, 0); + + if (blink_counter_ == 0) { + esp_timer_stop(blink_timer_); + } + } + ledc_update_duty(ledc_channel_.speed_mode, ledc_channel_.channel); +} + +void GpioLed::StartFadeTask() { + if (!ledc_initialized_) { + return; + } + + std::lock_guard lock(mutex_); + esp_timer_stop(blink_timer_); + ledc_fade_stop(ledc_channel_.speed_mode, ledc_channel_.channel); + fade_up_ = true; + ledc_set_fade_with_time(ledc_channel_.speed_mode, + ledc_channel_.channel, LEDC_DUTY, LEDC_FADE_TIME); + ledc_fade_start(ledc_channel_.speed_mode, + ledc_channel_.channel, LEDC_FADE_NO_WAIT); +} + +void GpioLed::OnFadeEnd() { + std::lock_guard lock(mutex_); + fade_up_ = !fade_up_; + ledc_set_fade_with_time(ledc_channel_.speed_mode, + ledc_channel_.channel, fade_up_ ? LEDC_DUTY : 0, LEDC_FADE_TIME); + ledc_fade_start(ledc_channel_.speed_mode, + ledc_channel_.channel, LEDC_FADE_NO_WAIT); +} + +bool IRAM_ATTR GpioLed::FadeCallback(const ledc_cb_param_t *param, void *user_arg) { + if (param->event == LEDC_FADE_END_EVT) { + auto led = static_cast(user_arg); + BaseType_t xHigherPriorityTaskWoken = pdFALSE; + xTaskNotifyFromISR(led->event_task_handle_, 0x01, eSetValueWithOverwrite, + &xHigherPriorityTaskWoken); + } + return true; +} + +void GpioLed::OnStateChanged() { + auto& app = Application::GetInstance(); + auto device_state = app.GetDeviceState(); + switch (device_state) { + case kDeviceStateStarting: + SetBrightness(DEFAULT_BRIGHTNESS); + StartContinuousBlink(100); + break; + case kDeviceStateWifiConfiguring: + SetBrightness(DEFAULT_BRIGHTNESS); + StartContinuousBlink(500); + break; + case kDeviceStateIdle: + SetBrightness(IDLE_BRIGHTNESS); + TurnOn(); + // TurnOff(); + break; + case kDeviceStateConnecting: + SetBrightness(DEFAULT_BRIGHTNESS); + TurnOn(); + break; + case kDeviceStateListening: + case kDeviceStateAudioTesting: + if (app.IsVoiceDetected()) { + SetBrightness(HIGH_BRIGHTNESS); + } else { + SetBrightness(LOW_BRIGHTNESS); + } + // TurnOn(); + StartFadeTask(); + break; + case kDeviceStateSpeaking: + SetBrightness(SPEAKING_BRIGHTNESS); + TurnOn(); + break; + case kDeviceStateUpgrading: + SetBrightness(UPGRADING_BRIGHTNESS); + StartContinuousBlink(100); + break; + case kDeviceStateActivating: + SetBrightness(ACTIVATING_BRIGHTNESS); + StartContinuousBlink(500); + break; + default: + ESP_LOGE(TAG, "Unknown gpio led event: %d", device_state); + return; + } +} + +void GpioLed::EventTask(void* arg) { + GpioLed* led = static_cast(arg); + + while (1) { + ulTaskNotifyTake(pdTRUE, portMAX_DELAY); + led->OnFadeEnd(); + } +} \ No newline at end of file diff --git a/main/led/gpio_led.h b/main/led/gpio_led.h new file mode 100644 index 0000000..390d23c --- /dev/null +++ b/main/led/gpio_led.h @@ -0,0 +1,49 @@ +#ifndef _GPIO_LED_H_ +#define _GPIO_LED_H_ + +#include +#include +#include "led.h" +#include +#include +#include +#include +#include + +class GpioLed : public Led { + public: + GpioLed(gpio_num_t gpio); + GpioLed(gpio_num_t gpio, int output_invert); + GpioLed(gpio_num_t gpio, int output_invert, ledc_timer_t timer_num, ledc_channel_t channel); + virtual ~GpioLed(); + + void OnStateChanged() override; + void TurnOn(); + void TurnOff(); + void SetBrightness(uint8_t brightness); + + private: + std::mutex mutex_; + TaskHandle_t blink_task_ = nullptr; + ledc_channel_config_t ledc_channel_ = {0}; + bool ledc_initialized_ = false; + uint32_t duty_ = 0; + int blink_counter_ = 0; + int blink_interval_ms_ = 0; + esp_timer_handle_t blink_timer_ = nullptr; + bool fade_up_ = true; + TaskHandle_t event_task_handle_; + + static void EventTask(void* arg); + void StartBlinkTask(int times, int interval_ms); + void OnBlinkTimer(); + + void BlinkOnce(); + void Blink(int times, int interval_ms); + void StartContinuousBlink(int interval_ms); + void StartFadeTask(); + void OnFadeEnd(); + static bool IRAM_ATTR FadeCallback(const ledc_cb_param_t *param, void *user_arg); +}; + +#endif // _GPIO_LED_H_ diff --git a/main/led/led.h b/main/led/led.h new file mode 100644 index 0000000..251fd6a --- /dev/null +++ b/main/led/led.h @@ -0,0 +1,17 @@ +#ifndef _LED_H_ +#define _LED_H_ + +class Led { +public: + virtual ~Led() = default; + // Set the led state based on the device state + virtual void OnStateChanged() = 0; +}; + + +class NoLed : public Led { +public: + virtual void OnStateChanged() override {} +}; + +#endif // _LED_H_ diff --git a/main/led/single_led.cc b/main/led/single_led.cc new file mode 100644 index 0000000..338af0a --- /dev/null +++ b/main/led/single_led.cc @@ -0,0 +1,163 @@ +#include "single_led.h" +#include "application.h" +#include + +#define TAG "SingleLed" + +#define DEFAULT_BRIGHTNESS 4 +#define HIGH_BRIGHTNESS 16 +#define LOW_BRIGHTNESS 2 + +#define BLINK_INFINITE -1 + + +SingleLed::SingleLed(gpio_num_t gpio) { + // If the gpio is not connected, you should use NoLed class + assert(gpio != GPIO_NUM_NC); + + led_strip_config_t strip_config = {}; + strip_config.strip_gpio_num = gpio; + strip_config.max_leds = 1; + strip_config.color_component_format = LED_STRIP_COLOR_COMPONENT_FMT_GRB; + strip_config.led_model = LED_MODEL_WS2812; + + led_strip_rmt_config_t rmt_config = {}; + rmt_config.resolution_hz = 10 * 1000 * 1000; // 10MHz + + ESP_ERROR_CHECK(led_strip_new_rmt_device(&strip_config, &rmt_config, &led_strip_)); + led_strip_clear(led_strip_); + + esp_timer_create_args_t blink_timer_args = { + .callback = [](void *arg) { + auto led = static_cast(arg); + led->OnBlinkTimer(); + }, + .arg = this, + .dispatch_method = ESP_TIMER_TASK, + .name = "blink_timer", + .skip_unhandled_events = false, + }; + ESP_ERROR_CHECK(esp_timer_create(&blink_timer_args, &blink_timer_)); +} + +SingleLed::~SingleLed() { + esp_timer_stop(blink_timer_); + if (led_strip_ != nullptr) { + led_strip_del(led_strip_); + } +} + + +void SingleLed::SetColor(uint8_t r, uint8_t g, uint8_t b) { + r_ = r; + g_ = g; + b_ = b; +} + +void SingleLed::TurnOn() { + if (led_strip_ == nullptr) { + return; + } + + std::lock_guard lock(mutex_); + esp_timer_stop(blink_timer_); + led_strip_set_pixel(led_strip_, 0, r_, g_, b_); + led_strip_refresh(led_strip_); +} + +void SingleLed::TurnOff() { + if (led_strip_ == nullptr) { + return; + } + + std::lock_guard lock(mutex_); + esp_timer_stop(blink_timer_); + led_strip_clear(led_strip_); +} + +void SingleLed::BlinkOnce() { + Blink(1, 100); +} + +void SingleLed::Blink(int times, int interval_ms) { + StartBlinkTask(times, interval_ms); +} + +void SingleLed::StartContinuousBlink(int interval_ms) { + StartBlinkTask(BLINK_INFINITE, interval_ms); +} + +void SingleLed::StartBlinkTask(int times, int interval_ms) { + if (led_strip_ == nullptr) { + return; + } + + std::lock_guard lock(mutex_); + esp_timer_stop(blink_timer_); + + blink_counter_ = times * 2; + blink_interval_ms_ = interval_ms; + esp_timer_start_periodic(blink_timer_, interval_ms * 1000); +} + +void SingleLed::OnBlinkTimer() { + std::lock_guard lock(mutex_); + blink_counter_--; + if (blink_counter_ & 1) { + led_strip_set_pixel(led_strip_, 0, r_, g_, b_); + led_strip_refresh(led_strip_); + } else { + led_strip_clear(led_strip_); + + if (blink_counter_ == 0) { + esp_timer_stop(blink_timer_); + } + } +} + + +void SingleLed::OnStateChanged() { + auto& app = Application::GetInstance(); + auto device_state = app.GetDeviceState(); + switch (device_state) { + case kDeviceStateStarting: + SetColor(0, 0, DEFAULT_BRIGHTNESS); + StartContinuousBlink(100); + break; + case kDeviceStateWifiConfiguring: + SetColor(0, 0, DEFAULT_BRIGHTNESS); + StartContinuousBlink(500); + break; + case kDeviceStateIdle: + TurnOff(); + break; + case kDeviceStateConnecting: + SetColor(0, 0, DEFAULT_BRIGHTNESS); + TurnOn(); + break; + case kDeviceStateListening: + case kDeviceStateAudioTesting: + if (app.IsVoiceDetected()) { + SetColor(HIGH_BRIGHTNESS, 0, 0); + } else { + SetColor(LOW_BRIGHTNESS, 0, 0); + } + TurnOn(); + break; + case kDeviceStateSpeaking: + SetColor(0, DEFAULT_BRIGHTNESS, 0); + TurnOn(); + break; + case kDeviceStateUpgrading: + SetColor(0, DEFAULT_BRIGHTNESS, 0); + StartContinuousBlink(100); + break; + case kDeviceStateActivating: + SetColor(0, DEFAULT_BRIGHTNESS, 0); + StartContinuousBlink(500); + break; + default: + ESP_LOGW(TAG, "Unknown led strip event: %d", device_state); + return; + } +} diff --git a/main/led/single_led.h b/main/led/single_led.h new file mode 100644 index 0000000..b949f74 --- /dev/null +++ b/main/led/single_led.h @@ -0,0 +1,38 @@ +#ifndef _SINGLE_LED_H_ +#define _SINGLE_LED_H_ + +#include "led.h" +#include +#include +#include +#include +#include + +class SingleLed : public Led { +public: + SingleLed(gpio_num_t gpio); + virtual ~SingleLed(); + + void OnStateChanged() override; + +private: + std::mutex mutex_; + TaskHandle_t blink_task_ = nullptr; + led_strip_handle_t led_strip_ = nullptr; + uint8_t r_ = 0, g_ = 0, b_ = 0; + int blink_counter_ = 0; + int blink_interval_ms_ = 0; + esp_timer_handle_t blink_timer_ = nullptr; + + void StartBlinkTask(int times, int interval_ms); + void OnBlinkTimer(); + + void BlinkOnce(); + void Blink(int times, int interval_ms); + void StartContinuousBlink(int interval_ms); + void TurnOn(); + void TurnOff(); + void SetColor(uint8_t r, uint8_t g, uint8_t b); +}; + +#endif // _SINGLE_LED_H_ diff --git a/main/main.cc b/main/main.cc new file mode 100644 index 0000000..f7e8bc3 --- /dev/null +++ b/main/main.cc @@ -0,0 +1,29 @@ +#include +#include +#include +#include +#include +#include +#include +#include + +#include "application.h" + +#define TAG "main" + +extern "C" void app_main(void) +{ + // Initialize NVS flash for WiFi configuration + esp_err_t ret = nvs_flash_init(); + if (ret == ESP_ERR_NVS_NO_FREE_PAGES || ret == ESP_ERR_NVS_NEW_VERSION_FOUND) { + ESP_LOGW(TAG, "Erasing NVS flash to fix corruption"); + ESP_ERROR_CHECK(nvs_flash_erase()); + ret = nvs_flash_init(); + } + ESP_ERROR_CHECK(ret); + + // Initialize and run the application + auto& app = Application::GetInstance(); + app.Initialize(); + app.Run(); // This function runs the main event loop and never returns +} diff --git a/main/mcp_server.cc b/main/mcp_server.cc new file mode 100644 index 0000000..7cd7ba0 --- /dev/null +++ b/main/mcp_server.cc @@ -0,0 +1,563 @@ +/* + * MCP Server Implementation + * Reference: https://modelcontextprotocol.io/specification/2024-11-05 + */ + +#include "mcp_server.h" +#include +#include +#include +#include +#include + +#include "application.h" +#include "display.h" +#include "oled_display.h" +#include "board.h" +#include "settings.h" +#include "lvgl_theme.h" +#include "lvgl_display.h" + +#define TAG "MCP" + +McpServer::McpServer() { +} + +McpServer::~McpServer() { + for (auto tool : tools_) { + delete tool; + } + tools_.clear(); +} + +void McpServer::AddCommonTools() { + // *Important* To speed up the response time, we add the common tools to the beginning of + // the tools list to utilize the prompt cache. + // **重要** 为了提升响应速度,我们把常用的工具放在前面,利用 prompt cache 的特性。 + + // Backup the original tools list and restore it after adding the common tools. + auto original_tools = std::move(tools_); + auto& board = Board::GetInstance(); + + // Do not add custom tools here. + // Custom tools must be added in the board's InitializeTools function. + + AddTool("self.get_device_status", + "Provides the real-time information of the device, including the current status of the audio speaker, screen, battery, network, etc.\n" + "Use this tool for: \n" + "1. Answering questions about current condition (e.g. what is the current volume of the audio speaker?)\n" + "2. As the first step to control the device (e.g. turn up / down the volume of the audio speaker, etc.)", + PropertyList(), + [&board](const PropertyList& properties) -> ReturnValue { + return board.GetDeviceStatusJson(); + }); + + AddTool("self.audio_speaker.set_volume", + "Set the volume of the audio speaker. If the current volume is unknown, you must call `self.get_device_status` tool first and then call this tool.", + PropertyList({ + Property("volume", kPropertyTypeInteger, 0, 100) + }), + [&board](const PropertyList& properties) -> ReturnValue { + auto codec = board.GetAudioCodec(); + codec->SetOutputVolume(properties["volume"].value()); + return true; + }); + + auto backlight = board.GetBacklight(); + if (backlight) { + AddTool("self.screen.set_brightness", + "Set the brightness of the screen.", + PropertyList({ + Property("brightness", kPropertyTypeInteger, 0, 100) + }), + [backlight](const PropertyList& properties) -> ReturnValue { + uint8_t brightness = static_cast(properties["brightness"].value()); + backlight->SetBrightness(brightness, true); + return true; + }); + } + +#ifdef HAVE_LVGL + auto display = board.GetDisplay(); + if (display && display->GetTheme() != nullptr) { + AddTool("self.screen.set_theme", + "Set the theme of the screen. The theme can be `light` or `dark`.", + PropertyList({ + Property("theme", kPropertyTypeString) + }), + [display](const PropertyList& properties) -> ReturnValue { + auto theme_name = properties["theme"].value(); + auto& theme_manager = LvglThemeManager::GetInstance(); + auto theme = theme_manager.GetTheme(theme_name); + if (theme != nullptr) { + display->SetTheme(theme); + return true; + } + return false; + }); + } + + auto camera = board.GetCamera(); + if (camera) { + AddTool("self.camera.take_photo", + "Always remember you have a camera. If the user asks you to see something, use this tool to take a photo and then explain it.\n" + "Args:\n" + " `question`: The question that you want to ask about the photo.\n" + "Return:\n" + " A JSON object that provides the photo information.", + PropertyList({ + Property("question", kPropertyTypeString) + }), + [camera](const PropertyList& properties) -> ReturnValue { + // Lower the priority to do the camera capture + TaskPriorityReset priority_reset(1); + + if (!camera->Capture()) { + throw std::runtime_error("Failed to capture photo"); + } + auto question = properties["question"].value(); + return camera->Explain(question); + }); + } +#endif + + // Restore the original tools list to the end of the tools list + tools_.insert(tools_.end(), original_tools.begin(), original_tools.end()); +} + +void McpServer::AddUserOnlyTools() { + // System tools + AddUserOnlyTool("self.get_system_info", + "Get the system information", + PropertyList(), + [this](const PropertyList& properties) -> ReturnValue { + auto& board = Board::GetInstance(); + return board.GetSystemInfoJson(); + }); + + AddUserOnlyTool("self.reboot", "Reboot the system", + PropertyList(), + [this](const PropertyList& properties) -> ReturnValue { + auto& app = Application::GetInstance(); + app.Schedule([&app]() { + ESP_LOGW(TAG, "User requested reboot"); + vTaskDelay(pdMS_TO_TICKS(1000)); + + app.Reboot(); + }); + return true; + }); + + // Firmware upgrade + AddUserOnlyTool("self.upgrade_firmware", "Upgrade firmware from a specific URL. This will download and install the firmware, then reboot the device.", + PropertyList({ + Property("url", kPropertyTypeString, "The URL of the firmware binary file to download and install") + }), + [this](const PropertyList& properties) -> ReturnValue { + auto url = properties["url"].value(); + ESP_LOGI(TAG, "User requested firmware upgrade from URL: %s", url.c_str()); + + auto& app = Application::GetInstance(); + app.Schedule([url, &app]() { + bool success = app.UpgradeFirmware(url); + if (!success) { + ESP_LOGE(TAG, "Firmware upgrade failed"); + } + }); + + return true; + }); + + // Display control +#ifdef HAVE_LVGL + auto display = dynamic_cast(Board::GetInstance().GetDisplay()); + if (display) { + AddUserOnlyTool("self.screen.get_info", "Information about the screen, including width, height, etc.", + PropertyList(), + [display](const PropertyList& properties) -> ReturnValue { + cJSON *json = cJSON_CreateObject(); + cJSON_AddNumberToObject(json, "width", display->width()); + cJSON_AddNumberToObject(json, "height", display->height()); + if (dynamic_cast(display)) { + cJSON_AddBoolToObject(json, "monochrome", true); + } else { + cJSON_AddBoolToObject(json, "monochrome", false); + } + return json; + }); + +#if CONFIG_LV_USE_SNAPSHOT + AddUserOnlyTool("self.screen.snapshot", "Snapshot the screen and upload it to a specific URL", + PropertyList({ + Property("url", kPropertyTypeString), + Property("quality", kPropertyTypeInteger, 80, 1, 100) + }), + [display](const PropertyList& properties) -> ReturnValue { + auto url = properties["url"].value(); + auto quality = properties["quality"].value(); + + std::string jpeg_data; + if (!display->SnapshotToJpeg(jpeg_data, quality)) { + throw std::runtime_error("Failed to snapshot screen"); + } + + ESP_LOGI(TAG, "Upload snapshot %u bytes to %s", jpeg_data.size(), url.c_str()); + + // 构造multipart/form-data请求体 + std::string boundary = "----ESP32_SCREEN_SNAPSHOT_BOUNDARY"; + + auto http = Board::GetInstance().GetNetwork()->CreateHttp(3); + http->SetHeader("Content-Type", "multipart/form-data; boundary=" + boundary); + if (!http->Open("POST", url)) { + throw std::runtime_error("Failed to open URL: " + url); + } + { + // 文件字段头部 + std::string file_header; + file_header += "--" + boundary + "\r\n"; + file_header += "Content-Disposition: form-data; name=\"file\"; filename=\"screenshot.jpg\"\r\n"; + file_header += "Content-Type: image/jpeg\r\n"; + file_header += "\r\n"; + http->Write(file_header.c_str(), file_header.size()); + } + + // JPEG数据 + http->Write((const char*)jpeg_data.data(), jpeg_data.size()); + + { + // multipart尾部 + std::string multipart_footer; + multipart_footer += "\r\n--" + boundary + "--\r\n"; + http->Write(multipart_footer.c_str(), multipart_footer.size()); + } + http->Write("", 0); + + if (http->GetStatusCode() != 200) { + throw std::runtime_error("Unexpected status code: " + std::to_string(http->GetStatusCode())); + } + std::string result = http->ReadAll(); + http->Close(); + ESP_LOGI(TAG, "Snapshot screen result: %s", result.c_str()); + return true; + }); + + AddUserOnlyTool("self.screen.preview_image", "Preview an image on the screen", + PropertyList({ + Property("url", kPropertyTypeString) + }), + [display](const PropertyList& properties) -> ReturnValue { + auto url = properties["url"].value(); + auto http = Board::GetInstance().GetNetwork()->CreateHttp(3); + + if (!http->Open("GET", url)) { + throw std::runtime_error("Failed to open URL: " + url); + } + int status_code = http->GetStatusCode(); + if (status_code != 200) { + throw std::runtime_error("Unexpected status code: " + std::to_string(status_code)); + } + + size_t content_length = http->GetBodyLength(); + char* data = (char*)heap_caps_malloc(content_length, MALLOC_CAP_8BIT); + if (data == nullptr) { + throw std::runtime_error("Failed to allocate memory for image: " + url); + } + size_t total_read = 0; + while (total_read < content_length) { + int ret = http->Read(data + total_read, content_length - total_read); + if (ret < 0) { + heap_caps_free(data); + throw std::runtime_error("Failed to download image: " + url); + } + if (ret == 0) { + break; + } + total_read += ret; + } + http->Close(); + + auto image = std::make_unique(data, content_length); + display->SetPreviewImage(std::move(image)); + return true; + }); +#endif // CONFIG_LV_USE_SNAPSHOT + } +#endif // HAVE_LVGL + + // Assets download url + auto& assets = Assets::GetInstance(); + if (assets.partition_valid()) { + AddUserOnlyTool("self.assets.set_download_url", "Set the download url for the assets", + PropertyList({ + Property("url", kPropertyTypeString) + }), + [](const PropertyList& properties) -> ReturnValue { + auto url = properties["url"].value(); + Settings settings("assets", true); + settings.SetString("download_url", url); + return true; + }); + } +} + +void McpServer::AddTool(McpTool* tool) { + // Prevent adding duplicate tools + if (std::find_if(tools_.begin(), tools_.end(), [tool](const McpTool* t) { return t->name() == tool->name(); }) != tools_.end()) { + ESP_LOGW(TAG, "Tool %s already added", tool->name().c_str()); + return; + } + + ESP_LOGI(TAG, "Add tool: %s%s", tool->name().c_str(), tool->user_only() ? " [user]" : ""); + tools_.push_back(tool); +} + +void McpServer::AddTool(const std::string& name, const std::string& description, const PropertyList& properties, std::function callback) { + AddTool(new McpTool(name, description, properties, callback)); +} + +void McpServer::AddUserOnlyTool(const std::string& name, const std::string& description, const PropertyList& properties, std::function callback) { + auto tool = new McpTool(name, description, properties, callback); + tool->set_user_only(true); + AddTool(tool); +} + +void McpServer::ParseMessage(const std::string& message) { + cJSON* json = cJSON_Parse(message.c_str()); + if (json == nullptr) { + ESP_LOGE(TAG, "Failed to parse MCP message: %s", message.c_str()); + return; + } + ParseMessage(json); + cJSON_Delete(json); +} + +void McpServer::ParseCapabilities(const cJSON* capabilities) { + auto vision = cJSON_GetObjectItem(capabilities, "vision"); + if (cJSON_IsObject(vision)) { + auto url = cJSON_GetObjectItem(vision, "url"); + auto token = cJSON_GetObjectItem(vision, "token"); + if (cJSON_IsString(url)) { + auto camera = Board::GetInstance().GetCamera(); + if (camera) { + std::string url_str = std::string(url->valuestring); + std::string token_str; + if (cJSON_IsString(token)) { + token_str = std::string(token->valuestring); + } + camera->SetExplainUrl(url_str, token_str); + } + } + } +} + +void McpServer::ParseMessage(const cJSON* json) { + // Check JSONRPC version + auto version = cJSON_GetObjectItem(json, "jsonrpc"); + if (version == nullptr || !cJSON_IsString(version) || strcmp(version->valuestring, "2.0") != 0) { + ESP_LOGE(TAG, "Invalid JSONRPC version: %s", version ? version->valuestring : "null"); + return; + } + + // Check method + auto method = cJSON_GetObjectItem(json, "method"); + if (method == nullptr || !cJSON_IsString(method)) { + ESP_LOGE(TAG, "Missing method"); + return; + } + + auto method_str = std::string(method->valuestring); + if (method_str.find("notifications") == 0) { + return; + } + + // Check params + auto params = cJSON_GetObjectItem(json, "params"); + if (params != nullptr && !cJSON_IsObject(params)) { + ESP_LOGE(TAG, "Invalid params for method: %s", method_str.c_str()); + return; + } + + auto id = cJSON_GetObjectItem(json, "id"); + if (id == nullptr || !cJSON_IsNumber(id)) { + ESP_LOGE(TAG, "Invalid id for method: %s", method_str.c_str()); + return; + } + auto id_int = id->valueint; + + if (method_str == "initialize") { + if (cJSON_IsObject(params)) { + auto capabilities = cJSON_GetObjectItem(params, "capabilities"); + if (cJSON_IsObject(capabilities)) { + ParseCapabilities(capabilities); + } + } + auto app_desc = esp_app_get_description(); + std::string message = "{\"protocolVersion\":\"2024-11-05\",\"capabilities\":{\"tools\":{}},\"serverInfo\":{\"name\":\"" BOARD_NAME "\",\"version\":\""; + message += app_desc->version; + message += "\"}}"; + ReplyResult(id_int, message); + } else if (method_str == "tools/list") { + std::string cursor_str = ""; + bool list_user_only_tools = false; + if (params != nullptr) { + auto cursor = cJSON_GetObjectItem(params, "cursor"); + if (cJSON_IsString(cursor)) { + cursor_str = std::string(cursor->valuestring); + } + auto with_user_tools = cJSON_GetObjectItem(params, "withUserTools"); + if (cJSON_IsBool(with_user_tools)) { + list_user_only_tools = with_user_tools->valueint == 1; + } + } + GetToolsList(id_int, cursor_str, list_user_only_tools); + } else if (method_str == "tools/call") { + if (!cJSON_IsObject(params)) { + ESP_LOGE(TAG, "tools/call: Missing params"); + ReplyError(id_int, "Missing params"); + return; + } + auto tool_name = cJSON_GetObjectItem(params, "name"); + if (!cJSON_IsString(tool_name)) { + ESP_LOGE(TAG, "tools/call: Missing name"); + ReplyError(id_int, "Missing name"); + return; + } + auto tool_arguments = cJSON_GetObjectItem(params, "arguments"); + if (tool_arguments != nullptr && !cJSON_IsObject(tool_arguments)) { + ESP_LOGE(TAG, "tools/call: Invalid arguments"); + ReplyError(id_int, "Invalid arguments"); + return; + } + DoToolCall(id_int, std::string(tool_name->valuestring), tool_arguments); + } else { + ESP_LOGE(TAG, "Method not implemented: %s", method_str.c_str()); + ReplyError(id_int, "Method not implemented: " + method_str); + } +} + +void McpServer::ReplyResult(int id, const std::string& result) { + std::string payload = "{\"jsonrpc\":\"2.0\",\"id\":"; + payload += std::to_string(id) + ",\"result\":"; + payload += result; + payload += "}"; + Application::GetInstance().SendMcpMessage(payload); +} + +void McpServer::ReplyError(int id, const std::string& message) { + std::string payload = "{\"jsonrpc\":\"2.0\",\"id\":"; + payload += std::to_string(id); + payload += ",\"error\":{\"message\":\""; + payload += message; + payload += "\"}}"; + Application::GetInstance().SendMcpMessage(payload); +} + +void McpServer::GetToolsList(int id, const std::string& cursor, bool list_user_only_tools) { + const int max_payload_size = 8000; + std::string json = "{\"tools\":["; + + bool found_cursor = cursor.empty(); + auto it = tools_.begin(); + std::string next_cursor = ""; + + while (it != tools_.end()) { + // 如果我们还没有找到起始位置,继续搜索 + if (!found_cursor) { + if ((*it)->name() == cursor) { + found_cursor = true; + } else { + ++it; + continue; + } + } + + if (!list_user_only_tools && (*it)->user_only()) { + ++it; + continue; + } + + // 添加tool前检查大小 + std::string tool_json = (*it)->to_json() + ","; + if (json.length() + tool_json.length() + 30 > max_payload_size) { + // 如果添加这个tool会超出大小限制,设置next_cursor并退出循环 + next_cursor = (*it)->name(); + break; + } + + json += tool_json; + ++it; + } + + if (json.back() == ',') { + json.pop_back(); + } + + if (json.back() == '[' && !tools_.empty()) { + // 如果没有添加任何tool,返回错误 + ESP_LOGE(TAG, "tools/list: Failed to add tool %s because of payload size limit", next_cursor.c_str()); + ReplyError(id, "Failed to add tool " + next_cursor + " because of payload size limit"); + return; + } + + if (next_cursor.empty()) { + json += "]}"; + } else { + json += "],\"nextCursor\":\"" + next_cursor + "\"}"; + } + + ReplyResult(id, json); +} + +void McpServer::DoToolCall(int id, const std::string& tool_name, const cJSON* tool_arguments) { + auto tool_iter = std::find_if(tools_.begin(), tools_.end(), + [&tool_name](const McpTool* tool) { + return tool->name() == tool_name; + }); + + if (tool_iter == tools_.end()) { + ESP_LOGE(TAG, "tools/call: Unknown tool: %s", tool_name.c_str()); + ReplyError(id, "Unknown tool: " + tool_name); + return; + } + + PropertyList arguments = (*tool_iter)->properties(); + try { + for (auto& argument : arguments) { + bool found = false; + if (cJSON_IsObject(tool_arguments)) { + auto value = cJSON_GetObjectItem(tool_arguments, argument.name().c_str()); + if (argument.type() == kPropertyTypeBoolean && cJSON_IsBool(value)) { + argument.set_value(value->valueint == 1); + found = true; + } else if (argument.type() == kPropertyTypeInteger && cJSON_IsNumber(value)) { + argument.set_value(value->valueint); + found = true; + } else if (argument.type() == kPropertyTypeString && cJSON_IsString(value)) { + argument.set_value(value->valuestring); + found = true; + } + } + + if (!argument.has_default_value() && !found) { + ESP_LOGE(TAG, "tools/call: Missing valid argument: %s", argument.name().c_str()); + ReplyError(id, "Missing valid argument: " + argument.name()); + return; + } + } + } catch (const std::exception& e) { + ESP_LOGE(TAG, "tools/call: %s", e.what()); + ReplyError(id, e.what()); + return; + } + + // Use main thread to call the tool + auto& app = Application::GetInstance(); + app.Schedule([this, id, tool_iter, arguments = std::move(arguments)]() { + try { + ReplyResult(id, (*tool_iter)->Call(arguments)); + } catch (const std::exception& e) { + ESP_LOGE(TAG, "tools/call: %s", e.what()); + ReplyError(id, e.what()); + } + }); +} diff --git a/main/mcp_server.h b/main/mcp_server.h new file mode 100644 index 0000000..dacdd55 --- /dev/null +++ b/main/mcp_server.h @@ -0,0 +1,344 @@ +#ifndef MCP_SERVER_H +#define MCP_SERVER_H + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +class ImageContent { +private: + std::string encoded_data_; + std::string mime_type_; + + static std::string Base64Encode(const std::string& data) { + size_t dlen = 0, olen = 0; + mbedtls_base64_encode((unsigned char*)nullptr, 0, &dlen, (const unsigned char*)data.data(), data.size()); + std::string result(dlen, 0); + mbedtls_base64_encode((unsigned char*)result.data(), result.size(), &olen, (const unsigned char*)data.data(), data.size()); + return result; + } + +public: + ImageContent(const std::string& mime_type, const std::string& data) { + mime_type_ = mime_type; + // base64 encode data + encoded_data_ = Base64Encode(data); + } + + std::string to_json() const { + cJSON *json = cJSON_CreateObject(); + cJSON_AddStringToObject(json, "type", "image"); + cJSON_AddStringToObject(json, "mimeType", mime_type_.c_str()); + cJSON_AddStringToObject(json, "data", encoded_data_.c_str()); + char* json_str = cJSON_PrintUnformatted(json); + std::string result(json_str); + cJSON_free(json_str); + cJSON_Delete(json); + return result; + } +}; + +// 添加类型别名 +using ReturnValue = std::variant; + +enum PropertyType { + kPropertyTypeBoolean, + kPropertyTypeInteger, + kPropertyTypeString +}; + +class Property { +private: + std::string name_; + PropertyType type_; + std::variant value_; + bool has_default_value_; + std::optional min_value_; // 新增:整数最小值 + std::optional max_value_; // 新增:整数最大值 + +public: + // Required field constructor + Property(const std::string& name, PropertyType type) + : name_(name), type_(type), has_default_value_(false) {} + + // Optional field constructor with default value + template + Property(const std::string& name, PropertyType type, const T& default_value) + : name_(name), type_(type), has_default_value_(true) { + value_ = default_value; + } + + Property(const std::string& name, PropertyType type, int min_value, int max_value) + : name_(name), type_(type), has_default_value_(false), min_value_(min_value), max_value_(max_value) { + if (type != kPropertyTypeInteger) { + throw std::invalid_argument("Range limits only apply to integer properties"); + } + } + + Property(const std::string& name, PropertyType type, int default_value, int min_value, int max_value) + : name_(name), type_(type), has_default_value_(true), min_value_(min_value), max_value_(max_value) { + if (type != kPropertyTypeInteger) { + throw std::invalid_argument("Range limits only apply to integer properties"); + } + if (default_value < min_value || default_value > max_value) { + throw std::invalid_argument("Default value must be within the specified range"); + } + value_ = default_value; + } + + inline const std::string& name() const { return name_; } + inline PropertyType type() const { return type_; } + inline bool has_default_value() const { return has_default_value_; } + inline bool has_range() const { return min_value_.has_value() && max_value_.has_value(); } + inline int min_value() const { return min_value_.value_or(0); } + inline int max_value() const { return max_value_.value_or(0); } + + template + inline T value() const { + return std::get(value_); + } + + template + inline void set_value(const T& value) { + // 添加对设置的整数值进行范围检查 + if constexpr (std::is_same_v) { + if (min_value_.has_value() && value < min_value_.value()) { + throw std::invalid_argument("Value is below minimum allowed: " + std::to_string(min_value_.value())); + } + if (max_value_.has_value() && value > max_value_.value()) { + throw std::invalid_argument("Value exceeds maximum allowed: " + std::to_string(max_value_.value())); + } + } + value_ = value; + } + + std::string to_json() const { + cJSON *json = cJSON_CreateObject(); + + if (type_ == kPropertyTypeBoolean) { + cJSON_AddStringToObject(json, "type", "boolean"); + if (has_default_value_) { + cJSON_AddBoolToObject(json, "default", value()); + } + } else if (type_ == kPropertyTypeInteger) { + cJSON_AddStringToObject(json, "type", "integer"); + if (has_default_value_) { + cJSON_AddNumberToObject(json, "default", value()); + } + if (min_value_.has_value()) { + cJSON_AddNumberToObject(json, "minimum", min_value_.value()); + } + if (max_value_.has_value()) { + cJSON_AddNumberToObject(json, "maximum", max_value_.value()); + } + } else if (type_ == kPropertyTypeString) { + cJSON_AddStringToObject(json, "type", "string"); + if (has_default_value_) { + cJSON_AddStringToObject(json, "default", value().c_str()); + } + } + + char *json_str = cJSON_PrintUnformatted(json); + std::string result(json_str); + cJSON_free(json_str); + cJSON_Delete(json); + + return result; + } +}; + +class PropertyList { +private: + std::vector properties_; + +public: + PropertyList() = default; + PropertyList(const std::vector& properties) : properties_(properties) {} + void AddProperty(const Property& property) { + properties_.push_back(property); + } + + const Property& operator[](const std::string& name) const { + for (const auto& property : properties_) { + if (property.name() == name) { + return property; + } + } + throw std::runtime_error("Property not found: " + name); + } + + auto begin() { return properties_.begin(); } + auto end() { return properties_.end(); } + + std::vector GetRequired() const { + std::vector required; + for (auto& property : properties_) { + if (!property.has_default_value()) { + required.push_back(property.name()); + } + } + return required; + } + + std::string to_json() const { + cJSON *json = cJSON_CreateObject(); + + for (const auto& property : properties_) { + cJSON *prop_json = cJSON_Parse(property.to_json().c_str()); + cJSON_AddItemToObject(json, property.name().c_str(), prop_json); + } + + char *json_str = cJSON_PrintUnformatted(json); + std::string result(json_str); + cJSON_free(json_str); + cJSON_Delete(json); + + return result; + } +}; + +class McpTool { +private: + std::string name_; + std::string description_; + PropertyList properties_; + std::function callback_; + bool user_only_ = false; + +public: + McpTool(const std::string& name, + const std::string& description, + const PropertyList& properties, + std::function callback) + : name_(name), + description_(description), + properties_(properties), + callback_(callback) {} + + void set_user_only(bool user_only) { user_only_ = user_only; } + inline const std::string& name() const { return name_; } + inline const std::string& description() const { return description_; } + inline const PropertyList& properties() const { return properties_; } + inline bool user_only() const { return user_only_; } + + std::string to_json() const { + std::vector required = properties_.GetRequired(); + + cJSON *json = cJSON_CreateObject(); + cJSON_AddStringToObject(json, "name", name_.c_str()); + cJSON_AddStringToObject(json, "description", description_.c_str()); + + cJSON *input_schema = cJSON_CreateObject(); + cJSON_AddStringToObject(input_schema, "type", "object"); + + cJSON *properties = cJSON_Parse(properties_.to_json().c_str()); + cJSON_AddItemToObject(input_schema, "properties", properties); + + if (!required.empty()) { + cJSON *required_array = cJSON_CreateArray(); + for (const auto& property : required) { + cJSON_AddItemToArray(required_array, cJSON_CreateString(property.c_str())); + } + cJSON_AddItemToObject(input_schema, "required", required_array); + } + + cJSON_AddItemToObject(json, "inputSchema", input_schema); + + // Add audience annotation if the tool is user only (invisible to AI) + if (user_only_) { + cJSON *annotations = cJSON_CreateObject(); + cJSON *audience = cJSON_CreateArray(); + cJSON_AddItemToArray(audience, cJSON_CreateString("user")); + cJSON_AddItemToObject(annotations, "audience", audience); + cJSON_AddItemToObject(json, "annotations", annotations); + } + + char *json_str = cJSON_PrintUnformatted(json); + std::string result(json_str); + cJSON_free(json_str); + cJSON_Delete(json); + + return result; + } + + std::string Call(const PropertyList& properties) { + ReturnValue return_value = callback_(properties); + // 返回结果 + cJSON* result = cJSON_CreateObject(); + cJSON* content = cJSON_CreateArray(); + + if (std::holds_alternative(return_value)) { + auto image_content = std::get(return_value); + cJSON* image = cJSON_CreateObject(); + cJSON_AddStringToObject(image, "type", "image"); + cJSON_AddStringToObject(image, "image", image_content->to_json().c_str()); + cJSON_AddItemToArray(content, image); + delete image_content; + } else { + cJSON* text = cJSON_CreateObject(); + cJSON_AddStringToObject(text, "type", "text"); + if (std::holds_alternative(return_value)) { + cJSON_AddStringToObject(text, "text", std::get(return_value).c_str()); + } else if (std::holds_alternative(return_value)) { + cJSON_AddStringToObject(text, "text", std::get(return_value) ? "true" : "false"); + } else if (std::holds_alternative(return_value)) { + cJSON_AddStringToObject(text, "text", std::to_string(std::get(return_value)).c_str()); + } else if (std::holds_alternative(return_value)) { + cJSON* json = std::get(return_value); + char* json_str = cJSON_PrintUnformatted(json); + cJSON_AddStringToObject(text, "text", json_str); + cJSON_free(json_str); + cJSON_Delete(json); + } + cJSON_AddItemToArray(content, text); + } + cJSON_AddItemToObject(result, "content", content); + cJSON_AddBoolToObject(result, "isError", false); + + auto json_str = cJSON_PrintUnformatted(result); + std::string result_str(json_str); + cJSON_free(json_str); + cJSON_Delete(result); + return result_str; + } +}; + +class McpServer { +public: + static McpServer& GetInstance() { + static McpServer instance; + return instance; + } + + void AddCommonTools(); + void AddUserOnlyTools(); + void AddTool(McpTool* tool); + void AddTool(const std::string& name, const std::string& description, const PropertyList& properties, std::function callback); + void AddUserOnlyTool(const std::string& name, const std::string& description, const PropertyList& properties, std::function callback); + void ParseMessage(const cJSON* json); + void ParseMessage(const std::string& message); + +private: + McpServer(); + ~McpServer(); + + void ParseCapabilities(const cJSON* capabilities); + + void ReplyResult(int id, const std::string& result); + void ReplyError(int id, const std::string& message); + + void GetToolsList(int id, const std::string& cursor, bool list_user_only_tools); + void DoToolCall(int id, const std::string& tool_name, const cJSON* tool_arguments); + + std::vector tools_; +}; + +#endif // MCP_SERVER_H diff --git a/main/ota.cc b/main/ota.cc new file mode 100644 index 0000000..bcc0dc5 --- /dev/null +++ b/main/ota.cc @@ -0,0 +1,492 @@ +#include "ota.h" +#include "system_info.h" +#include "settings.h" +#include "assets/lang_config.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#ifdef SOC_HMAC_SUPPORTED +#include +#endif + +#include +#include +#include +#include + +#define TAG "Ota" + + +Ota::Ota() { +#ifdef ESP_EFUSE_BLOCK_USR_DATA + // Read Serial Number from efuse user_data + uint8_t serial_number[33] = {0}; + if (esp_efuse_read_field_blob(ESP_EFUSE_USER_DATA, serial_number, 32 * 8) == ESP_OK) { + if (serial_number[0] == 0) { + has_serial_number_ = false; + } else { + serial_number_ = std::string(reinterpret_cast(serial_number), 32); + has_serial_number_ = true; + } + } +#endif +} + +Ota::~Ota() { +} + +std::string Ota::GetCheckVersionUrl() { + Settings settings("wifi", false); + std::string url = settings.GetString("ota_url"); + if (url.empty()) { + url = CONFIG_OTA_URL; + } + return url; +} + +std::unique_ptr Ota::SetupHttp() { + auto& board = Board::GetInstance(); + auto network = board.GetNetwork(); + auto http = network->CreateHttp(0); + auto user_agent = SystemInfo::GetUserAgent(); + http->SetHeader("Activation-Version", has_serial_number_ ? "2" : "1"); + http->SetHeader("Device-Id", SystemInfo::GetMacAddress().c_str()); + http->SetHeader("Client-Id", board.GetUuid()); + if (has_serial_number_) { + http->SetHeader("Serial-Number", serial_number_.c_str()); + ESP_LOGI(TAG, "Setup HTTP, User-Agent: %s, Serial-Number: %s", user_agent.c_str(), serial_number_.c_str()); + } + http->SetHeader("User-Agent", user_agent); + http->SetHeader("Accept-Language", Lang::CODE); + http->SetHeader("Content-Type", "application/json"); + + return http; +} + +/* + * Specification: https://ccnphfhqs21z.feishu.cn/wiki/FjW6wZmisimNBBkov6OcmfvknVd + */ +esp_err_t Ota::CheckVersion() { + auto& board = Board::GetInstance(); + auto app_desc = esp_app_get_description(); + + // Check if there is a new firmware version available + current_version_ = app_desc->version; + ESP_LOGI(TAG, "Current version: %s", current_version_.c_str()); + + std::string url = GetCheckVersionUrl(); + if (url.length() < 10) { + ESP_LOGE(TAG, "Check version URL is not properly set"); + return ESP_ERR_INVALID_ARG; + } + + auto http = SetupHttp(); + + std::string data = board.GetSystemInfoJson(); + std::string method = data.length() > 0 ? "POST" : "GET"; + http->SetContent(std::move(data)); + + if (!http->Open(method, url)) { + int last_error = http->GetLastError(); + ESP_LOGE(TAG, "Failed to open HTTP connection, code=0x%x", last_error); + return last_error; + } + + auto status_code = http->GetStatusCode(); + if (status_code != 200) { + ESP_LOGE(TAG, "Failed to check version, status code: %d", status_code); + return status_code; + } + + data = http->ReadAll(); + http->Close(); + + // Response: { "firmware": { "version": "1.0.0", "url": "http://" } } + // Parse the JSON response and check if the version is newer + // If it is, set has_new_version_ to true and store the new version and URL + + cJSON *root = cJSON_Parse(data.c_str()); + if (root == NULL) { + ESP_LOGE(TAG, "Failed to parse JSON response"); + return ESP_ERR_INVALID_RESPONSE; + } + + has_activation_code_ = false; + has_activation_challenge_ = false; + cJSON *activation = cJSON_GetObjectItem(root, "activation"); + if (cJSON_IsObject(activation)) { + cJSON* message = cJSON_GetObjectItem(activation, "message"); + if (cJSON_IsString(message)) { + activation_message_ = message->valuestring; + } + cJSON* code = cJSON_GetObjectItem(activation, "code"); + if (cJSON_IsString(code)) { + activation_code_ = code->valuestring; + has_activation_code_ = true; + } + cJSON* challenge = cJSON_GetObjectItem(activation, "challenge"); + if (cJSON_IsString(challenge)) { + activation_challenge_ = challenge->valuestring; + has_activation_challenge_ = true; + } + cJSON* timeout_ms = cJSON_GetObjectItem(activation, "timeout_ms"); + if (cJSON_IsNumber(timeout_ms)) { + activation_timeout_ms_ = timeout_ms->valueint; + } + } + + has_mqtt_config_ = false; + cJSON *mqtt = cJSON_GetObjectItem(root, "mqtt"); + if (cJSON_IsObject(mqtt)) { + Settings settings("mqtt", true); + cJSON *item = NULL; + cJSON_ArrayForEach(item, mqtt) { + if (cJSON_IsString(item)) { + if (settings.GetString(item->string) != item->valuestring) { + settings.SetString(item->string, item->valuestring); + } + } else if (cJSON_IsNumber(item)) { + if (settings.GetInt(item->string) != item->valueint) { + settings.SetInt(item->string, item->valueint); + } + } + } + has_mqtt_config_ = true; + } else { + ESP_LOGI(TAG, "No mqtt section found !"); + } + + has_websocket_config_ = false; + cJSON *websocket = cJSON_GetObjectItem(root, "websocket"); + if (cJSON_IsObject(websocket)) { + Settings settings("websocket", true); + cJSON *item = NULL; + cJSON_ArrayForEach(item, websocket) { + if (cJSON_IsString(item)) { + if (settings.GetString(item->string) != item->valuestring) { + settings.SetString(item->string, item->valuestring); + } + } else if (cJSON_IsNumber(item)) { + if (settings.GetInt(item->string) != item->valueint) { + settings.SetInt(item->string, item->valueint); + } + } + } + has_websocket_config_ = true; + } else { + ESP_LOGI(TAG, "No websocket section found!"); + } + + has_server_time_ = false; + cJSON *server_time = cJSON_GetObjectItem(root, "server_time"); + if (cJSON_IsObject(server_time)) { + cJSON *timestamp = cJSON_GetObjectItem(server_time, "timestamp"); + cJSON *timezone_offset = cJSON_GetObjectItem(server_time, "timezone_offset"); + + if (cJSON_IsNumber(timestamp)) { + // 设置系统时间 + struct timeval tv; + double ts = timestamp->valuedouble; + + // 如果有时区偏移,计算本地时间 + if (cJSON_IsNumber(timezone_offset)) { + ts += (timezone_offset->valueint * 60 * 1000); // 转换分钟为毫秒 + } + + tv.tv_sec = (time_t)(ts / 1000); // 转换毫秒为秒 + tv.tv_usec = (suseconds_t)((long long)ts % 1000) * 1000; // 剩余的毫秒转换为微秒 + settimeofday(&tv, NULL); + has_server_time_ = true; + } + } else { + ESP_LOGW(TAG, "No server_time section found!"); + } + + has_new_version_ = false; + cJSON *firmware = cJSON_GetObjectItem(root, "firmware"); + if (cJSON_IsObject(firmware)) { + cJSON *version = cJSON_GetObjectItem(firmware, "version"); + if (cJSON_IsString(version)) { + firmware_version_ = version->valuestring; + } + cJSON *url = cJSON_GetObjectItem(firmware, "url"); + if (cJSON_IsString(url)) { + firmware_url_ = url->valuestring; + } + + if (cJSON_IsString(version) && cJSON_IsString(url)) { + // Check if the version is newer, for example, 0.1.0 is newer than 0.0.1 + has_new_version_ = IsNewVersionAvailable(current_version_, firmware_version_); + if (has_new_version_) { + ESP_LOGI(TAG, "New version available: %s", firmware_version_.c_str()); + } else { + ESP_LOGI(TAG, "Current is the latest version"); + } + // If the force flag is set to 1, the given version is forced to be installed + cJSON *force = cJSON_GetObjectItem(firmware, "force"); + if (cJSON_IsNumber(force) && force->valueint == 1) { + has_new_version_ = true; + } + } + } else { + ESP_LOGW(TAG, "No firmware section found!"); + } + + cJSON_Delete(root); + return ESP_OK; +} + +void Ota::MarkCurrentVersionValid() { + auto partition = esp_ota_get_running_partition(); + if (strcmp(partition->label, "factory") == 0) { + ESP_LOGI(TAG, "Running from factory partition, skipping"); + return; + } + + ESP_LOGI(TAG, "Running partition: %s", partition->label); + esp_ota_img_states_t state; + if (esp_ota_get_state_partition(partition, &state) != ESP_OK) { + ESP_LOGE(TAG, "Failed to get state of partition"); + return; + } + + if (state == ESP_OTA_IMG_PENDING_VERIFY) { + ESP_LOGI(TAG, "Marking firmware as valid"); + esp_ota_mark_app_valid_cancel_rollback(); + } +} + +bool Ota::Upgrade(const std::string& firmware_url, std::function callback) { + ESP_LOGI(TAG, "Upgrading firmware from %s", firmware_url.c_str()); + esp_ota_handle_t update_handle = 0; + auto update_partition = esp_ota_get_next_update_partition(NULL); + if (update_partition == NULL) { + ESP_LOGE(TAG, "Failed to get update partition"); + return false; + } + + ESP_LOGI(TAG, "Writing to partition %s at offset 0x%lx", update_partition->label, update_partition->address); + bool image_header_checked = false; + std::string image_header; + + auto network = Board::GetInstance().GetNetwork(); + auto http = network->CreateHttp(0); + if (!http->Open("GET", firmware_url)) { + ESP_LOGE(TAG, "Failed to open HTTP connection"); + return false; + } + + if (http->GetStatusCode() != 200) { + ESP_LOGE(TAG, "Failed to get firmware, status code: %d", http->GetStatusCode()); + return false; + } + + size_t content_length = http->GetBodyLength(); + if (content_length == 0) { + ESP_LOGE(TAG, "Failed to get content length"); + return false; + } + + constexpr size_t PAGE_SIZE = 4096; + char* buffer = (char*)heap_caps_malloc(PAGE_SIZE, MALLOC_CAP_INTERNAL); + if (buffer == nullptr) { + ESP_LOGE(TAG, "Failed to allocate buffer"); + return false; + } + + size_t buffer_offset = 0; // Current data size in buffer + size_t total_read = 0, recent_read = 0; + auto last_calc_time = esp_timer_get_time(); + while (true) { + int ret = http->Read(buffer + buffer_offset, PAGE_SIZE - buffer_offset); + if (ret < 0) { + ESP_LOGE(TAG, "Failed to read HTTP data: %s", esp_err_to_name(ret)); + heap_caps_free(buffer); + return false; + } + + // Calculate speed and progress every second + recent_read += ret; + total_read += ret; + buffer_offset += ret; + if (esp_timer_get_time() - last_calc_time >= 1000000 || ret == 0) { + size_t progress = total_read * 100 / content_length; + ESP_LOGI(TAG, "Progress: %u%% (%u/%u), Speed: %uB/s", progress, total_read, content_length, recent_read); + if (callback) { + callback(progress, recent_read); + } + last_calc_time = esp_timer_get_time(); + recent_read = 0; + } + + if (!image_header_checked) { + image_header.append(buffer, buffer_offset); + if (image_header.size() >= sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t) + sizeof(esp_app_desc_t)) { + esp_app_desc_t new_app_info; + memcpy(&new_app_info, image_header.data() + sizeof(esp_image_header_t) + sizeof(esp_image_segment_header_t), sizeof(esp_app_desc_t)); + + if (esp_ota_begin(update_partition, OTA_WITH_SEQUENTIAL_WRITES, &update_handle)) { + esp_ota_abort(update_handle); + ESP_LOGE(TAG, "Failed to begin OTA"); + heap_caps_free(buffer); + return false; + } + + image_header_checked = true; + std::string().swap(image_header); + } + } + + // Write to flash when buffer is full (4KB) or it's the last chunk + bool is_last_chunk = (ret == 0); + if (buffer_offset == PAGE_SIZE || (is_last_chunk && buffer_offset > 0)) { + auto err = esp_ota_write(update_handle, buffer, buffer_offset); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to write OTA data: %s", esp_err_to_name(err)); + esp_ota_abort(update_handle); + heap_caps_free(buffer); + return false; + } + + buffer_offset = 0; + } + + if (is_last_chunk) { + break; + } + } + http->Close(); + heap_caps_free(buffer); + + esp_err_t err = esp_ota_end(update_handle); + if (err != ESP_OK) { + if (err == ESP_ERR_OTA_VALIDATE_FAILED) { + ESP_LOGE(TAG, "Image validation failed, image is corrupted"); + } else { + ESP_LOGE(TAG, "Failed to end OTA: %s", esp_err_to_name(err)); + } + return false; + } + + err = esp_ota_set_boot_partition(update_partition); + if (err != ESP_OK) { + ESP_LOGE(TAG, "Failed to set boot partition: %s", esp_err_to_name(err)); + return false; + } + + ESP_LOGI(TAG, "Firmware upgrade successful"); + return true; +} + +bool Ota::StartUpgrade(std::function callback) { + return Upgrade(firmware_url_, callback); +} + + +std::vector Ota::ParseVersion(const std::string& version) { + std::vector versionNumbers; + std::stringstream ss(version); + std::string segment; + + while (std::getline(ss, segment, '.')) { + versionNumbers.push_back(std::stoi(segment)); + } + + return versionNumbers; +} + +bool Ota::IsNewVersionAvailable(const std::string& currentVersion, const std::string& newVersion) { + std::vector current = ParseVersion(currentVersion); + std::vector newer = ParseVersion(newVersion); + + for (size_t i = 0; i < std::min(current.size(), newer.size()); ++i) { + if (newer[i] > current[i]) { + return true; + } else if (newer[i] < current[i]) { + return false; + } + } + + return newer.size() > current.size(); +} + +std::string Ota::GetActivationPayload() { + if (!has_serial_number_) { + return "{}"; + } + + std::string hmac_hex; +#ifdef SOC_HMAC_SUPPORTED + uint8_t hmac_result[32]; // SHA-256 输出为32字节 + + // 使用Key0计算HMAC + esp_err_t ret = esp_hmac_calculate(HMAC_KEY0, (uint8_t*)activation_challenge_.data(), activation_challenge_.size(), hmac_result); + if (ret != ESP_OK) { + ESP_LOGE(TAG, "HMAC calculation failed: %s", esp_err_to_name(ret)); + return "{}"; + } + + for (size_t i = 0; i < sizeof(hmac_result); i++) { + char buffer[3]; + sprintf(buffer, "%02x", hmac_result[i]); + hmac_hex += buffer; + } +#endif + + cJSON *payload = cJSON_CreateObject(); + cJSON_AddStringToObject(payload, "algorithm", "hmac-sha256"); + cJSON_AddStringToObject(payload, "serial_number", serial_number_.c_str()); + cJSON_AddStringToObject(payload, "challenge", activation_challenge_.c_str()); + cJSON_AddStringToObject(payload, "hmac", hmac_hex.c_str()); + auto json_str = cJSON_PrintUnformatted(payload); + std::string json(json_str); + cJSON_free(json_str); + cJSON_Delete(payload); + + ESP_LOGI(TAG, "Activation payload: %s", json.c_str()); + return json; +} + +esp_err_t Ota::Activate() { + if (!has_activation_challenge_) { + ESP_LOGW(TAG, "No activation challenge found"); + return ESP_FAIL; + } + + std::string url = GetCheckVersionUrl(); + if (url.back() != '/') { + url += "/activate"; + } else { + url += "activate"; + } + + auto http = SetupHttp(); + + std::string data = GetActivationPayload(); + http->SetContent(std::move(data)); + + if (!http->Open("POST", url)) { + ESP_LOGE(TAG, "Failed to open HTTP connection"); + return ESP_FAIL; + } + + auto status_code = http->GetStatusCode(); + if (status_code == 202) { + return ESP_ERR_TIMEOUT; + } + if (status_code != 200) { + ESP_LOGE(TAG, "Failed to activate, code: %d, body: %s", status_code, http->ReadAll().c_str()); + return ESP_FAIL; + } + + ESP_LOGI(TAG, "Activation successful"); + return ESP_OK; +} diff --git a/main/ota.h b/main/ota.h new file mode 100644 index 0000000..1e5d687 --- /dev/null +++ b/main/ota.h @@ -0,0 +1,58 @@ +#ifndef _OTA_H +#define _OTA_H + +#include +#include + +#include +#include "board.h" + +class Ota { +public: + Ota(); + ~Ota(); + + esp_err_t CheckVersion(); + esp_err_t Activate(); + bool HasActivationChallenge() { return has_activation_challenge_; } + bool HasNewVersion() { return has_new_version_; } + bool HasMqttConfig() { return has_mqtt_config_; } + bool HasWebsocketConfig() { return has_websocket_config_; } + bool HasActivationCode() { return has_activation_code_; } + bool HasServerTime() { return has_server_time_; } + bool StartUpgrade(std::function callback); + static bool Upgrade(const std::string& firmware_url, std::function callback); + void MarkCurrentVersionValid(); + + const std::string& GetFirmwareVersion() const { return firmware_version_; } + const std::string& GetCurrentVersion() const { return current_version_; } + const std::string& GetFirmwareUrl() const { return firmware_url_; } + const std::string& GetActivationMessage() const { return activation_message_; } + const std::string& GetActivationCode() const { return activation_code_; } + std::string GetCheckVersionUrl(); + +private: + std::string activation_message_; + std::string activation_code_; + bool has_new_version_ = false; + bool has_mqtt_config_ = false; + bool has_websocket_config_ = false; + bool has_server_time_ = false; + bool has_activation_code_ = false; + bool has_serial_number_ = false; + bool has_activation_challenge_ = false; + std::string current_version_; + std::string firmware_version_; + std::string firmware_url_; + std::string activation_challenge_; + std::string serial_number_; + int activation_timeout_ms_ = 30000; + + std::function upgrade_callback_; + std::vector ParseVersion(const std::string& version); + bool IsNewVersionAvailable(const std::string& currentVersion, const std::string& newVersion); + std::string GetActivationPayload(); + std::unique_ptr SetupHttp(); +}; + +#endif // _OTA_H diff --git a/main/protocols/mqtt_protocol.cc b/main/protocols/mqtt_protocol.cc new file mode 100644 index 0000000..94d9d12 --- /dev/null +++ b/main/protocols/mqtt_protocol.cc @@ -0,0 +1,389 @@ +#include "mqtt_protocol.h" +#include "board.h" +#include "application.h" +#include "settings.h" + +#include +#include +#include +#include "assets/lang_config.h" + +#define TAG "MQTT" + +MqttProtocol::MqttProtocol() { + event_group_handle_ = xEventGroupCreate(); + + // Initialize reconnect timer + esp_timer_create_args_t reconnect_timer_args = { + .callback = [](void* arg) { + MqttProtocol* protocol = (MqttProtocol*)arg; + auto& app = Application::GetInstance(); + if (app.GetDeviceState() == kDeviceStateIdle) { + ESP_LOGI(TAG, "Reconnecting to MQTT server"); + auto alive = protocol->alive_; // Capture alive flag + app.Schedule([protocol, alive]() { + if (*alive) { + protocol->StartMqttClient(false); + } + }); + } + }, + .arg = this, + }; + esp_timer_create(&reconnect_timer_args, &reconnect_timer_); +} + +MqttProtocol::~MqttProtocol() { + ESP_LOGI(TAG, "MqttProtocol deinit"); + + // Mark as dead first to prevent any pending scheduled tasks from executing + *alive_ = false; + + if (reconnect_timer_ != nullptr) { + esp_timer_stop(reconnect_timer_); + esp_timer_delete(reconnect_timer_); + } + + udp_.reset(); + mqtt_.reset(); + + if (event_group_handle_ != nullptr) { + vEventGroupDelete(event_group_handle_); + } +} + +bool MqttProtocol::Start() { + return StartMqttClient(false); +} + +bool MqttProtocol::StartMqttClient(bool report_error) { + if (mqtt_ != nullptr) { + ESP_LOGW(TAG, "Mqtt client already started"); + mqtt_.reset(); + } + + Settings settings("mqtt", false); + auto endpoint = settings.GetString("endpoint"); + auto client_id = settings.GetString("client_id"); + auto username = settings.GetString("username"); + auto password = settings.GetString("password"); + int keepalive_interval = settings.GetInt("keepalive", 240); + publish_topic_ = settings.GetString("publish_topic"); + + if (endpoint.empty()) { + ESP_LOGW(TAG, "MQTT endpoint is not specified"); + if (report_error) { + SetError(Lang::Strings::SERVER_NOT_FOUND); + } + return false; + } + + auto network = Board::GetInstance().GetNetwork(); + mqtt_ = network->CreateMqtt(0); + mqtt_->SetKeepAlive(keepalive_interval); + + mqtt_->OnDisconnected([this]() { + if (on_disconnected_ != nullptr) { + on_disconnected_(); + } + ESP_LOGI(TAG, "MQTT disconnected, schedule reconnect in %d seconds", MQTT_RECONNECT_INTERVAL_MS / 1000); + esp_timer_start_once(reconnect_timer_, MQTT_RECONNECT_INTERVAL_MS * 1000); + }); + + mqtt_->OnConnected([this]() { + if (on_connected_ != nullptr) { + on_connected_(); + } + esp_timer_stop(reconnect_timer_); + }); + + mqtt_->OnMessage([this](const std::string& topic, const std::string& payload) { + cJSON* root = cJSON_Parse(payload.c_str()); + if (root == nullptr) { + ESP_LOGE(TAG, "Failed to parse json message %s", payload.c_str()); + return; + } + cJSON* type = cJSON_GetObjectItem(root, "type"); + if (!cJSON_IsString(type)) { + ESP_LOGE(TAG, "Message type is invalid"); + cJSON_Delete(root); + return; + } + + if (strcmp(type->valuestring, "hello") == 0) { + ParseServerHello(root); + } else if (strcmp(type->valuestring, "goodbye") == 0) { + auto session_id = cJSON_GetObjectItem(root, "session_id"); + ESP_LOGI(TAG, "Received goodbye message, session_id: %s", session_id ? session_id->valuestring : "null"); + if (session_id == nullptr || session_id_ == session_id->valuestring) { + auto alive = alive_; // Capture alive flag + Application::GetInstance().Schedule([this, alive]() { + if (*alive) { + // Server initiated goodbye, don't send goodbye back to avoid ping-pong + CloseAudioChannel(false); + } + }); + } + } else if (on_incoming_json_ != nullptr) { + on_incoming_json_(root); + } + cJSON_Delete(root); + last_incoming_time_ = std::chrono::steady_clock::now(); + }); + + ESP_LOGI(TAG, "Connecting to endpoint %s", endpoint.c_str()); + std::string broker_address; + int broker_port = 8883; + size_t pos = endpoint.find(':'); + if (pos != std::string::npos) { + broker_address = endpoint.substr(0, pos); + broker_port = std::stoi(endpoint.substr(pos + 1)); + } else { + broker_address = endpoint; + } + 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); + return false; + } + + ESP_LOGI(TAG, "Connected to endpoint"); + return true; +} + +bool MqttProtocol::SendText(const std::string& text) { + if (publish_topic_.empty()) { + return false; + } + if (!mqtt_->Publish(publish_topic_, text)) { + ESP_LOGE(TAG, "Failed to publish message: %s", text.c_str()); + SetError(Lang::Strings::SERVER_ERROR); + return false; + } + return true; +} + +bool MqttProtocol::SendAudio(std::unique_ptr packet) { + std::lock_guard lock(channel_mutex_); + if (udp_ == nullptr) { + return false; + } + + 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; + } + + return udp_->Send(encrypted) > 0; +} + +void MqttProtocol::CloseAudioChannel(bool send_goodbye) { + { + std::lock_guard lock(channel_mutex_); + udp_.reset(); + } + + ESP_LOGI(TAG, "Closing audio channel, send_goodbye: %d", send_goodbye); + + // Only send goodbye when client initiates the close + // Don't send if server already sent goodbye (to avoid ping-pong) + if (send_goodbye) { + std::string message = "{"; + message += "\"session_id\":\"" + session_id_ + "\","; + message += "\"type\":\"goodbye\""; + message += "}"; + SendText(message); + } + + if (on_audio_channel_closed_ != nullptr) { + on_audio_channel_closed_(); + } +} + +bool MqttProtocol::OpenAudioChannel() { + if (mqtt_ == nullptr || !mqtt_->IsConnected()) { + ESP_LOGI(TAG, "MQTT is not connected, try to connect now"); + if (!StartMqttClient(true)) { + return false; + } + } + + error_occurred_ = false; + session_id_ = ""; + xEventGroupClearBits(event_group_handle_, MQTT_PROTOCOL_SERVER_HELLO_EVENT); + + auto message = GetHelloMessage(); + if (!SendText(message)) { + return false; + } + + // 等待服务器响应 + EventBits_t bits = xEventGroupWaitBits(event_group_handle_, MQTT_PROTOCOL_SERVER_HELLO_EVENT, pdTRUE, pdFALSE, pdMS_TO_TICKS(10000)); + if (!(bits & MQTT_PROTOCOL_SERVER_HELLO_EVENT)) { + ESP_LOGE(TAG, "Failed to receive server hello"); + SetError(Lang::Strings::SERVER_TIMEOUT); + return false; + } + + std::lock_guard lock(channel_mutex_); + auto network = Board::GetInstance().GetNetwork(); + udp_ = network->CreateUdp(2); + udp_->OnMessage([this](const std::string& data) { + /* + * UDP Encrypted OPUS Packet Format: + * |type 1u|flags 1u|payload_len 2u|ssrc 4u|timestamp 4u|sequence 4u| + * |payload payload_len| + */ + if (data.size() < sizeof(aes_nonce_)) { + ESP_LOGE(TAG, "Invalid audio packet size: %u", data.size()); + return; + } + if (data[0] != 0x01) { + ESP_LOGE(TAG, "Invalid audio packet type: %x", data[0]); + return; + } + uint32_t timestamp = ntohl(*(uint32_t*)&data[8]); + uint32_t sequence = ntohl(*(uint32_t*)&data[12]); + if (sequence < remote_sequence_) { + ESP_LOGW(TAG, "Received audio packet with old sequence: %lu, expected: %lu", sequence, remote_sequence_); + return; + } + if (sequence != remote_sequence_ + 1) { + ESP_LOGW(TAG, "Received audio packet with wrong sequence: %lu, expected: %lu", sequence, remote_sequence_ + 1); + } + + size_t decrypted_size = data.size() - aes_nonce_.size(); + size_t nc_off = 0; + uint8_t stream_block[16] = {0}; + auto nonce = (uint8_t*)data.data(); + auto encrypted = (uint8_t*)data.data() + aes_nonce_.size(); + auto packet = std::make_unique(); + packet->sample_rate = server_sample_rate_; + packet->frame_duration = server_frame_duration_; + packet->timestamp = timestamp; + packet->payload.resize(decrypted_size); + int ret = mbedtls_aes_crypt_ctr(&aes_ctx_, decrypted_size, &nc_off, nonce, stream_block, encrypted, (uint8_t*)packet->payload.data()); + if (ret != 0) { + ESP_LOGE(TAG, "Failed to decrypt audio data, ret: %d", ret); + return; + } + if (on_incoming_audio_ != nullptr) { + on_incoming_audio_(std::move(packet)); + } + remote_sequence_ = sequence; + last_incoming_time_ = std::chrono::steady_clock::now(); + }); + + udp_->Connect(udp_server_, udp_port_); + + if (on_audio_channel_opened_ != nullptr) { + on_audio_channel_opened_(); + } + return true; +} + +std::string MqttProtocol::GetHelloMessage() { + // 发送 hello 消息申请 UDP 通道 + cJSON* root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "type", "hello"); + cJSON_AddNumberToObject(root, "version", 3); + cJSON_AddStringToObject(root, "transport", "udp"); + cJSON* features = cJSON_CreateObject(); +#if CONFIG_USE_SERVER_AEC + cJSON_AddBoolToObject(features, "aec", true); +#endif + cJSON_AddBoolToObject(features, "mcp", true); + cJSON_AddItemToObject(root, "features", features); + cJSON* audio_params = cJSON_CreateObject(); + cJSON_AddStringToObject(audio_params, "format", "opus"); + cJSON_AddNumberToObject(audio_params, "sample_rate", 16000); + cJSON_AddNumberToObject(audio_params, "channels", 1); + cJSON_AddNumberToObject(audio_params, "frame_duration", OPUS_FRAME_DURATION_MS); + cJSON_AddItemToObject(root, "audio_params", audio_params); + auto json_str = cJSON_PrintUnformatted(root); + std::string message(json_str); + cJSON_free(json_str); + cJSON_Delete(root); + return message; +} + +void MqttProtocol::ParseServerHello(const cJSON* root) { + auto transport = cJSON_GetObjectItem(root, "transport"); + if (transport == nullptr || strcmp(transport->valuestring, "udp") != 0) { + ESP_LOGE(TAG, "Unsupported transport: %s", transport->valuestring); + return; + } + + auto session_id = cJSON_GetObjectItem(root, "session_id"); + if (cJSON_IsString(session_id)) { + session_id_ = session_id->valuestring; + ESP_LOGI(TAG, "Session ID: %s", session_id_.c_str()); + } + + // Get sample rate from hello message + auto audio_params = cJSON_GetObjectItem(root, "audio_params"); + if (cJSON_IsObject(audio_params)) { + auto sample_rate = cJSON_GetObjectItem(audio_params, "sample_rate"); + if (cJSON_IsNumber(sample_rate)) { + server_sample_rate_ = sample_rate->valueint; + } + auto frame_duration = cJSON_GetObjectItem(audio_params, "frame_duration"); + if (cJSON_IsNumber(frame_duration)) { + server_frame_duration_ = frame_duration->valueint; + } + } + + auto udp = cJSON_GetObjectItem(root, "udp"); + if (!cJSON_IsObject(udp)) { + ESP_LOGE(TAG, "UDP is not specified"); + return; + } + udp_server_ = cJSON_GetObjectItem(udp, "server")->valuestring; + udp_port_ = cJSON_GetObjectItem(udp, "port")->valueint; + auto key = cJSON_GetObjectItem(udp, "key")->valuestring; + auto nonce = cJSON_GetObjectItem(udp, "nonce")->valuestring; + + // auto encryption = cJSON_GetObjectItem(udp, "encryption")->valuestring; + // ESP_LOGI(TAG, "UDP server: %s, port: %d, encryption: %s", udp_server_.c_str(), udp_port_, encryption); + aes_nonce_ = DecodeHexString(nonce); + mbedtls_aes_init(&aes_ctx_); + mbedtls_aes_setkey_enc(&aes_ctx_, (const unsigned char*)DecodeHexString(key).c_str(), 128); + local_sequence_ = 0; + remote_sequence_ = 0; + xEventGroupSetBits(event_group_handle_, MQTT_PROTOCOL_SERVER_HELLO_EVENT); +} + +static const char hex_chars[] = "0123456789ABCDEF"; +// 辅助函数,将单个十六进制字符转换为对应的数值 +static inline uint8_t CharToHex(char c) { + if (c >= '0' && c <= '9') return c - '0'; + if (c >= 'A' && c <= 'F') return c - 'A' + 10; + if (c >= 'a' && c <= 'f') return c - 'a' + 10; + return 0; // 对于无效输入,返回0 +} + +std::string MqttProtocol::DecodeHexString(const std::string& hex_string) { + std::string decoded; + decoded.reserve(hex_string.size() / 2); + for (size_t i = 0; i < hex_string.size(); i += 2) { + char byte = (CharToHex(hex_string[i]) << 4) | CharToHex(hex_string[i + 1]); + decoded.push_back(byte); + } + return decoded; +} + +bool MqttProtocol::IsAudioChannelOpened() const { + return udp_ != nullptr && !error_occurred_ && !IsTimeout(); +} diff --git a/main/protocols/mqtt_protocol.h b/main/protocols/mqtt_protocol.h new file mode 100644 index 0000000..90963db --- /dev/null +++ b/main/protocols/mqtt_protocol.h @@ -0,0 +1,65 @@ +#ifndef MQTT_PROTOCOL_H +#define MQTT_PROTOCOL_H + + +#include "protocol.h" +#include +#include +#include +#include +#include +#include +#include + +#include +#include +#include +#include +#include +#include + +#define MQTT_PING_INTERVAL_SECONDS 90 +#define MQTT_RECONNECT_INTERVAL_MS 60000 + +#define MQTT_PROTOCOL_SERVER_HELLO_EVENT (1 << 0) + +class MqttProtocol : public Protocol { +public: + MqttProtocol(); + ~MqttProtocol(); + + bool Start() override; + bool SendAudio(std::unique_ptr packet) override; + bool OpenAudioChannel() override; + void CloseAudioChannel(bool send_goodbye = true) override; + bool IsAudioChannelOpened() const override; + +private: + // Alive flag for safe scheduled callbacks - set to false in destructor + std::shared_ptr> alive_ = std::make_shared>(true); + + EventGroupHandle_t event_group_handle_; + + std::string publish_topic_; + + std::mutex channel_mutex_; + std::unique_ptr mqtt_; + std::unique_ptr udp_; + mbedtls_aes_context aes_ctx_; + std::string aes_nonce_; + std::string udp_server_; + int udp_port_; + uint32_t local_sequence_; + uint32_t remote_sequence_; + esp_timer_handle_t reconnect_timer_; + + bool StartMqttClient(bool report_error=false); + void ParseServerHello(const cJSON* root); + std::string DecodeHexString(const std::string& hex_string); + + bool SendText(const std::string& text) override; + std::string GetHelloMessage(); +}; + + +#endif // MQTT_PROTOCOL_H diff --git a/main/protocols/protocol.cc b/main/protocols/protocol.cc new file mode 100644 index 0000000..470cc91 --- /dev/null +++ b/main/protocols/protocol.cc @@ -0,0 +1,90 @@ +#include "protocol.h" + +#include + +#define TAG "Protocol" + +void Protocol::OnIncomingJson(std::function callback) { + on_incoming_json_ = callback; +} + +void Protocol::OnIncomingAudio(std::function packet)> callback) { + on_incoming_audio_ = callback; +} + +void Protocol::OnAudioChannelOpened(std::function callback) { + on_audio_channel_opened_ = callback; +} + +void Protocol::OnAudioChannelClosed(std::function callback) { + on_audio_channel_closed_ = callback; +} + +void Protocol::OnNetworkError(std::function callback) { + on_network_error_ = callback; +} + +void Protocol::OnConnected(std::function callback) { + on_connected_ = callback; +} + +void Protocol::OnDisconnected(std::function callback) { + on_disconnected_ = callback; +} + +void Protocol::SetError(const std::string& message) { + error_occurred_ = true; + if (on_network_error_ != nullptr) { + on_network_error_(message); + } +} + +void Protocol::SendAbortSpeaking(AbortReason reason) { + std::string message = "{\"session_id\":\"" + session_id_ + "\",\"type\":\"abort\""; + if (reason == kAbortReasonWakeWordDetected) { + message += ",\"reason\":\"wake_word_detected\""; + } + message += "}"; + SendText(message); +} + +void Protocol::SendWakeWordDetected(const std::string& wake_word) { + std::string json = "{\"session_id\":\"" + session_id_ + + "\",\"type\":\"listen\",\"state\":\"detect\",\"text\":\"" + wake_word + "\"}"; + SendText(json); +} + +void Protocol::SendStartListening(ListeningMode mode) { + std::string message = "{\"session_id\":\"" + session_id_ + "\""; + message += ",\"type\":\"listen\",\"state\":\"start\""; + if (mode == kListeningModeRealtime) { + message += ",\"mode\":\"realtime\""; + } else if (mode == kListeningModeAutoStop) { + message += ",\"mode\":\"auto\""; + } else { + message += ",\"mode\":\"manual\""; + } + message += "}"; + SendText(message); +} + +void Protocol::SendStopListening() { + std::string message = "{\"session_id\":\"" + session_id_ + "\",\"type\":\"listen\",\"state\":\"stop\"}"; + SendText(message); +} + +void Protocol::SendMcpMessage(const std::string& payload) { + std::string message = "{\"session_id\":\"" + session_id_ + "\",\"type\":\"mcp\",\"payload\":" + payload + "}"; + SendText(message); +} + +bool Protocol::IsTimeout() const { + const int kTimeoutSeconds = 120; + auto now = std::chrono::steady_clock::now(); + auto duration = std::chrono::duration_cast(now - last_incoming_time_); + bool timeout = duration.count() > kTimeoutSeconds; + if (timeout) { + ESP_LOGE(TAG, "Channel timeout %ld seconds", (long)duration.count()); + } + return timeout; +} diff --git a/main/protocols/protocol.h b/main/protocols/protocol.h new file mode 100644 index 0000000..28ef604 --- /dev/null +++ b/main/protocols/protocol.h @@ -0,0 +1,98 @@ +#ifndef PROTOCOL_H +#define PROTOCOL_H + +#include +#include +#include +#include +#include + +struct AudioStreamPacket { + int sample_rate = 0; + int frame_duration = 0; + uint32_t timestamp = 0; + std::vector payload; +}; + +struct BinaryProtocol2 { + uint16_t version; + uint16_t type; // Message type (0: OPUS, 1: JSON) + uint32_t reserved; // Reserved for future use + uint32_t timestamp; // Timestamp in milliseconds (used for server-side AEC) + uint32_t payload_size; // Payload size in bytes + uint8_t payload[]; // Payload data +} __attribute__((packed)); + +struct BinaryProtocol3 { + uint8_t type; + uint8_t reserved; + uint16_t payload_size; + uint8_t payload[]; +} __attribute__((packed)); + +enum AbortReason { + kAbortReasonNone, + kAbortReasonWakeWordDetected +}; + +enum ListeningMode { + kListeningModeAutoStop, + kListeningModeManualStop, + kListeningModeRealtime // 需要 AEC 支持 +}; + +class Protocol { +public: + virtual ~Protocol() = default; + + inline int server_sample_rate() const { + return server_sample_rate_; + } + inline int server_frame_duration() const { + return server_frame_duration_; + } + inline const std::string& session_id() const { + return session_id_; + } + + void OnIncomingAudio(std::function packet)> callback); + void OnIncomingJson(std::function callback); + void OnAudioChannelOpened(std::function callback); + void OnAudioChannelClosed(std::function callback); + void OnNetworkError(std::function callback); + void OnConnected(std::function callback); + void OnDisconnected(std::function callback); + + virtual bool Start() = 0; + virtual bool OpenAudioChannel() = 0; + virtual void CloseAudioChannel(bool send_goodbye = true) = 0; + virtual bool IsAudioChannelOpened() const = 0; + virtual bool SendAudio(std::unique_ptr packet) = 0; + virtual void SendWakeWordDetected(const std::string& wake_word); + virtual void SendStartListening(ListeningMode mode); + virtual void SendStopListening(); + virtual void SendAbortSpeaking(AbortReason reason); + virtual void SendMcpMessage(const std::string& message); + +protected: + std::function on_incoming_json_; + std::function packet)> on_incoming_audio_; + std::function on_audio_channel_opened_; + std::function on_audio_channel_closed_; + std::function on_network_error_; + std::function on_connected_; + std::function on_disconnected_; + + int server_sample_rate_ = 24000; + int server_frame_duration_ = 60; + bool error_occurred_ = false; + std::string session_id_; + std::chrono::time_point last_incoming_time_; + + virtual bool SendText(const std::string& text) = 0; + virtual void SetError(const std::string& message); + virtual bool IsTimeout() const; +}; + +#endif // PROTOCOL_H + diff --git a/main/protocols/websocket_protocol.cc b/main/protocols/websocket_protocol.cc new file mode 100644 index 0000000..f0cc034 --- /dev/null +++ b/main/protocols/websocket_protocol.cc @@ -0,0 +1,254 @@ +#include "websocket_protocol.h" +#include "board.h" +#include "system_info.h" +#include "application.h" +#include "settings.h" + +#include +#include +#include +#include +#include "assets/lang_config.h" + +#define TAG "WS" + +WebsocketProtocol::WebsocketProtocol() { + event_group_handle_ = xEventGroupCreate(); +} + +WebsocketProtocol::~WebsocketProtocol() { + vEventGroupDelete(event_group_handle_); +} + +bool WebsocketProtocol::Start() { + // Only connect to server when audio channel is needed + return true; +} + +bool WebsocketProtocol::SendAudio(std::unique_ptr packet) { + if (websocket_ == nullptr || !websocket_->IsConnected()) { + return false; + } + + if (version_ == 2) { + std::string serialized; + serialized.resize(sizeof(BinaryProtocol2) + packet->payload.size()); + auto bp2 = (BinaryProtocol2*)serialized.data(); + bp2->version = htons(version_); + bp2->type = 0; + bp2->reserved = 0; + bp2->timestamp = htonl(packet->timestamp); + bp2->payload_size = htonl(packet->payload.size()); + memcpy(bp2->payload, packet->payload.data(), packet->payload.size()); + + return websocket_->Send(serialized.data(), serialized.size(), true); + } else if (version_ == 3) { + std::string serialized; + serialized.resize(sizeof(BinaryProtocol3) + packet->payload.size()); + auto bp3 = (BinaryProtocol3*)serialized.data(); + bp3->type = 0; + bp3->reserved = 0; + bp3->payload_size = htons(packet->payload.size()); + memcpy(bp3->payload, packet->payload.data(), packet->payload.size()); + + return websocket_->Send(serialized.data(), serialized.size(), true); + } else { + return websocket_->Send(packet->payload.data(), packet->payload.size(), true); + } +} + +bool WebsocketProtocol::SendText(const std::string& text) { + if (websocket_ == nullptr || !websocket_->IsConnected()) { + return false; + } + + if (!websocket_->Send(text)) { + ESP_LOGE(TAG, "Failed to send text: %s", text.c_str()); + SetError(Lang::Strings::SERVER_ERROR); + return false; + } + + return true; +} + +bool WebsocketProtocol::IsAudioChannelOpened() const { + return websocket_ != nullptr && websocket_->IsConnected() && !error_occurred_ && !IsTimeout(); +} + +void WebsocketProtocol::CloseAudioChannel(bool send_goodbye) { + (void)send_goodbye; // Websocket doesn't need to send goodbye message + websocket_.reset(); +} + +bool WebsocketProtocol::OpenAudioChannel() { + Settings settings("websocket", false); + std::string url = settings.GetString("url"); + std::string token = settings.GetString("token"); + int version = settings.GetInt("version"); + if (version != 0) { + version_ = version; + } + + error_occurred_ = false; + + auto network = Board::GetInstance().GetNetwork(); + websocket_ = network->CreateWebSocket(1); + if (websocket_ == nullptr) { + ESP_LOGE(TAG, "Failed to create websocket"); + return false; + } + + if (!token.empty()) { + // If token not has a space, add "Bearer " prefix + if (token.find(" ") == std::string::npos) { + token = "Bearer " + token; + } + websocket_->SetHeader("Authorization", token.c_str()); + } + websocket_->SetHeader("Protocol-Version", std::to_string(version_).c_str()); + websocket_->SetHeader("Device-Id", SystemInfo::GetMacAddress().c_str()); + websocket_->SetHeader("Client-Id", Board::GetInstance().GetUuid().c_str()); + + websocket_->OnData([this](const char* data, size_t len, bool binary) { + if (binary) { + if (on_incoming_audio_ != nullptr) { + if (version_ == 2) { + BinaryProtocol2* bp2 = (BinaryProtocol2*)data; + bp2->version = ntohs(bp2->version); + bp2->type = ntohs(bp2->type); + bp2->timestamp = ntohl(bp2->timestamp); + bp2->payload_size = ntohl(bp2->payload_size); + auto payload = (uint8_t*)bp2->payload; + on_incoming_audio_(std::make_unique(AudioStreamPacket{ + .sample_rate = server_sample_rate_, + .frame_duration = server_frame_duration_, + .timestamp = bp2->timestamp, + .payload = std::vector(payload, payload + bp2->payload_size) + })); + } else if (version_ == 3) { + BinaryProtocol3* bp3 = (BinaryProtocol3*)data; + bp3->type = bp3->type; + bp3->payload_size = ntohs(bp3->payload_size); + auto payload = (uint8_t*)bp3->payload; + on_incoming_audio_(std::make_unique(AudioStreamPacket{ + .sample_rate = server_sample_rate_, + .frame_duration = server_frame_duration_, + .timestamp = 0, + .payload = std::vector(payload, payload + bp3->payload_size) + })); + } else { + on_incoming_audio_(std::make_unique(AudioStreamPacket{ + .sample_rate = server_sample_rate_, + .frame_duration = server_frame_duration_, + .timestamp = 0, + .payload = std::vector((uint8_t*)data, (uint8_t*)data + len) + })); + } + } + } else { + // Parse JSON data + auto root = cJSON_Parse(data); + auto type = cJSON_GetObjectItem(root, "type"); + if (cJSON_IsString(type)) { + if (strcmp(type->valuestring, "hello") == 0) { + ParseServerHello(root); + } else { + if (on_incoming_json_ != nullptr) { + on_incoming_json_(root); + } + } + } else { + ESP_LOGE(TAG, "Missing message type, data: %s", data); + } + cJSON_Delete(root); + } + last_incoming_time_ = std::chrono::steady_clock::now(); + }); + + websocket_->OnDisconnected([this]() { + ESP_LOGI(TAG, "Websocket disconnected"); + if (on_audio_channel_closed_ != nullptr) { + on_audio_channel_closed_(); + } + }); + + ESP_LOGI(TAG, "Connecting to websocket server: %s with version: %d", url.c_str(), version_); + if (!websocket_->Connect(url.c_str())) { + ESP_LOGE(TAG, "Failed to connect to websocket server, code=%d", websocket_->GetLastError()); + SetError(Lang::Strings::SERVER_NOT_CONNECTED); + return false; + } + + // Send hello message to describe the client + auto message = GetHelloMessage(); + if (!SendText(message)) { + return false; + } + + // Wait for server hello + EventBits_t bits = xEventGroupWaitBits(event_group_handle_, WEBSOCKET_PROTOCOL_SERVER_HELLO_EVENT, pdTRUE, pdFALSE, pdMS_TO_TICKS(10000)); + if (!(bits & WEBSOCKET_PROTOCOL_SERVER_HELLO_EVENT)) { + ESP_LOGE(TAG, "Failed to receive server hello"); + SetError(Lang::Strings::SERVER_TIMEOUT); + return false; + } + + if (on_audio_channel_opened_ != nullptr) { + on_audio_channel_opened_(); + } + + return true; +} + +std::string WebsocketProtocol::GetHelloMessage() { + // keys: message type, version, audio_params (format, sample_rate, channels) + cJSON* root = cJSON_CreateObject(); + cJSON_AddStringToObject(root, "type", "hello"); + cJSON_AddNumberToObject(root, "version", version_); + cJSON* features = cJSON_CreateObject(); +#if CONFIG_USE_SERVER_AEC + cJSON_AddBoolToObject(features, "aec", true); +#endif + cJSON_AddBoolToObject(features, "mcp", true); + cJSON_AddItemToObject(root, "features", features); + cJSON_AddStringToObject(root, "transport", "websocket"); + cJSON* audio_params = cJSON_CreateObject(); + cJSON_AddStringToObject(audio_params, "format", "opus"); + cJSON_AddNumberToObject(audio_params, "sample_rate", 16000); + cJSON_AddNumberToObject(audio_params, "channels", 1); + cJSON_AddNumberToObject(audio_params, "frame_duration", OPUS_FRAME_DURATION_MS); + cJSON_AddItemToObject(root, "audio_params", audio_params); + auto json_str = cJSON_PrintUnformatted(root); + std::string message(json_str); + cJSON_free(json_str); + cJSON_Delete(root); + return message; +} + +void WebsocketProtocol::ParseServerHello(const cJSON* root) { + auto transport = cJSON_GetObjectItem(root, "transport"); + if (transport == nullptr || strcmp(transport->valuestring, "websocket") != 0) { + ESP_LOGE(TAG, "Unsupported transport: %s", transport->valuestring); + return; + } + + auto session_id = cJSON_GetObjectItem(root, "session_id"); + if (cJSON_IsString(session_id)) { + session_id_ = session_id->valuestring; + ESP_LOGI(TAG, "Session ID: %s", session_id_.c_str()); + } + + auto audio_params = cJSON_GetObjectItem(root, "audio_params"); + if (cJSON_IsObject(audio_params)) { + auto sample_rate = cJSON_GetObjectItem(audio_params, "sample_rate"); + if (cJSON_IsNumber(sample_rate)) { + server_sample_rate_ = sample_rate->valueint; + } + auto frame_duration = cJSON_GetObjectItem(audio_params, "frame_duration"); + if (cJSON_IsNumber(frame_duration)) { + server_frame_duration_ = frame_duration->valueint; + } + } + + xEventGroupSetBits(event_group_handle_, WEBSOCKET_PROTOCOL_SERVER_HELLO_EVENT); +} diff --git a/main/protocols/websocket_protocol.h b/main/protocols/websocket_protocol.h new file mode 100644 index 0000000..48bbc00 --- /dev/null +++ b/main/protocols/websocket_protocol.h @@ -0,0 +1,34 @@ +#ifndef _WEBSOCKET_PROTOCOL_H_ +#define _WEBSOCKET_PROTOCOL_H_ + + +#include "protocol.h" + +#include +#include +#include + +#define WEBSOCKET_PROTOCOL_SERVER_HELLO_EVENT (1 << 0) + +class WebsocketProtocol : public Protocol { +public: + WebsocketProtocol(); + ~WebsocketProtocol(); + + bool Start() override; + bool SendAudio(std::unique_ptr packet) override; + bool OpenAudioChannel() override; + void CloseAudioChannel(bool send_goodbye = true) override; + bool IsAudioChannelOpened() const override; + +private: + EventGroupHandle_t event_group_handle_; + std::unique_ptr websocket_; + int version_ = 1; + + void ParseServerHello(const cJSON* root); + bool SendText(const std::string& text) override; + std::string GetHelloMessage(); +}; + +#endif diff --git a/main/settings.cc b/main/settings.cc new file mode 100644 index 0000000..3b40622 --- /dev/null +++ b/main/settings.cc @@ -0,0 +1,108 @@ +#include "settings.h" + +#include +#include + +#define TAG "Settings" + +Settings::Settings(const std::string& ns, bool read_write) : ns_(ns), read_write_(read_write) { + nvs_open(ns.c_str(), read_write_ ? NVS_READWRITE : NVS_READONLY, &nvs_handle_); +} + +Settings::~Settings() { + if (nvs_handle_ != 0) { + if (read_write_ && dirty_) { + ESP_ERROR_CHECK(nvs_commit(nvs_handle_)); + } + nvs_close(nvs_handle_); + } +} + +std::string Settings::GetString(const std::string& key, const std::string& default_value) { + if (nvs_handle_ == 0) { + return default_value; + } + + size_t length = 0; + if (nvs_get_str(nvs_handle_, key.c_str(), nullptr, &length) != ESP_OK) { + return default_value; + } + + std::string value; + value.resize(length); + ESP_ERROR_CHECK(nvs_get_str(nvs_handle_, key.c_str(), value.data(), &length)); + while (!value.empty() && value.back() == '\0') { + value.pop_back(); + } + return value; +} + +void Settings::SetString(const std::string& key, const std::string& value) { + if (read_write_) { + ESP_ERROR_CHECK(nvs_set_str(nvs_handle_, key.c_str(), value.c_str())); + dirty_ = true; + } else { + ESP_LOGW(TAG, "Namespace %s is not open for writing", ns_.c_str()); + } +} + +int32_t Settings::GetInt(const std::string& key, int32_t default_value) { + if (nvs_handle_ == 0) { + return default_value; + } + + int32_t value; + if (nvs_get_i32(nvs_handle_, key.c_str(), &value) != ESP_OK) { + return default_value; + } + return value; +} + +void Settings::SetInt(const std::string& key, int32_t value) { + if (read_write_) { + ESP_ERROR_CHECK(nvs_set_i32(nvs_handle_, key.c_str(), value)); + dirty_ = true; + } else { + ESP_LOGW(TAG, "Namespace %s is not open for writing", ns_.c_str()); + } +} + +bool Settings::GetBool(const std::string& key, bool default_value) { + if (nvs_handle_ == 0) { + return default_value; + } + + uint8_t value; + if (nvs_get_u8(nvs_handle_, key.c_str(), &value) != ESP_OK) { + return default_value; + } + return value != 0; +} + +void Settings::SetBool(const std::string& key, bool value) { + if (read_write_) { + ESP_ERROR_CHECK(nvs_set_u8(nvs_handle_, key.c_str(), value ? 1 : 0)); + dirty_ = true; + } else { + ESP_LOGW(TAG, "Namespace %s is not open for writing", ns_.c_str()); + } +} + +void Settings::EraseKey(const std::string& key) { + if (read_write_) { + auto ret = nvs_erase_key(nvs_handle_, key.c_str()); + if (ret != ESP_ERR_NVS_NOT_FOUND) { + ESP_ERROR_CHECK(ret); + } + } else { + ESP_LOGW(TAG, "Namespace %s is not open for writing", ns_.c_str()); + } +} + +void Settings::EraseAll() { + if (read_write_) { + ESP_ERROR_CHECK(nvs_erase_all(nvs_handle_)); + } else { + ESP_LOGW(TAG, "Namespace %s is not open for writing", ns_.c_str()); + } +} diff --git a/main/settings.h b/main/settings.h new file mode 100644 index 0000000..7eb596e --- /dev/null +++ b/main/settings.h @@ -0,0 +1,28 @@ +#ifndef SETTINGS_H +#define SETTINGS_H + +#include +#include + +class Settings { +public: + Settings(const std::string& ns, bool read_write = false); + ~Settings(); + + std::string GetString(const std::string& key, const std::string& default_value = ""); + void SetString(const std::string& key, const std::string& value); + int32_t GetInt(const std::string& key, int32_t default_value = 0); + void SetInt(const std::string& key, int32_t value); + bool GetBool(const std::string& key, bool default_value = false); + void SetBool(const std::string& key, bool value); + void EraseKey(const std::string& key); + void EraseAll(); + +private: + std::string ns_; + nvs_handle_t nvs_handle_ = 0; + bool read_write_ = false; + bool dirty_ = false; +}; + +#endif diff --git a/main/system_info.cc b/main/system_info.cc new file mode 100644 index 0000000..3c011d1 --- /dev/null +++ b/main/system_info.cc @@ -0,0 +1,156 @@ +#include "system_info.h" + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#if CONFIG_IDF_TARGET_ESP32P4 +#include "esp_wifi_remote.h" +#endif + +#define TAG "SystemInfo" + +size_t SystemInfo::GetFlashSize() { + uint32_t flash_size; + if (esp_flash_get_size(NULL, &flash_size) != ESP_OK) { + ESP_LOGE(TAG, "Failed to get flash size"); + return 0; + } + return (size_t)flash_size; +} + +size_t SystemInfo::GetMinimumFreeHeapSize() { + return esp_get_minimum_free_heap_size(); +} + +size_t SystemInfo::GetFreeHeapSize() { + return esp_get_free_heap_size(); +} + +std::string SystemInfo::GetMacAddress() { + uint8_t mac[6]; +#if CONFIG_IDF_TARGET_ESP32P4 + esp_wifi_get_mac(WIFI_IF_STA, mac); +#else + esp_read_mac(mac, ESP_MAC_WIFI_STA); +#endif + char mac_str[18]; + snprintf(mac_str, sizeof(mac_str), "%02x:%02x:%02x:%02x:%02x:%02x", mac[0], mac[1], mac[2], mac[3], mac[4], mac[5]); + return std::string(mac_str); +} + +std::string SystemInfo::GetChipModelName() { + return std::string(CONFIG_IDF_TARGET); +} + +std::string SystemInfo::GetUserAgent() { + auto app_desc = esp_app_get_description(); + auto user_agent = std::string(BOARD_NAME "/") + app_desc->version; + return user_agent; +} + +esp_err_t SystemInfo::PrintTaskCpuUsage(TickType_t xTicksToWait) { + #define ARRAY_SIZE_OFFSET 5 + TaskStatus_t *start_array = NULL, *end_array = NULL; + UBaseType_t start_array_size, end_array_size; + configRUN_TIME_COUNTER_TYPE start_run_time, end_run_time; + esp_err_t ret; + uint32_t total_elapsed_time; + + //Allocate array to store current task states + start_array_size = uxTaskGetNumberOfTasks() + ARRAY_SIZE_OFFSET; + start_array = (TaskStatus_t*)malloc(sizeof(TaskStatus_t) * start_array_size); + if (start_array == NULL) { + ret = ESP_ERR_NO_MEM; + goto exit; + } + //Get current task states + start_array_size = uxTaskGetSystemState(start_array, start_array_size, &start_run_time); + if (start_array_size == 0) { + ret = ESP_ERR_INVALID_SIZE; + goto exit; + } + + vTaskDelay(xTicksToWait); + + //Allocate array to store tasks states post delay + end_array_size = uxTaskGetNumberOfTasks() + ARRAY_SIZE_OFFSET; + end_array = (TaskStatus_t*)malloc(sizeof(TaskStatus_t) * end_array_size); + if (end_array == NULL) { + ret = ESP_ERR_NO_MEM; + goto exit; + } + //Get post delay task states + end_array_size = uxTaskGetSystemState(end_array, end_array_size, &end_run_time); + if (end_array_size == 0) { + ret = ESP_ERR_INVALID_SIZE; + goto exit; + } + + //Calculate total_elapsed_time in units of run time stats clock period. + total_elapsed_time = (end_run_time - start_run_time); + if (total_elapsed_time == 0) { + ret = ESP_ERR_INVALID_STATE; + goto exit; + } + + printf("| Task | Run Time | Percentage\n"); + //Match each task in start_array to those in the end_array + for (int i = 0; i < start_array_size; i++) { + int k = -1; + for (int j = 0; j < end_array_size; j++) { + if (start_array[i].xHandle == end_array[j].xHandle) { + k = j; + //Mark that task have been matched by overwriting their handles + start_array[i].xHandle = NULL; + end_array[j].xHandle = NULL; + break; + } + } + //Check if matching task found + if (k >= 0) { + uint32_t task_elapsed_time = end_array[k].ulRunTimeCounter - start_array[i].ulRunTimeCounter; + uint32_t percentage_time = (task_elapsed_time * 100UL) / (total_elapsed_time * CONFIG_FREERTOS_NUMBER_OF_CORES); + printf("| %-16s | %8lu | %4lu%%\n", start_array[i].pcTaskName, task_elapsed_time, percentage_time); + } + } + + //Print unmatched tasks + for (int i = 0; i < start_array_size; i++) { + if (start_array[i].xHandle != NULL) { + printf("| %s | Deleted\n", start_array[i].pcTaskName); + } + } + for (int i = 0; i < end_array_size; i++) { + if (end_array[i].xHandle != NULL) { + printf("| %s | Created\n", end_array[i].pcTaskName); + } + } + ret = ESP_OK; + +exit: //Common return path + free(start_array); + free(end_array); + return ret; +} + +void SystemInfo::PrintTaskList() { + char buffer[1000]; + vTaskList(buffer); + ESP_LOGI(TAG, "Task list: \n%s", buffer); +} + +void SystemInfo::PrintHeapStats() { + int free_sram = heap_caps_get_free_size(MALLOC_CAP_INTERNAL); + int min_free_sram = heap_caps_get_minimum_free_size(MALLOC_CAP_INTERNAL); + ESP_LOGI(TAG, "free sram: %u minimal sram: %u", free_sram, min_free_sram); +} + +void SystemInfo::PrintPmLocks() { + esp_pm_dump_locks(stdout); +} diff --git a/main/system_info.h b/main/system_info.h new file mode 100644 index 0000000..1c0d410 --- /dev/null +++ b/main/system_info.h @@ -0,0 +1,23 @@ +#ifndef _SYSTEM_INFO_H_ +#define _SYSTEM_INFO_H_ + +#include + +#include +#include + +class SystemInfo { +public: + static size_t GetFlashSize(); + static size_t GetMinimumFreeHeapSize(); + static size_t GetFreeHeapSize(); + static std::string GetMacAddress(); + static std::string GetChipModelName(); + static std::string GetUserAgent(); + static esp_err_t PrintTaskCpuUsage(TickType_t xTicksToWait); + static void PrintTaskList(); + static void PrintHeapStats(); + static void PrintPmLocks(); +}; + +#endif // _SYSTEM_INFO_H_ diff --git a/partitions/v1/16m.csv b/partitions/v1/16m.csv new file mode 100644 index 0000000..bc3a0e8 --- /dev/null +++ b/partitions/v1/16m.csv @@ -0,0 +1,8 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +model, data, spiffs, 0x10000, 0xF0000, +ota_0, app, ota_0, 0x100000, 6M, +ota_1, app, ota_1, 0x700000, 6M, diff --git a/partitions/v1/16m_custom_wakeword.csv b/partitions/v1/16m_custom_wakeword.csv new file mode 100644 index 0000000..868294e --- /dev/null +++ b/partitions/v1/16m_custom_wakeword.csv @@ -0,0 +1,8 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +model, data, spiffs, 0x10000, 0x3f0000, +ota_0, app, ota_0, 0x400000, 6M, +ota_1, app, ota_1, 0xa00000, 6M, \ No newline at end of file diff --git a/partitions/v1/16m_echoear.csv b/partitions/v1/16m_echoear.csv new file mode 100644 index 0000000..543c92c --- /dev/null +++ b/partitions/v1/16m_echoear.csv @@ -0,0 +1,9 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +model, data, spiffs, 0x10000, 0xF0000, +ota_0, app, ota_0, 0x100000, 5M, +ota_1, app, ota_1, 0x700000, 5M, +assets_A, data, spiffs, , 4000K, diff --git a/partitions/v1/32m.csv b/partitions/v1/32m.csv new file mode 100644 index 0000000..e95eb22 --- /dev/null +++ b/partitions/v1/32m.csv @@ -0,0 +1,10 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvsfactory, data, nvs, , 200K, +nvs, data, nvs, , 840K, +otadata, data, ota, , 0x2000, +phy_init, data, phy, , 0x1000, +model, data, spiffs, , 0xF0000, +# According to scripts/versions.py, app partition must be aligned to 1MB +ota_0, app, ota_0, 0x200000, 12M, +ota_1, app, ota_1, , 12M, diff --git a/partitions/v1/4m.csv b/partitions/v1/4m.csv new file mode 100644 index 0000000..101349f --- /dev/null +++ b/partitions/v1/4m.csv @@ -0,0 +1,7 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +model, data, spiffs, 0x10000, 0xF0000, +factory, app, factory, 0x100000, 3M, diff --git a/partitions/v1/4m_esp-hi.csv b/partitions/v1/4m_esp-hi.csv new file mode 100644 index 0000000..90c9c43 --- /dev/null +++ b/partitions/v1/4m_esp-hi.csv @@ -0,0 +1,8 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +model, data, spiffs, 0x10000, 0xD0000, +factory, app, factory, 0xe0000, 2200K, +assets_A, data, spiffs, , 700K, diff --git a/partitions/v1/8m.csv b/partitions/v1/8m.csv new file mode 100644 index 0000000..1e0e943 --- /dev/null +++ b/partitions/v1/8m.csv @@ -0,0 +1,8 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +model, data, spiffs, 0x10000, 0xF0000, +ota_0, app, ota_0, 0x100000, 0x380000, +ota_1, app, ota_1, 0x480000, 0x380000, diff --git a/partitions/v2/16m.csv b/partitions/v2/16m.csv new file mode 100644 index 0000000..8af93a3 --- /dev/null +++ b/partitions/v2/16m.csv @@ -0,0 +1,8 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +ota_0, app, ota_0, 0x20000, 0x3f0000, +ota_1, app, ota_1, , 0x3f0000, +assets, data, spiffs, 0x800000, 8M diff --git a/partitions/v2/16m_c3.csv b/partitions/v2/16m_c3.csv new file mode 100644 index 0000000..e444023 --- /dev/null +++ b/partitions/v2/16m_c3.csv @@ -0,0 +1,8 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +ota_0, app, ota_0, 0x20000, 0x3f0000, +ota_1, app, ota_1, , 0x3f0000, +assets, data, spiffs, 0x800000, 4000K diff --git a/partitions/v2/32m.csv b/partitions/v2/32m.csv new file mode 100644 index 0000000..d829cbb --- /dev/null +++ b/partitions/v2/32m.csv @@ -0,0 +1,9 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvsfactory, data, nvs, , 200K, +nvs, data, nvs, , 840K, +otadata, data, ota, , 0x2000, +phy_init, data, phy, , 0x1000, +ota_0, app, ota_0, 0x200000, 4M, +ota_1, app, ota_1, 0x600000, 4M, +assets, data, spiffs, 0xA00000, 16M diff --git a/partitions/v2/4m.csv b/partitions/v2/4m.csv new file mode 100644 index 0000000..e46fa75 --- /dev/null +++ b/partitions/v2/4m.csv @@ -0,0 +1,7 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +factory, app, factory, 0x10000, 0x2CE000, +assets, data, spiffs, 0x2DE000, 0x122000, \ No newline at end of file diff --git a/partitions/v2/8m.csv b/partitions/v2/8m.csv new file mode 100644 index 0000000..bae9f15 --- /dev/null +++ b/partitions/v2/8m.csv @@ -0,0 +1,8 @@ +# ESP-IDF Partition Table +# Name, Type, SubType, Offset, Size, Flags +nvs, data, nvs, 0x9000, 0x4000, +otadata, data, ota, 0xd000, 0x2000, +phy_init, data, phy, 0xf000, 0x1000, +ota_0, app, ota_0, 0x20000, 0x2f0000, +ota_1, app, ota_1, , 0x2f0000, +assets, data, spiffs, 0x600000, 2M diff --git a/partitions/v2/README.md b/partitions/v2/README.md new file mode 100644 index 0000000..0b56851 --- /dev/null +++ b/partitions/v2/README.md @@ -0,0 +1,107 @@ +# Version 2 Partition Table + +This version introduces significant improvements over v1 by adding an `assets` partition to support network-loadable content and optimizing partition layouts for different flash sizes. + +## Key Changes from v1 + +### Major Improvements +1. **Added Assets Partition**: New `assets` partition for network-loadable content +2. **Replaced Model Partition**: The old `model` partition (960KB) is replaced with a larger `assets` partition +3. **Optimized App Partitions**: Reduced application partition sizes to accommodate assets +4. **Enhanced Flexibility**: Support for dynamic content updates without reflashing + +### Assets Partition Features +The `assets` partition stores: +- **Wake word models**: Customizable wake word models that can be loaded from the network +- **Theme files**: Complete theming system including: + - Fonts (text and icon fonts) + - Audio effects and sound files + - Background images and UI elements + - Custom emoji packs + - Language configuration files +- **Dynamic Content**: All content can be updated over-the-air via HTTP downloads + +## Partition Layout Comparison + +### v1 Layout (16MB) +- `nvs`: 16KB (non-volatile storage) +- `otadata`: 8KB (OTA data) +- `phy_init`: 4KB (PHY initialization data) +- `model`: 960KB (model storage - fixed content) +- `ota_0`: 6MB (application partition 0) +- `ota_1`: 6MB (application partition 1) + +### v2 Layout (16MB) +- `nvs`: 16KB (non-volatile storage) +- `otadata`: 8KB (OTA data) +- `phy_init`: 4KB (PHY initialization data) +- `ota_0`: 4MB (application partition 0) +- `ota_1`: 4MB (application partition 1) +- `assets`: 8MB (network-loadable assets) + +## Available Configurations + +### 8MB Flash Devices (`8m.csv`) +- `nvs`: 16KB +- `otadata`: 8KB +- `phy_init`: 4KB +- `ota_0`: 3MB +- `ota_1`: 3MB +- `assets`: 2MB + +### 16MB Flash Devices (`16m.csv`) - Standard +- `nvs`: 16KB +- `otadata`: 8KB +- `phy_init`: 4KB +- `ota_0`: 4MB +- `ota_1`: 4MB +- `assets`: 8MB + +### 16MB Flash Devices (`16m_c3.csv`) - ESP32-C3 Optimized +- `nvs`: 16KB +- `otadata`: 8KB +- `phy_init`: 4KB +- `ota_0`: 4MB +- `ota_1`: 4MB +- `assets`: 4MB (4000K - limited by available mmap pages) + +### 32MB Flash Devices (`32m.csv`) +- `nvsfactory`: 200KB +- `nvs`: 840KB +- `otadata`: 8KB +- `phy_init`: 4KB +- `ota_0`: 4MB +- `ota_1`: 4MB +- `assets`: 16MB + +## Benefits + +1. **Dynamic Content Management**: Users can download and update wake word models, themes, and other assets without reflashing the device +2. **Reduced App Size**: Application partitions are optimized, allowing more space for dynamic content +3. **Enhanced Customization**: Support for custom themes, wake words, and language packs enhances user experience +4. **Network Flexibility**: Assets can be updated independently of the main application firmware +5. **Better Resource Utilization**: Efficient use of flash memory with configurable asset storage +6. **OTA Asset Updates**: Assets can be updated over-the-air via HTTP downloads + +## Technical Details + +- **Partition Type**: Assets partition uses `spiffs` subtype for SPIFFS filesystem compatibility +- **Memory Mapping**: Assets are memory-mapped for efficient access during runtime +- **Checksum Validation**: Built-in integrity checking ensures asset data validity +- **Progressive Download**: Assets can be downloaded progressively with progress tracking +- **Fallback Support**: Graceful fallback to default assets if network updates fail + +## Migration from v1 + +When upgrading from v1 to v2: +1. **Backup Important Data**: Ensure any important data in the old `model` partition is backed up +2. **Flash New Partition Table**: Use the appropriate v2 partition table for your flash size +3. **Download Assets**: The device will automatically download required assets on first boot +4. **Verify Functionality**: Ensure all features work correctly with the new partition layout + +## Usage Notes + +- The `assets` partition size varies by configuration to optimize for different flash sizes +- ESP32-C3 devices use a smaller assets partition (4MB) due to limited available mmap pages in the system +- 32MB devices get the largest assets partition (16MB) for maximum content storage +- All partition tables maintain proper alignment for optimal flash performance \ No newline at end of file diff --git a/scripts/Image_Converter/LVGLImage.py b/scripts/Image_Converter/LVGLImage.py new file mode 100644 index 0000000..b2ffbb3 --- /dev/null +++ b/scripts/Image_Converter/LVGLImage.py @@ -0,0 +1,1426 @@ +#!/usr/bin/env python3 +import os +import logging +import argparse +import subprocess +from os import path +from enum import Enum +from typing import List +from pathlib import Path + +try: + import png +except ImportError: + raise ImportError("Need pypng package, do `pip3 install pypng`") + +try: + import lz4.block +except ImportError: + raise ImportError("Need lz4 package, do `pip3 install lz4`") + + +def uint8_t(val) -> bytes: + return val.to_bytes(1, byteorder='little') + + +def uint16_t(val) -> bytes: + return val.to_bytes(2, byteorder='little') + + +def uint24_t(val) -> bytes: + return val.to_bytes(3, byteorder='little') + + +def uint32_t(val) -> bytes: + try: + return val.to_bytes(4, byteorder='little') + except OverflowError: + raise ParameterError(f"overflow: {hex(val)}") + + +def color_pre_multiply(r, g, b, a, background): + bb = background & 0xff + bg = (background >> 8) & 0xff + br = (background >> 16) & 0xff + + return ((r * a + (255 - a) * br) >> 8, (g * a + (255 - a) * bg) >> 8, + (b * a + (255 - a) * bb) >> 8, a) + + +class Error(Exception): + + def __str__(self): + return self.__class__.__name__ + ': ' + ' '.join(self.args) + + +class FormatError(Error): + """ + Problem with input filename format. + BIN filename does not conform to standard lvgl bin image format + """ + + +class ParameterError(Error): + """ + Parameter for LVGL image not correct + """ + + +class PngQuant: + """ + Compress PNG file to 8bit mode using `pngquant` + """ + + def __init__(self, ncolors=256, dither=True, exec_path="") -> None: + executable = path.join(exec_path, "pngquant") + self.cmd = (f"{executable} {'--nofs' if not dither else ''} " + f"{ncolors} --force - < ") + + def convert(self, filename) -> bytes: + if not os.path.isfile(filename): + raise BaseException(f"file not found: {filename}") + + try: + compressed = subprocess.check_output( + f'{self.cmd} "{str(filename)}"', + stderr=subprocess.STDOUT, + shell=True) + except subprocess.CalledProcessError: + raise BaseException( + "cannot find pngquant tool, install it via " + "`sudo apt install pngquant` for debian " + "or `brew install pngquant` for macintosh " + "For windows, you may need to download pngquant.exe from " + "https://pngquant.org/, and put it in your PATH.") + + return compressed + + +class CompressMethod(Enum): + NONE = 0x00 + RLE = 0x01 + LZ4 = 0x02 + + +class ColorFormat(Enum): + UNKNOWN = 0x00 + RAW = 0x01, + RAW_ALPHA = 0x02, + L8 = 0x06 + I1 = 0x07 + I2 = 0x08 + I4 = 0x09 + I8 = 0x0A + A1 = 0x0B + A2 = 0x0C + A4 = 0x0D + A8 = 0x0E + ARGB8888 = 0x10 + XRGB8888 = 0x11 + RGB565 = 0x12 + ARGB8565 = 0x13 + RGB565A8 = 0x14 + RGB888 = 0x0F + + @property + def bpp(self) -> int: + """ + Return bit per pixel for this cf + """ + cf_map = { + ColorFormat.L8: 8, + ColorFormat.I1: 1, + ColorFormat.I2: 2, + ColorFormat.I4: 4, + ColorFormat.I8: 8, + ColorFormat.A1: 1, + ColorFormat.A2: 2, + ColorFormat.A4: 4, + ColorFormat.A8: 8, + ColorFormat.ARGB8888: 32, + ColorFormat.XRGB8888: 32, + ColorFormat.RGB565: 16, + ColorFormat.RGB565A8: 16, # 16bpp + a8 map + ColorFormat.ARGB8565: 24, + ColorFormat.RGB888: 24, + } + + return cf_map[self] if self in cf_map else 0 + + @property + def ncolors(self) -> int: + """ + Return number of colors in palette if cf is indexed1/2/4/8. + Return zero if cf is not indexed format + """ + + cf_map = { + ColorFormat.I1: 2, + ColorFormat.I2: 4, + ColorFormat.I4: 16, + ColorFormat.I8: 256, + } + return cf_map.get(self, 0) + + @property + def is_indexed(self) -> bool: + """ + Return if cf is indexed color format + """ + return self.ncolors != 0 + + @property + def is_alpha_only(self) -> bool: + return ColorFormat.A1.value <= self.value <= ColorFormat.A8.value + + @property + def has_alpha(self) -> bool: + return self.is_alpha_only or self.is_indexed or self in ( + ColorFormat.ARGB8888, + ColorFormat.XRGB8888, # const alpha: 0xff + ColorFormat.ARGB8565, + ColorFormat.RGB565A8) + + @property + def is_colormap(self) -> bool: + return self in (ColorFormat.ARGB8888, ColorFormat.RGB888, + ColorFormat.XRGB8888, ColorFormat.RGB565A8, + ColorFormat.ARGB8565, ColorFormat.RGB565) + + @property + def is_luma_only(self) -> bool: + return self in (ColorFormat.L8, ) + + +def bit_extend(value, bpp): + """ + Extend value from bpp to 8 bit with interpolation to reduce rounding error. + """ + + if value == 0: + return 0 + + res = value + bpp_now = bpp + while bpp_now < 8: + res |= value << (8 - bpp_now) + bpp_now += bpp + + return res + + +def unpack_colors(data: bytes, cf: ColorFormat, w) -> List: + """ + Unpack lvgl 1/2/4/8/16/32 bpp color to png color: alpha map, grey scale, + or R,G,B,(A) map + """ + ret = [] + bpp = cf.bpp + if bpp == 8: + ret = data + elif bpp == 4: + if cf == ColorFormat.A4: + values = [x * 17 for x in range(16)] + else: + values = [x for x in range(16)] + + for p in data: + for i in range(2): + ret.append(values[(p >> (4 - i * 4)) & 0x0f]) + if len(ret) % w == 0: + break + + elif bpp == 2: + if cf == ColorFormat.A2: + values = [x * 85 for x in range(4)] + else: # must be ColorFormat.I2 + values = [x for x in range(4)] + for p in data: + for i in range(4): + ret.append(values[(p >> (6 - i * 2)) & 0x03]) + if len(ret) % w == 0: + break + elif bpp == 1: + if cf == ColorFormat.A1: + values = [0, 255] + else: + values = [0, 1] + for p in data: + for i in range(8): + ret.append(values[(p >> (7 - i)) & 0x01]) + if len(ret) % w == 0: + break + elif bpp == 16: + # This is RGB565 + pixels = [(data[2 * i + 1] << 8) | data[2 * i] + for i in range(len(data) // 2)] + + for p in pixels: + ret.append(bit_extend((p >> 11) & 0x1f, 5)) # R + ret.append(bit_extend((p >> 5) & 0x3f, 6)) # G + ret.append(bit_extend((p >> 0) & 0x1f, 5)) # B + elif bpp == 24: + if cf == ColorFormat.RGB888: + B = data[0::3] + G = data[1::3] + R = data[2::3] + for r, g, b in zip(R, G, B): + ret += [r, g, b] + elif cf == ColorFormat.RGB565A8: + alpha_size = len(data) // 3 + pixel_alpha = data[-alpha_size:] + pixel_data = data[:-alpha_size] + pixels = [(pixel_data[2 * i + 1] << 8) | pixel_data[2 * i] + for i in range(len(pixel_data) // 2)] + + for a, p in zip(pixel_alpha, pixels): + ret.append(bit_extend((p >> 11) & 0x1f, 5)) # R + ret.append(bit_extend((p >> 5) & 0x3f, 6)) # G + ret.append(bit_extend((p >> 0) & 0x1f, 5)) # B + ret.append(a) + elif cf == ColorFormat.ARGB8565: + L = data[0::3] + H = data[1::3] + A = data[2::3] + + for h, l, a in zip(H, L, A): + p = (h << 8) | (l) + ret.append(bit_extend((p >> 11) & 0x1f, 5)) # R + ret.append(bit_extend((p >> 5) & 0x3f, 6)) # G + ret.append(bit_extend((p >> 0) & 0x1f, 5)) # B + ret.append(a) # A + + elif bpp == 32: + B = data[0::4] + G = data[1::4] + R = data[2::4] + A = data[3::4] + for r, g, b, a in zip(R, G, B, A): + ret += [r, g, b, a] + else: + assert 0 + + return ret + + +def write_c_array_file( + w: int, h: int, + stride: int, + cf: ColorFormat, + filename: str, + premultiplied: bool, + compress: CompressMethod, + data: bytes): + varname = path.basename(filename).split('.')[0] + varname = varname.replace("-", "_") + varname = varname.replace(".", "_") + + flags = "0" + if compress is not CompressMethod.NONE: + flags += " | LV_IMAGE_FLAGS_COMPRESSED" + if premultiplied: + flags += " | LV_IMAGE_FLAGS_PREMULTIPLIED" + + macro = "LV_ATTRIBUTE_" + varname.upper() + header = f''' +#if defined(LV_LVGL_H_INCLUDE_SIMPLE) +#include "lvgl.h" +#elif defined(LV_BUILD_TEST) +#include "../lvgl.h" +#else +#include "lvgl/lvgl.h" +#endif + + +#ifndef LV_ATTRIBUTE_MEM_ALIGN +#define LV_ATTRIBUTE_MEM_ALIGN +#endif + +#ifndef {macro} +#define {macro} +#endif + +static const +LV_ATTRIBUTE_MEM_ALIGN LV_ATTRIBUTE_LARGE_CONST {macro} +uint8_t {varname}_map[] = {{ +''' + + ending = f''' +}}; + +const lv_image_dsc_t {varname} = {{ + .header.magic = LV_IMAGE_HEADER_MAGIC, + .header.cf = LV_COLOR_FORMAT_{cf.name}, + .header.flags = {flags}, + .header.w = {w}, + .header.h = {h}, + .header.stride = {stride}, + .data_size = sizeof({varname}_map), + .data = {varname}_map, +}}; + +''' + + def write_binary(f, data, stride): + stride = 16 if stride == 0 else stride + for i, v in enumerate(data): + if i % stride == 0: + f.write("\n ") + f.write(f"0x{v:02x},") + f.write("\n") + + with open(filename, "w+") as f: + f.write(header) + + if compress != CompressMethod.NONE: + write_binary(f, data, 16) + else: + # write palette separately + ncolors = cf.ncolors + if ncolors: + write_binary(f, data[:ncolors * 4], 16) + + write_binary(f, data[ncolors * 4:], stride) + + f.write(ending) + + +class LVGLImageHeader: + + def __init__(self, + cf: ColorFormat = ColorFormat.UNKNOWN, + w: int = 0, + h: int = 0, + stride: int = 0, + align: int = 1, + flags: int = 0): + self.cf = cf + self.flags = flags + self.w = w & 0xffff + self.h = h & 0xffff + if w > 0xffff or h > 0xffff: + raise ParameterError(f"w, h overflow: {w}x{h}") + if align < 1: + # stride align in bytes must be larger than 1 + raise ParameterError(f"Invalid stride align: {align}") + + self.stride = self.stride_align(align) if stride == 0 else stride + + def stride_align(self, align: int) -> int: + stride = self.stride_default + if align == 1: + pass + elif align > 1: + stride = (stride + align - 1) // align + stride *= align + else: + raise ParameterError(f"Invalid stride align: {align}") + + self.stride = stride + return stride + + @property + def stride_default(self) -> int: + return (self.w * self.cf.bpp + 7) // 8 + + @property + def binary(self) -> bytearray: + binary = bytearray() + binary += uint8_t(0x19) # magic number for lvgl version 9 + binary += uint8_t(self.cf.value) + binary += uint16_t(self.flags) # 16bits flags + + binary += uint16_t(self.w) # 16bits width + binary += uint16_t(self.h) # 16bits height + binary += uint16_t(self.stride) # 16bits stride + + binary += uint16_t(0) # 16bits reserved + return binary + + def from_binary(self, data: bytes): + if len(data) < 12: + raise FormatError("invalid header length") + + try: + self.cf = ColorFormat(data[1] & 0x1f) # color format + except ValueError as exc: + raise FormatError(f"invalid color format: {hex(data[0])}") from exc + self.w = int.from_bytes(data[4:6], 'little') + self.h = int.from_bytes(data[6:8], 'little') + self.stride = int.from_bytes(data[8:10], 'little') + return self + + +class LVGLCompressData: + + def __init__(self, + cf: ColorFormat, + method: CompressMethod, + raw_data: bytes = b''): + self.blk_size = (cf.bpp + 7) // 8 + self.compress = method + self.raw_data = raw_data + self.raw_data_len = len(raw_data) + self.compressed = self._compress(raw_data) + + def _compress(self, raw_data: bytes) -> bytearray: + if self.compress == CompressMethod.NONE: + return raw_data + + if self.compress == CompressMethod.RLE: + # RLE compression performs on pixel unit, pad data to pixel unit + pad = b'\x00' * 0 + if self.raw_data_len % self.blk_size: + pad = b'\x00' * (self.blk_size - self.raw_data_len % self.blk_size) + compressed = RLEImage().rle_compress(raw_data + pad, self.blk_size) + elif self.compress == CompressMethod.LZ4: + compressed = lz4.block.compress(raw_data, store_size=False) + else: + raise ParameterError(f"Invalid compress method: {self.compress}") + + self.compressed_len = len(compressed) + + bin = bytearray() + bin += uint32_t(self.compress.value) + bin += uint32_t(self.compressed_len) + bin += uint32_t(self.raw_data_len) + bin += compressed + return bin + + +class LVGLImage: + + def __init__(self, + cf: ColorFormat = ColorFormat.UNKNOWN, + w: int = 0, + h: int = 0, + data: bytes = b'') -> None: + self.stride = 0 # default no valid stride value + self.premultiplied = False + self.rgb565_dither = False + self.set_data(cf, w, h, data) + + def __repr__(self) -> str: + return (f"'LVGL image {self.w}x{self.h}, {self.cf.name}, " + f"{'Pre-multiplied, ' if self.premultiplied else ''}" + f"stride: {self.stride} " + f"(12+{self.data_len})Byte'") + + def adjust_stride(self, stride: int = 0, align: int = 1): + """ + Stride can be set directly, or by stride alignment in bytes + """ + if self.stride == 0: + # stride can only be 0, when LVGLImage is created with empty data + logging.warning("Cannot adjust stride for empty image") + return + + if align >= 1 and stride == 0: + # The header with specified stride alignment + header = LVGLImageHeader(self.cf, self.w, self.h, align=align) + stride = header.stride + elif stride > 0: + pass + else: + raise ParameterError(f"Invalid parameter, align:{align}," + f" stride:{stride}") + + if self.stride == stride: + return # no stride adjustment + + # if current image is empty, no need to do anything + if self.data_len == 0: + self.stride = 0 + return + + current = LVGLImageHeader(self.cf, self.w, self.h, stride=self.stride) + + if stride < current.stride_default: + raise ParameterError(f"Stride is too small:{stride}, " + f"minimal:{current.stride_default}") + + def change_stride(data: bytearray, h, current_stride, new_stride): + data_in = data + data_out = [] # stride adjusted new data + if new_stride < current_stride: # remove padding byte + for i in range(h): + start = i * current_stride + end = start + new_stride + data_out.append(data_in[start:end]) + else: # adding more padding bytes + padding = b'\x00' * (new_stride - current_stride) + for i in range(h): + data_out.append(data_in[i * current_stride:(i + 1) * + current_stride]) + data_out.append(padding) + return b''.join(data_out) + + palette_size = self.cf.ncolors * 4 + data_out = [self.data[:palette_size]] + data_out.append( + change_stride(self.data[palette_size:], self.h, current.stride, + stride)) + + # deal with alpha map for RGB565A8 + if self.cf == ColorFormat.RGB565A8: + logging.warning("handle RGB565A8 alpha map") + a8_stride = self.stride // 2 + a8_map = self.data[-a8_stride * self.h:] + data_out.append( + change_stride(a8_map, self.h, current.stride // 2, + stride // 2)) + + self.stride = stride + self.data = bytearray(b''.join(data_out)) + + def premultiply(self): + """ + Pre-multiply image RGB data with alpha, set corresponding image header flags + """ + if self.premultiplied: + raise ParameterError("Image already pre-multiplied") + + if not self.cf.has_alpha: + raise ParameterError(f"Image has no alpha channel: {self.cf.name}") + + if self.cf.is_indexed: + + def multiply(r, g, b, a): + r, g, b = (r * a) >> 8, (g * a) >> 8, (b * a) >> 8 + return uint8_t(b) + uint8_t(g) + uint8_t(r) + uint8_t(a) + + # process the palette only. + palette_size = self.cf.ncolors * 4 + palette = self.data[:palette_size] + palette = [ + multiply(palette[i], palette[i + 1], palette[i + 2], + palette[i + 3]) for i in range(0, len(palette), 4) + ] + palette = b''.join(palette) + self.data = palette + self.data[palette_size:] + elif self.cf is ColorFormat.ARGB8888: + + def multiply(b, g, r, a): + r, g, b = (r * a) >> 8, (g * a) >> 8, (b * a) >> 8 + return uint32_t((a << 24) | (r << 16) | (g << 8) | (b << 0)) + + line_width = self.w * 4 + for h in range(self.h): + offset = h * self.stride + map = self.data[offset:offset + self.stride] + + processed = b''.join([ + multiply(map[i], map[i + 1], map[i + 2], map[i + 3]) + for i in range(0, line_width, 4) + ]) + self.data[offset:offset + line_width] = processed + elif self.cf is ColorFormat.RGB565A8: + + def multiply(data, a): + r = (data >> 11) & 0x1f + g = (data >> 5) & 0x3f + b = (data >> 0) & 0x1f + + r, g, b = (r * a) // 255, (g * a) // 255, (b * a) // 255 + return uint16_t((r << 11) | (g << 5) | (b << 0)) + + line_width = self.w * 2 + for h in range(self.h): + # alpha map offset for this line + offset = self.h * self.stride + h * (self.stride // 2) + a = self.data[offset:offset + self.stride // 2] + + # RGB map offset + offset = h * self.stride + rgb = self.data[offset:offset + self.stride] + + processed = b''.join([ + multiply((rgb[i + 1] << 8) | rgb[i], a[i // 2]) + for i in range(0, line_width, 2) + ]) + self.data[offset:offset + line_width] = processed + elif self.cf is ColorFormat.ARGB8565: + + def multiply(data, a): + r = (data >> 11) & 0x1f + g = (data >> 5) & 0x3f + b = (data >> 0) & 0x1f + + r, g, b = (r * a) // 255, (g * a) // 255, (b * a) // 255 + return uint24_t((a << 16) | (r << 11) | (g << 5) | (b << 0)) + + line_width = self.w * 3 + for h in range(self.h): + offset = h * self.stride + map = self.data[offset:offset + self.stride] + + processed = b''.join([ + multiply((map[i + 1] << 8) | map[i], map[i + 2]) + for i in range(0, line_width, 3) + ]) + self.data[offset:offset + line_width] = processed + else: + raise ParameterError(f"Not supported yet: {self.cf.name}") + + self.premultiplied = True + + @property + def data_len(self) -> int: + """ + Return data_len in byte of this image, excluding image header + """ + + # palette is always in ARGB format, 4Byte per color + p = self.cf.ncolors * 4 if self.is_indexed and self.w * self.h else 0 + p += self.stride * self.h + if self.cf is ColorFormat.RGB565A8: + a8_stride = self.stride // 2 + p += a8_stride * self.h + return p + + @property + def header(self) -> bytearray: + return LVGLImageHeader(self.cf, self.w, self.h) + + @property + def is_indexed(self): + return self.cf.is_indexed + + def set_data(self, + cf: ColorFormat, + w: int, + h: int, + data: bytes, + stride: int = 0): + """ + Directly set LVGL image parameters + """ + + if w > 0xffff or h > 0xffff: + raise ParameterError(f"w, h overflow: {w}x{h}") + + self.cf = cf + self.w = w + self.h = h + + # if stride is 0, then it's aligned to 1byte by default, + # let image header handle it + self.stride = LVGLImageHeader(cf, w, h, stride, align=1).stride + + if self.data_len != len(data): + raise ParameterError(f"{self} data length error got: {len(data)}, " + f"expect: {self.data_len}, {self}") + + self.data = data + + return self + + def from_data(self, data: bytes): + header = LVGLImageHeader().from_binary(data) + return self.set_data(header.cf, header.w, header.h, + data[len(header.binary):], header.stride) + + def from_bin(self, filename: str): + """ + Read from existing bin file and update image parameters + """ + + if not filename.endswith(".bin"): + raise FormatError("filename not ended with '.bin'") + + with open(filename, "rb") as f: + data = f.read() + return self.from_data(data) + + def _check_ext(self, filename: str, ext): + if not filename.lower().endswith(ext): + raise FormatError(f"filename not ended with {ext}") + + def _check_dir(self, filename: str): + dir = path.dirname(filename) + if dir and not path.exists(dir): + logging.info(f"mkdir of {dir} for {filename}") + os.makedirs(dir) + + def to_bin(self, + filename: str, + compress: CompressMethod = CompressMethod.NONE): + """ + Write this image to file, filename should be ended with '.bin' + """ + self._check_ext(filename, ".bin") + self._check_dir(filename) + + with open(filename, "wb+") as f: + bin = bytearray() + flags = 0 + flags |= 0x08 if compress != CompressMethod.NONE else 0 + flags |= 0x01 if self.premultiplied else 0 + + header = LVGLImageHeader(self.cf, + self.w, + self.h, + self.stride, + flags=flags) + bin += header.binary + compressed = LVGLCompressData(self.cf, compress, self.data) + bin += compressed.compressed + + f.write(bin) + + return self + + def to_c_array(self, + filename: str, + compress: CompressMethod = CompressMethod.NONE): + self._check_ext(filename, ".c") + self._check_dir(filename) + + if compress != CompressMethod.NONE: + data = LVGLCompressData(self.cf, compress, self.data).compressed + else: + data = self.data + write_c_array_file(self.w, self.h, self.stride, self.cf, filename, + self.premultiplied, + compress, data) + + def to_png(self, filename: str): + self._check_ext(filename, ".png") + self._check_dir(filename) + + old_stride = self.stride + self.adjust_stride(align=1) + if self.cf.is_indexed: + data = self.data + # Separate lvgl bin image data to palette and bitmap + # The palette is in format of [(RGBA), (RGBA)...]. + # LVGL palette is in format of B,G,R,A,... + palette = [(data[i * 4 + 2], data[i * 4 + 1], data[i * 4 + 0], + data[i * 4 + 3]) for i in range(self.cf.ncolors)] + + data = data[self.cf.ncolors * 4:] + + encoder = png.Writer(self.w, + self.h, + palette=palette, + bitdepth=self.cf.bpp) + # separate packed data to plain data + data = unpack_colors(data, self.cf, self.w) + elif self.cf.is_alpha_only: + # separate packed data to plain data + transparency = unpack_colors(self.data, self.cf, self.w) + data = [] + for a in transparency: + data += [0, 0, 0, a] + encoder = png.Writer(self.w, self.h, greyscale=False, alpha=True) + elif self.cf == ColorFormat.L8: + # to grayscale + encoder = png.Writer(self.w, + self.h, + bitdepth=self.cf.bpp, + greyscale=True, + alpha=False) + data = self.data + elif self.cf.is_colormap: + encoder = png.Writer(self.w, + self.h, + alpha=self.cf.has_alpha, + greyscale=False) + data = unpack_colors(self.data, self.cf, self.w) + else: + logging.warning(f"missing logic: {self.cf.name}") + return + + with open(filename, "wb") as f: + encoder.write_array(f, data) + + self.adjust_stride(stride=old_stride) + + def from_png(self, + filename: str, + cf: ColorFormat = None, + background: int = 0x00_00_00, + rgb565_dither=False): + """ + Create lvgl image from png file. + If cf is none, used I1/2/4/8 based on palette size + """ + + self.background = background + self.rgb565_dither = rgb565_dither + + if cf is None: # guess cf from filename + # split filename string and match with ColorFormat to check + # which cf to use + names = str(path.basename(filename)).split(".") + for c in names[1:-1]: + if c in ColorFormat.__members__: + cf = ColorFormat[c] + break + + if cf is None or cf.is_indexed: # palette mode + self._png_to_indexed(cf, filename) + elif cf.is_alpha_only: + self._png_to_alpha_only(cf, filename) + elif cf.is_luma_only: + self._png_to_luma_only(cf, filename) + elif cf.is_colormap: + self._png_to_colormap(cf, filename) + else: + logging.warning(f"missing logic: {cf.name}") + + logging.info(f"from png: {filename}, cf: {self.cf.name}") + return self + + def _png_to_indexed(self, cf: ColorFormat, filename: str): + # convert to palette mode + auto_cf = cf is None + + # read the image data to get the metadata + reader = png.Reader(filename=filename) + w, h, rows, metadata = reader.read() + + # to preserve original palette data only convert the image if needed. For this + # check if image has a palette and the requested palette size equals the existing one + if not 'palette' in metadata or not auto_cf and len(metadata['palette']) != 2 ** cf.bpp: + # reread and convert file + reader = png.Reader( + bytes=PngQuant(256 if auto_cf else cf.ncolors).convert(filename)) + w, h, rows, _ = reader.read() + + palette = reader.palette(alpha="force") # always return alpha + + palette_len = len(palette) + if auto_cf: + if palette_len <= 2: + cf = ColorFormat.I1 + elif palette_len <= 4: + cf = ColorFormat.I2 + elif palette_len <= 16: + cf = ColorFormat.I4 + else: + cf = ColorFormat.I8 + + if palette_len != cf.ncolors: + if not auto_cf: + logging.warning( + f"{path.basename(filename)} palette: {palette_len}, " + f"extended to: {cf.ncolors}") + palette += [(255, 255, 255, 0)] * (cf.ncolors - palette_len) + + # Assemble lvgl image palette from PNG palette. + # PNG palette is a list of tuple(R,G,B,A) + + rawdata = bytearray() + for (r, g, b, a) in palette: + rawdata += uint32_t((a << 24) | (r << 16) | (g << 8) | (b << 0)) + + # pack data if not in I8 format + if cf == ColorFormat.I8: + for e in rows: + rawdata += e + else: + for e in png.pack_rows(rows, cf.bpp): + rawdata += e + + self.set_data(cf, w, h, rawdata) + + def _png_to_alpha_only(self, cf: ColorFormat, filename: str): + reader = png.Reader(str(filename)) + w, h, rows, info = reader.asRGBA8() + if not info['alpha']: + raise FormatError(f"{filename} has no alpha channel") + + rawdata = bytearray() + if cf == ColorFormat.A8: + for row in rows: + A = row[3::4] + for e in A: + rawdata += uint8_t(e) + else: + shift = 8 - cf.bpp + mask = 2**cf.bpp - 1 + rows = [[(a >> shift) & mask for a in row[3::4]] for row in rows] + for row in png.pack_rows(rows, cf.bpp): + rawdata += row + + self.set_data(cf, w, h, rawdata) + + def sRGB_to_linear(self, x): + if x < 0.04045: + return x / 12.92 + return pow((x + 0.055) / 1.055, 2.4) + + def linear_to_sRGB(self, y): + if y <= 0.0031308: + return 12.92 * y + return 1.055 * pow(y, 1 / 2.4) - 0.055 + + def _png_to_luma_only(self, cf: ColorFormat, filename: str): + reader = png.Reader(str(filename)) + w, h, rows, info = reader.asRGBA8() + rawdata = bytearray() + for row in rows: + R = row[0::4] + G = row[1::4] + B = row[2::4] + A = row[3::4] + for r, g, b, a in zip(R, G, B, A): + r, g, b, a = color_pre_multiply(r, g, b, a, self.background) + r = self.sRGB_to_linear(r / 255.0) + g = self.sRGB_to_linear(g / 255.0) + b = self.sRGB_to_linear(b / 255.0) + luma = 0.2126 * r + 0.7152 * g + 0.0722 * b + rawdata += uint8_t(int(self.linear_to_sRGB(luma) * 255)) + + self.set_data(ColorFormat.L8, w, h, rawdata) + + def _png_to_colormap(self, cf, filename: str): + + if cf == ColorFormat.ARGB8888: + + def pack(r, g, b, a): + return uint32_t((a << 24) | (r << 16) | (g << 8) | (b << 0)) + elif cf == ColorFormat.XRGB8888: + + def pack(r, g, b, a): + r, g, b, a = color_pre_multiply(r, g, b, a, self.background) + return uint32_t((0xff << 24) | (r << 16) | (g << 8) | (b << 0)) + elif cf == ColorFormat.RGB888: + + def pack(r, g, b, a): + r, g, b, a = color_pre_multiply(r, g, b, a, self.background) + return uint24_t((r << 16) | (g << 8) | (b << 0)) + elif cf == ColorFormat.RGB565: + + def pack(r, g, b, a): + r, g, b, a = color_pre_multiply(r, g, b, a, self.background) + color = (r >> 3) << 11 + color |= (g >> 2) << 5 + color |= (b >> 3) << 0 + return uint16_t(color) + + elif cf == ColorFormat.RGB565A8: + + def pack(r, g, b, a): + color = (r >> 3) << 11 + color |= (g >> 2) << 5 + color |= (b >> 3) << 0 + return uint16_t(color) + elif cf == ColorFormat.ARGB8565: + + def pack(r, g, b, a): + color = (r >> 3) << 11 + color |= (g >> 2) << 5 + color |= (b >> 3) << 0 + return uint24_t((a << 16) | color) + else: + raise FormatError(f"Invalid color format: {cf.name}") + + reader = png.Reader(str(filename)) + w, h, rows, _ = reader.asRGBA8() + rawdata = bytearray() + alpha = bytearray() + for y, row in enumerate(rows): + R = row[0::4] + G = row[1::4] + B = row[2::4] + A = row[3::4] + for x, (r, g, b, a) in enumerate(zip(R, G, B, A)): + if cf == ColorFormat.RGB565A8: + alpha += uint8_t(a) + + if ( + self.rgb565_dither and + cf in (ColorFormat.RGB565, ColorFormat.RGB565A8, ColorFormat.ARGB8565) + ): + treshold_id = ((y & 7) << 3) + (x & 7) + + r = min(r + red_thresh[treshold_id], 0xFF) & 0xF8 + g = min(g + green_thresh[treshold_id], 0xFF) & 0xFC + b = min(b + blue_thresh[treshold_id], 0xFF) & 0xF8 + + rawdata += pack(r, g, b, a) + + if cf == ColorFormat.RGB565A8: + rawdata += alpha + + self.set_data(cf, w, h, rawdata) + + +red_thresh = [ + 1, 7, 3, 5, 0, 8, 2, 6, + 7, 1, 5, 3, 8, 0, 6, 2, + 3, 5, 0, 8, 2, 6, 1, 7, + 5, 3, 8, 0, 6, 2, 7, 1, + 0, 8, 2, 6, 1, 7, 3, 5, + 8, 0, 6, 2, 7, 1, 5, 3, + 2, 6, 1, 7, 3, 5, 0, 8, + 6, 2, 7, 1, 5, 3, 8, 0 +] + +green_thresh = [ + 1, 3, 2, 2, 3, 1, 2, 2, + 2, 2, 0, 4, 2, 2, 4, 0, + 3, 1, 2, 2, 1, 3, 2, 2, + 2, 2, 4, 0, 2, 2, 0, 4, + 1, 3, 2, 2, 3, 1, 2, 2, + 2, 2, 0, 4, 2, 2, 4, 0, + 3, 1, 2, 2, 1, 3, 2, 2, + 2, 2, 4, 0, 2, 2, 0, 4 +] + +blue_thresh = [ + 5, 3, 8, 0, 6, 2, 7, 1, + 3, 5, 0, 8, 2, 6, 1, 7, + 8, 0, 6, 2, 7, 1, 5, 3, + 0, 8, 2, 6, 1, 7, 3, 5, + 6, 2, 7, 1, 5, 3, 8, 0, + 2, 6, 1, 7, 3, 5, 0, 8, + 7, 1, 5, 3, 8, 0, 6, 2, + 1, 7, 3, 5, 0, 8, 2, 6 +] + + +class RLEHeader: + + def __init__(self, blksize: int, len: int): + self.blksize = blksize + self.len = len + + @property + def binary(self): + magic = 0x5aa521e0 + + rle_header = self.blksize + rle_header |= (self.len & 0xffffff) << 4 + + binary = bytearray() + binary.extend(uint32_t(magic)) + binary.extend(uint32_t(rle_header)) + return binary + + +class RLEImage(LVGLImage): + + def __init__(self, + cf: ColorFormat = ColorFormat.UNKNOWN, + w: int = 0, + h: int = 0, + data: bytes = b'') -> None: + super().__init__(cf, w, h, data) + + def to_rle(self, filename: str): + """ + Compress this image to file, filename should be ended with '.rle' + """ + self._check_ext(filename, ".rle") + self._check_dir(filename) + + # compress image data excluding lvgl image header + blksize = (self.cf.bpp + 7) // 8 + compressed = self.rle_compress(self.data, blksize) + with open(filename, "wb+") as f: + header = RLEHeader(blksize, len(self.data)).binary + header.extend(self.header.binary) + f.write(header) + f.write(compressed) + + def rle_compress(self, data: bytearray, blksize: int, threshold=16): + index = 0 + data_len = len(data) + compressed_data = [] + memview = memoryview(data) + while index < data_len: + repeat_cnt = self.get_repeat_count(memview[index:], blksize) + if repeat_cnt == 0: + # done + break + elif repeat_cnt < threshold: + nonrepeat_cnt = self.get_nonrepeat_count( + memview[index:], blksize, threshold) + ctrl_byte = uint8_t(nonrepeat_cnt | 0x80) + compressed_data.append(ctrl_byte) + compressed_data.append(memview[index:index + + nonrepeat_cnt * blksize]) + index += nonrepeat_cnt * blksize + else: + ctrl_byte = uint8_t(repeat_cnt) + compressed_data.append(ctrl_byte) + compressed_data.append(memview[index:index + blksize]) + index += repeat_cnt * blksize + + return b"".join(compressed_data) + + def get_repeat_count(self, data: bytearray, blksize: int): + if len(data) < blksize: + return 0 + + start = data[:blksize] + index = 0 + repeat_cnt = 0 + value = 0 + + while index < len(data): + value = data[index:index + blksize] + + if value == start: + repeat_cnt += 1 + if repeat_cnt == 127: # limit max repeat count to max value of signed char. + break + else: + break + index += blksize + + return repeat_cnt + + def get_nonrepeat_count(self, data: bytearray, blksize: int, threshold): + if len(data) < blksize: + return 0 + + pre_value = data[:blksize] + + index = 0 + nonrepeat_count = 0 + + repeat_cnt = 0 + while True: + value = data[index:index + blksize] + if value == pre_value: + repeat_cnt += 1 + if repeat_cnt > threshold: + # repeat found. + break + else: + pre_value = value + nonrepeat_count += 1 + repeat_cnt + repeat_cnt = 0 + if nonrepeat_count >= 127: # limit max repeat count to max value of signed char. + nonrepeat_count = 127 + break + + index += blksize # move to next position + if index >= len(data): # data end + nonrepeat_count += repeat_cnt + break + + return nonrepeat_count + + +class RAWImage(): + ''' + RAW image is an exception to LVGL image, it has color format of RAW or RAW_ALPHA. + It has same image header as LVGL image, but the data is pure raw data from file. + It does not support stride adjustment etc. features for LVGL image. + It only supports convert an image to C array with RAW or RAW_ALPHA format. + ''' + CF_SUPPORTED = (ColorFormat.RAW, ColorFormat.RAW_ALPHA) + + class NotSupported(NotImplementedError): + pass + + def __init__(self, + cf: ColorFormat = ColorFormat.UNKNOWN, + data: bytes = b'') -> None: + self.cf = cf + self.data = data + + def to_c_array(self, + filename: str): + # Image size is set to zero, to let PNG or JPEG decoder to handle it + # Stride is meaningless for RAW image + write_c_array_file(0, 0, 0, self.cf, filename, + False, CompressMethod.NONE, self.data) + + def from_file(self, + filename: str, + cf: ColorFormat = None): + if cf not in RAWImage.CF_SUPPORTED: + raise RAWImage.NotSupported(f"Invalid color format: {cf.name}") + + with open(filename, "rb") as f: + self.data = f.read() + self.cf = cf + return self + + +class OutputFormat(Enum): + C_ARRAY = "C" + BIN_FILE = "BIN" + PNG_FILE = "PNG" # convert to lvgl image and then to png + + +class PNGConverter: + + def __init__(self, + files: List, + cf: ColorFormat, + ofmt: OutputFormat, + odir: str, + background: int = 0x00, + align: int = 1, + premultiply: bool = False, + compress: CompressMethod = CompressMethod.NONE, + keep_folder=True, + rgb565_dither=False) -> None: + self.files = files + self.cf = cf + self.ofmt = ofmt + self.output = odir + self.pngquant = None + self.keep_folder = keep_folder + self.align = align + self.premultiply = premultiply + self.compress = compress + self.background = background + self.rgb565_dither = rgb565_dither + + def _replace_ext(self, input, ext): + if self.keep_folder: + name, _ = path.splitext(input) + else: + name, _ = path.splitext(path.basename(input)) + output = name + ext + output = path.join(self.output, output) + return output + + def convert(self): + output = [] + for f in self.files: + if self.cf in (ColorFormat.RAW, ColorFormat.RAW_ALPHA): + # Process RAW image explicitly + img = RAWImage().from_file(f, self.cf) + img.to_c_array(self._replace_ext(f, ".c")) + else: + img = LVGLImage().from_png(f, self.cf, background=self.background, rgb565_dither=self.rgb565_dither) + img.adjust_stride(align=self.align) + + if self.premultiply: + img.premultiply() + output.append((f, img)) + if self.ofmt == OutputFormat.BIN_FILE: + img.to_bin(self._replace_ext(f, ".bin"), + compress=self.compress) + elif self.ofmt == OutputFormat.C_ARRAY: + img.to_c_array(self._replace_ext(f, ".c"), + compress=self.compress) + elif self.ofmt == OutputFormat.PNG_FILE: + img.to_png(self._replace_ext(f, ".png")) + + return output + + +def main(): + parser = argparse.ArgumentParser(description='LVGL PNG to bin image tool.') + parser.add_argument('--ofmt', + help="output filename format, C or BIN", + default="BIN", + choices=["C", "BIN", "PNG"]) + parser.add_argument( + '--cf', + help=("bin image color format, use AUTO for automatically " + "choose from I1/2/4/8"), + default="I8", + choices=[ + "L8", "I1", "I2", "I4", "I8", "A1", "A2", "A4", "A8", "ARGB8888", + "XRGB8888", "RGB565", "RGB565A8", "ARGB8565", "RGB888", "AUTO", + "RAW", "RAW_ALPHA" + ]) + + parser.add_argument('--rgb565dither', action='store_true', + help="use dithering to correct banding in gradients", default=False) + + parser.add_argument('--premultiply', action='store_true', + help="pre-multiply color with alpha", default=False) + + parser.add_argument('--compress', + help=("Binary data compress method, default to NONE"), + default="NONE", + choices=["NONE", "RLE", "LZ4"]) + + parser.add_argument('--align', + help="stride alignment in bytes for bin image", + default=1, + type=int, + metavar='byte', + nargs='?') + parser.add_argument('--background', + help="Background color for formats without alpha", + default=0x00_00_00, + type=lambda x: int(x, 0), + metavar='color', + nargs='?') + parser.add_argument('-o', + '--output', + default="./output", + help="Select the output folder, default to ./output") + parser.add_argument('-v', '--verbose', action='store_true') + parser.add_argument( + 'input', help="the filename or folder to be recursively converted") + + args = parser.parse_args() + + if path.isfile(args.input): + files = [args.input] + elif path.isdir(args.input): + files = list(Path(args.input).rglob("*.[pP][nN][gG]")) + else: + raise BaseException(f"invalid input: {args.input}") + + if args.verbose: + logging.basicConfig(level=logging.INFO) + + logging.info(f"options: {args.__dict__}, files:{[str(f) for f in files]}") + + if args.cf == "AUTO": + cf = None + else: + cf = ColorFormat[args.cf] + + ofmt = OutputFormat(args.ofmt) if cf not in ( + ColorFormat.RAW, ColorFormat.RAW_ALPHA) else OutputFormat.C_ARRAY + compress = CompressMethod[args.compress] + + converter = PNGConverter(files, + cf, + ofmt, + args.output, + background=args.background, + align=args.align, + premultiply=args.premultiply, + compress=compress, + keep_folder=False, + rgb565_dither=args.rgb565dither) + output = converter.convert() + for f, img in output: + logging.info(f"len: {img.data_len} for {path.basename(f)} ") + + print(f"done {len(files)} files") + + +def test(): + logging.basicConfig(level=logging.INFO) + f = "pngs/cogwheel.RGB565A8.png" + img = LVGLImage().from_png(f, + cf=ColorFormat.ARGB8565, + background=0xFF_FF_00, + rgb565_dither=True) + img.adjust_stride(align=16) + img.premultiply() + img.to_bin("output/cogwheel.ARGB8565.bin") + img.to_c_array("output/cogwheel-abc.c") # file name is used as c var name + img.to_png("output/cogwheel.ARGB8565.png.png") # convert back to png + + +def test_raw(): + logging.basicConfig(level=logging.INFO) + f = "pngs/cogwheel.RGB565A8.png" + img = RAWImage().from_file(f, + cf=ColorFormat.RAW_ALPHA) + img.to_c_array("output/cogwheel-raw.c") + + +if __name__ == "__main__": + # test() + # test_raw() + main() diff --git a/scripts/Image_Converter/README.md b/scripts/Image_Converter/README.md new file mode 100644 index 0000000..62a02cd --- /dev/null +++ b/scripts/Image_Converter/README.md @@ -0,0 +1,45 @@ +# LVGL图片转换工具 + +这个目录包含两个用于处理和转换图片为LVGL格式的Python脚本: + +## 1. LVGLImage (LVGLImage.py) + +引用自LVGL[官方repo](https://github.com/lvgl/lvgl)的转换脚本[LVGLImage.py](https://github.com/lvgl/lvgl/blob/master/scripts/LVGLImage.py) + +## 2. LVGL图片转换工具 (lvgl_tools_gui.py) + +调用`LVGLImage.py`,将图片批量转换为LVGL图片格式 +可用于修改小智的默认表情,具体修改教程[在这里](https://www.bilibili.com/video/BV12FQkYeEJ3/) + +### 特性 + +- 图形化操作,界面更友好 +- 支持批量转换图片 +- 自动识别图片格式并选择最佳的颜色格式转换 +- 多分辨率支持 + +### 使用方法 + +创建虚拟环境 +```bash +# 创建 venv +python -m venv venv +# 激活环境 +source venv/bin/activate # Linux/Mac +venv\Scripts\activate # Windows +``` + +安装依赖 +```bash +pip install -r requirements.txt +``` + +运行转换工具 + +```bash +# 激活环境 +source venv/bin/activate # Linux/Mac +venv\Scripts\activate # Windows +# 运行 +python lvgl_tools_gui.py +``` diff --git a/scripts/Image_Converter/lvgl_tools_gui.py b/scripts/Image_Converter/lvgl_tools_gui.py new file mode 100644 index 0000000..de7c1f2 --- /dev/null +++ b/scripts/Image_Converter/lvgl_tools_gui.py @@ -0,0 +1,253 @@ +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +from PIL import Image +import os +import tempfile +import sys +from LVGLImage import LVGLImage, ColorFormat, CompressMethod + +HELP_TEXT = """LVGL图片转换工具使用说明: + +1. 添加文件:点击“添加文件”按钮选择需要转换的图片,支持批量导入 + +2. 移除文件:在列表中选中文件前的复选框“[ ]”(选中后会变成“[√]”),点击“移除选中”可删除选定文件 + +3. 设置分辨率:选择需要的分辨率,如128x128 + 建议根据自己的设备的屏幕分辨率来选择。过大和过小都会影响显示效果。 + +4. 颜色格式:选择“自动识别”会根据图片是否透明自动选择,或手动指定 + 除非你了解这个选项,否则建议使用自动识别,不然可能会出现一些意想不到的问题…… + +5. 压缩方式:选择NONE或RLE压缩 + 除非你了解这个选项,否则建议保持默认NONE不压缩 + +6. 输出目录:设置转换后文件的保存路径 + 默认为程序所在目录下的output文件夹 + +7. 转换:点击“转换全部”或“转换选中”开始转换 +""" + +class ImageConverterApp: + def __init__(self, root): + self.root = root + self.root.title("LVGL图片转换工具") + self.root.geometry("750x650") + + # 初始化变量 + self.output_dir = tk.StringVar(value=os.path.abspath("output")) + self.resolution = tk.StringVar(value="128x128") + self.color_format = tk.StringVar(value="自动识别") + self.compress_method = tk.StringVar(value="NONE") + + # 创建UI组件 + self.create_widgets() + self.redirect_output() + + def create_widgets(self): + # 参数设置框架 + settings_frame = ttk.LabelFrame(self.root, text="转换设置") + settings_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew") + + # 分辨率设置 + ttk.Label(settings_frame, text="分辨率:").grid(row=0, column=0, padx=2) + ttk.Combobox(settings_frame, textvariable=self.resolution, + values=["512x512", "256x256", "128x128", "64x64", "32x32"], width=8).grid(row=0, column=1, padx=2) + + # 颜色格式 + ttk.Label(settings_frame, text="颜色格式:").grid(row=0, column=2, padx=2) + ttk.Combobox(settings_frame, textvariable=self.color_format, + values=["自动识别", "RGB565", "RGB565A8"], width=10).grid(row=0, column=3, padx=2) + + # 压缩方式 + ttk.Label(settings_frame, text="压缩方式:").grid(row=0, column=4, padx=2) + ttk.Combobox(settings_frame, textvariable=self.compress_method, + values=["NONE", "RLE"], width=8).grid(row=0, column=5, padx=2) + + # 文件操作框架 + file_frame = ttk.LabelFrame(self.root, text="选取文件") + file_frame.grid(row=1, column=0, padx=10, pady=5, sticky="nsew") + + # 文件操作按钮 + btn_frame = ttk.Frame(file_frame) + btn_frame.pack(fill=tk.X, pady=2) + ttk.Button(btn_frame, text="添加文件", command=self.select_files).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="移除选中", command=self.remove_selected).pack(side=tk.LEFT, padx=2) + ttk.Button(btn_frame, text="清空列表", command=self.clear_files).pack(side=tk.LEFT, padx=2) + + # 文件列表(Treeview) + self.tree = ttk.Treeview(file_frame, columns=("selected", "filename"), + show="headings", height=10) + self.tree.heading("selected", text="选择", anchor=tk.W) + self.tree.heading("filename", text="文件名", anchor=tk.W) + self.tree.column("selected", width=60, anchor=tk.W) + self.tree.column("filename", width=600, anchor=tk.W) + self.tree.pack(fill=tk.BOTH, expand=True) + self.tree.bind("", self.on_tree_click) + + # 输出目录 + output_frame = ttk.LabelFrame(self.root, text="输出目录") + output_frame.grid(row=2, column=0, padx=10, pady=5, sticky="ew") + ttk.Entry(output_frame, textvariable=self.output_dir, width=60).pack(side=tk.LEFT, padx=5) + ttk.Button(output_frame, text="浏览", command=self.select_output_dir).pack(side=tk.RIGHT, padx=5) + + # 转换按钮和帮助按钮 + convert_frame = ttk.Frame(self.root) + convert_frame.grid(row=3, column=0, padx=10, pady=10) + ttk.Button(convert_frame, text="转换全部文件", command=lambda: self.start_conversion(True)).pack(side=tk.LEFT, padx=5) + ttk.Button(convert_frame, text="转换选中文件", command=lambda: self.start_conversion(False)).pack(side=tk.LEFT, padx=5) + ttk.Button(convert_frame, text="帮助", command=self.show_help).pack(side=tk.RIGHT, padx=5) + + # 日志区域(新增清空按钮部分) + log_frame = ttk.LabelFrame(self.root, text="日志") + log_frame.grid(row=4, column=0, padx=10, pady=5, sticky="nsew") + + # 添加按钮框架 + log_btn_frame = ttk.Frame(log_frame) + log_btn_frame.pack(fill=tk.X, side=tk.BOTTOM) + + # 清空日志按钮 + ttk.Button(log_btn_frame, text="清空日志", command=self.clear_log).pack(side=tk.RIGHT, padx=5, pady=2) + + self.log_text = tk.Text(log_frame, height=15) + self.log_text.pack(fill=tk.BOTH, expand=True) + + # 布局配置 + self.root.columnconfigure(0, weight=1) + self.root.rowconfigure(1, weight=1) + self.root.rowconfigure(4, weight=1) + + def clear_log(self): + """清空日志内容""" + self.log_text.delete(1.0, tk.END) + + def show_help(self): + messagebox.showinfo("帮助", HELP_TEXT) + + def redirect_output(self): + class StdoutRedirector: + def __init__(self, text_widget): + self.text_widget = text_widget + self.original_stdout = sys.stdout + + def write(self, message): + self.text_widget.insert(tk.END, message) + self.text_widget.see(tk.END) + self.original_stdout.write(message) + + def flush(self): + self.original_stdout.flush() + + sys.stdout = StdoutRedirector(self.log_text) + + def on_tree_click(self, event): + region = self.tree.identify("region", event.x, event.y) + if region == "cell": + col = self.tree.identify_column(event.x) + item = self.tree.identify_row(event.y) + if col == "#1": # 点击的是选中列 + current_val = self.tree.item(item, "values")[0] + new_val = "[√]" if current_val == "[ ]" else "[ ]" + self.tree.item(item, values=(new_val, self.tree.item(item, "values")[1])) + + def select_output_dir(self): + path = filedialog.askdirectory() + if path: + self.output_dir.set(path) + + def select_files(self): + files = filedialog.askopenfilenames(filetypes=[("图片文件", "*.png;*.jpg;*.jpeg;*.bmp;*.gif")]) + for f in files: + self.tree.insert("", tk.END, values=("[ ]", os.path.basename(f)), tags=(f,)) + + def remove_selected(self): + to_remove = [] + for item in self.tree.get_children(): + if self.tree.item(item, "values")[0] == "[√]": + to_remove.append(item) + for item in reversed(to_remove): + self.tree.delete(item) + + def clear_files(self): + for item in self.tree.get_children(): + self.tree.delete(item) + + def start_conversion(self, convert_all): + input_files = [ + self.tree.item(item, "tags")[0] + for item in self.tree.get_children() + if convert_all or self.tree.item(item, "values")[0] == "[√]" + ] + + if not input_files: + msg = "没有找到可转换的文件" if convert_all else "没有选中任何文件" + messagebox.showwarning("警告", msg) + return + + os.makedirs(self.output_dir.get(), exist_ok=True) + + # 解析转换参数 + width, height = map(int, self.resolution.get().split('x')) + compress = CompressMethod.RLE if self.compress_method.get() == "RLE" else CompressMethod.NONE + + # 执行转换 + self.convert_images(input_files, width, height, compress) + + def convert_images(self, input_files, width, height, compress): + success_count = 0 + total_files = len(input_files) + + for idx, file_path in enumerate(input_files): + try: + print(f"正在处理: {os.path.basename(file_path)}") + + with Image.open(file_path) as img: + # 调整图片大小 + img = img.resize((width, height), Image.Resampling.LANCZOS) + + # 处理颜色格式 + color_format_str = self.color_format.get() + if color_format_str == "自动识别": + # 检测透明通道 + has_alpha = img.mode in ('RGBA', 'LA') or (img.mode == 'P' and 'transparency' in img.info) + if has_alpha: + img = img.convert('RGBA') + cf = ColorFormat.RGB565A8 + else: + img = img.convert('RGB') + cf = ColorFormat.RGB565 + else: + if color_format_str == "RGB565A8": + img = img.convert('RGBA') + cf = ColorFormat.RGB565A8 + else: + img = img.convert('RGB') + cf = ColorFormat.RGB565 + + # 保存调整后的图片 + base_name = os.path.splitext(os.path.basename(file_path))[0] + output_image_path = os.path.join(self.output_dir.get(), f"{base_name}_{width}x{height}.png") + img.save(output_image_path, 'PNG') + + # 创建临时文件 + with tempfile.NamedTemporaryFile(suffix=".png", delete=False) as tmpfile: + temp_path = tmpfile.name + img.save(temp_path, 'PNG') + + # 转换为LVGL C数组 + lvgl_img = LVGLImage().from_png(temp_path, cf=cf) + output_c_path = os.path.join(self.output_dir.get(), f"{base_name}.c") + lvgl_img.to_c_array(output_c_path, compress=compress) + + success_count += 1 + os.unlink(temp_path) + print(f"成功转换: {base_name}.c\n") + + except Exception as e: + print(f"转换失败: {str(e)}\n") + + print(f"转换完成! 成功 {success_count}/{total_files} 个文件\n") + +if __name__ == "__main__": + root = tk.Tk() + app = ImageConverterApp(root) + root.mainloop() diff --git a/scripts/Image_Converter/requirements.txt b/scripts/Image_Converter/requirements.txt new file mode 100644 index 0000000..5dbc310 --- /dev/null +++ b/scripts/Image_Converter/requirements.txt @@ -0,0 +1,3 @@ +lz4==4.4.4 +Pillow==11.3.0 +pypng==0.20220715.0 diff --git a/scripts/acoustic_check/demod.py b/scripts/acoustic_check/demod.py new file mode 100644 index 0000000..89c8196 --- /dev/null +++ b/scripts/acoustic_check/demod.py @@ -0,0 +1,280 @@ +""" +实时AFSK解调器 - 基于Goertzel算法 +""" + +import numpy as np +from collections import deque + + +class TraceGoertzel: + """实时Goertzel算法实现""" + + def __init__(self, freq: float, n: int): + """ + 初始化Goertzel算法 + + Args: + freq: 归一化频率 (目标频率/采样频率) + n: 窗口大小 + """ + self.freq = freq + self.n = n + + # 预计算系数 - 与参考代码一致 + self.k = int(freq * n) + self.w = 2.0 * np.pi * freq + self.cw = np.cos(self.w) + self.sw = np.sin(self.w) + self.c = 2.0 * self.cw + + # 初始化状态变量 - 使用deque存储最近两个值 + self.zs = deque([0.0, 0.0], maxlen=2) + + def reset(self): + """重置算法状态""" + self.zs.clear() + self.zs.extend([0.0, 0.0]) + + def __call__(self, xs): + """ + 处理一组采样点 - 与参考代码一致的接口 + + Args: + xs: 采样点序列 + + Returns: + 计算出的振幅 + """ + self.reset() + for x in xs: + z1, z2 = self.zs[-1], self.zs[-2] # Z[-1], Z[-2] + z0 = x + self.c * z1 - z2 # S[n] = x[n] + C * S[n-1] - S[n-2] + self.zs.append(float(z0)) # 更新序列 + return self.amp + + @property + def amp(self) -> float: + """计算当前振幅 - 与参考代码一致""" + z1, z2 = self.zs[-1], self.zs[-2] + ip = self.cw * z1 - z2 + qp = self.sw * z1 + return np.sqrt(ip**2 + qp**2) / (self.n / 2.0) + + +class PairGoertzel: + """双频Goertzel解调器""" + + def __init__(self, f_sample: int, f_space: int, f_mark: int, + bit_rate: int, win_size: int): + """ + 初始化双频解调器 + + Args: + f_sample: 采样频率 + f_space: Space频率 (通常对应0) + f_mark: Mark频率 (通常对应1) + bit_rate: 比特率 + win_size: Goertzel窗口大小 + """ + assert f_sample % bit_rate == 0, "采样频率必须是比特率的整数倍" + + self.Fs = f_sample + self.F0 = f_space + self.F1 = f_mark + self.bit_rate = bit_rate + self.n_per_bit = int(f_sample // bit_rate) # 每个比特的采样点数 + + # 计算归一化频率 + f1 = f_mark / f_sample + f0 = f_space / f_sample + + # 初始化Goertzel算法 + self.g0 = TraceGoertzel(freq=f0, n=win_size) + self.g1 = TraceGoertzel(freq=f1, n=win_size) + + # 输入缓冲区 + self.in_buffer = deque(maxlen=win_size) + self.out_count = 0 + + print(f"PairGoertzel initialized: f0={f0:.6f}, f1={f1:.6f}, win_size={win_size}, n_per_bit={self.n_per_bit}") + + def __call__(self, s: float): + """ + 处理单个采样点 - 与参考代码一致的接口 + + Args: + s: 采样点值 + + Returns: + (amp0, amp1, p1_prob) - 空间频率振幅,标记频率振幅,标记概率 + """ + self.in_buffer.append(s) + self.out_count += 1 + + amp0, amp1, p1_prob = 0, 0, None + + # 每个比特周期输出一次结果 + if self.out_count >= self.n_per_bit: + amp0 = self.g0(self.in_buffer) # 计算space频率振幅 + amp1 = self.g1(self.in_buffer) # 计算mark频率振幅 + p1_prob = amp1 / (amp0 + amp1 + 1e-8) # 计算mark概率 + self.out_count = 0 + + return amp0, amp1, p1_prob + + +class RealTimeAFSKDecoder: + """实时AFSK解码器 - 基于起始帧触发""" + + def __init__(self, f_sample: int = 16000, mark_freq: int = 1800, + space_freq: int = 1500, bitrate: int = 100, + s_goertzel: int = 9, threshold: float = 0.5): + """ + 初始化实时AFSK解码器 + + Args: + f_sample: 采样频率 + mark_freq: Mark频率 + space_freq: Space频率 + bitrate: 比特率 + s_goertzel: Goertzel窗口大小系数 (win_size = f_sample // mark_freq * s_goertzel) + threshold: 判决门限 + """ + self.f_sample = f_sample + self.mark_freq = mark_freq + self.space_freq = space_freq + self.bitrate = bitrate + self.threshold = threshold + + # 计算窗口大小 - 与参考代码一致 + win_size = int(f_sample / mark_freq * s_goertzel) + + # 初始化解调器 + self.demodulator = PairGoertzel(f_sample, space_freq, mark_freq, + bitrate, win_size) + + # 帧定义 - 与参考代码一致 + self.start_bytes = b'\x01\x02' + self.end_bytes = b'\x03\x04' + self.start_bits = "".join(format(int(x), '08b') for x in self.start_bytes) + self.end_bits = "".join(format(int(x), '08b') for x in self.end_bytes) + + # 状态机 + self.state = "idle" # idle / entering + + # 存储解调结果 + self.buffer_prelude:deque = deque(maxlen=len(self.start_bits)) # 判断是否启动 + self.indicators = [] # 存储概率序列 + self.signal_bits = "" # 存储比特序列 + self.text_cache = "" + + # 解码结果 + self.decoded_messages = [] + self.total_bits_received = 0 + + print(f"Decoder initialized: win_size={win_size}") + print(f"Start frame: {self.start_bits} (from {self.start_bytes.hex()})") + print(f"End frame: {self.end_bits} (from {self.end_bytes.hex()})") + + def process_audio(self, samples: np.array) -> str: + """ + 处理音频数据并返回解码文本 + + Args: + audio_data: 音频字节数据 (16-bit PCM) + + Returns: + 新解码的文本 + """ + new_text = "" + # 逐个处理采样点 + for sample in samples: + amp0, amp1, p1_prob = self.demodulator(sample) + # 如果有概率输出,记录并判决 + if p1_prob is not None: + bit = '1' if p1_prob > self.threshold else '0' + match self.state: + case "idle": + self.buffer_prelude.append(bit) + pass + case "entering": + self.buffer_prelude.append(bit) + self.signal_bits += bit + self.total_bits_received += 1 + case _: + pass + self.indicators.append(p1_prob) + + # 检查状态机 + if self.state == "idle" and "".join(self.buffer_prelude) == self.start_bits: + self.state = "entering" + self.text_cache = "" + self.signal_bits = "" # 清空比特序列 + self.buffer_prelude.clear() + elif self.state == "entering" and ("".join(self.buffer_prelude) == self.end_bits or len(self.signal_bits) >= 256): + self.state = "idle" + self.buffer_prelude.clear() + + # 每收集一定数量的比特后尝试解码 + if len(self.signal_bits) >= 8: + text = self._decode_bits_to_text(self.signal_bits) + if len(text) > len(self.text_cache): + new_text = text[len(self.text_cache) - len(text):] + self.text_cache = text + return new_text + + def _decode_bits_to_text(self, bits: str) -> str: + """ + 将比特串解码为文本 + + Args: + bits: 比特串 + + Returns: + 解码出的文本 + """ + if len(bits) < 8: + return "" + + decoded_text = "" + byte_count = len(bits) // 8 + + for i in range(byte_count): + # 提取8位 + byte_bits = bits[i*8:(i+1)*8] + + # 位转字节 + byte_val = int(byte_bits, 2) + + # 尝试解码为ASCII字符 + if 32 <= byte_val <= 126: # 可打印ASCII字符 + decoded_text += chr(byte_val) + elif byte_val == 0: # NULL字符,忽略 + continue + else: + # 非可打印字符pass,以十六进制显示 + pass + # decoded_text += f"\\x{byte_val:02X}" + + return decoded_text + + def clear(self): + """清空解码状态""" + self.indicators = [] + self.signal_bits = "" + self.decoded_messages = [] + self.total_bits_received = 0 + print("解码器状态已清空") + + def get_stats(self) -> dict: + """获取解码统计信息""" + return { + 'prelude_bits': "".join(self.buffer_prelude), + "state": self.state, + 'total_chars': sum(len(msg) for msg in self.text_cache), + 'buffer_bits': len(self.signal_bits), + 'mark_freq': self.mark_freq, + 'space_freq': self.space_freq, + 'bitrate': self.bitrate, + 'threshold': self.threshold, + } diff --git a/scripts/acoustic_check/graphic.py b/scripts/acoustic_check/graphic.py new file mode 100644 index 0000000..e60cf06 --- /dev/null +++ b/scripts/acoustic_check/graphic.py @@ -0,0 +1,444 @@ +import sys +import numpy as np +import asyncio +import wave +from collections import deque +import qasync + +import matplotlib +matplotlib.use('qtagg') + +from matplotlib.backends.backend_qtagg import FigureCanvasQTAgg as FigureCanvas +from matplotlib.backends.backend_qtagg import NavigationToolbar2QT as NavigationToolbar # noqa: F401 +from matplotlib.figure import Figure + +from PyQt6.QtWidgets import (QApplication, QMainWindow, QVBoxLayout, QWidget, + QHBoxLayout, QLineEdit, QPushButton, QLabel, QTextEdit) +from PyQt6.QtCore import QTimer + +# 导入解码器 +from demod import RealTimeAFSKDecoder + + +class UDPServerProtocol(asyncio.DatagramProtocol): + """UDP服务器协议类""" + def __init__(self, data_queue): + self.client_address = None + self.data_queue: deque = data_queue + + def connection_made(self, transport): + self.transport = transport + + def datagram_received(self, data, addr): + # 如果还没有客户端地址,记录第一个连接的客户端 + if self.client_address is None: + self.client_address = addr + print(f"接受来自 {addr} 的连接") + + # 只处理来自已记录客户端的数据 + if addr == self.client_address: + # 将接收到的音频数据添加到队列 + self.data_queue.extend(data) + else: + print(f"忽略来自未知地址 {addr} 的数据") + + +class MatplotlibWidget(QWidget): + def __init__(self, parent=None): + super().__init__(parent) + + # 创建 Matplotlib 的 Figure 对象 + self.figure = Figure() + + # 创建 FigureCanvas 对象,它是 Figure 的 QWidget 容器 + self.canvas = FigureCanvas(self.figure) + + # 创建 Matplotlib 的导航工具栏 + # self.toolbar = NavigationToolbar(self.canvas, self) + self.toolbar = None + + # 创建布局 + layout = QVBoxLayout() + layout.addWidget(self.toolbar) + layout.addWidget(self.canvas) + self.setLayout(layout) + + # 初始化音频数据参数 + self.freq = 16000 # 采样频率 + self.time_window = 20 # 显示时间窗口 + self.wave_data = deque(maxlen=self.freq * self.time_window * 2) # 缓冲队列, 用于分发计算/绘图 + self.signals = deque(maxlen=self.freq * self.time_window) # 双端队列存储信号数据 + + # 创建包含两个子图的画布 + self.ax1 = self.figure.add_subplot(2, 1, 1) + self.ax2 = self.figure.add_subplot(2, 1, 2) + + # 时域子图 + self.ax1.set_title('Real-time Audio Waveform') + self.ax1.set_xlabel('Sample Index') + self.ax1.set_ylabel('Amplitude') + self.line_time, = self.ax1.plot([], []) + self.ax1.grid(True, alpha=0.3) + + # 频域子图 + self.ax2.set_title('Real-time Frequency Spectrum') + self.ax2.set_xlabel('Frequency (Hz)') + self.ax2.set_ylabel('Magnitude') + self.line_freq, = self.ax2.plot([], []) + self.ax2.grid(True, alpha=0.3) + + self.figure.tight_layout() + + # 定时器用于更新图表 + self.timer = QTimer(self) + self.timer.setInterval(100) # 100毫秒更新一次 + self.timer.timeout.connect(self.update_plot) + + # 初始化AFSK解码器 + self.decoder = RealTimeAFSKDecoder( + f_sample=self.freq, + mark_freq=1800, + space_freq=1500, + bitrate=100, + s_goertzel=9, + threshold=0.5 + ) + + # 解码结果回调 + self.decode_callback = None + + def start_plotting(self): + """开始绘图""" + self.timer.start() + + def stop_plotting(self): + """停止绘图""" + self.timer.stop() + + def update_plot(self): + """更新绘图数据""" + if len(self.wave_data) >= 2: + # 进行实时解码 + # 获取最新的音频数据进行解码 + even = len(self.wave_data) // 2 * 2 + print(f"length of wave_data: {len(self.wave_data)}") + drained = [self.wave_data.popleft() for _ in range(even)] + signal = np.frombuffer(bytearray(drained), dtype=' 0: + # 只显示最近的一段数据,避免图表过于密集 + signal = np.array(self.signals) + max_samples = min(len(signal), self.freq * self.time_window) + if len(signal) > max_samples: + signal = signal[-max_samples:] + + # 更新时域图 + x = np.arange(len(signal)) + self.line_time.set_data(x, signal) + + # 自动调整时域坐标轴范围 + if len(signal) > 0: + self.ax1.set_xlim(0, len(signal)) + y_min, y_max = np.min(signal), np.max(signal) + if y_min != y_max: + margin = (y_max - y_min) * 0.1 + self.ax1.set_ylim(y_min - margin, y_max + margin) + else: + self.ax1.set_ylim(-1, 1) + + # 计算频谱(短时离散傅立叶变换) + if len(signal) > 1: + # 计算FFT + fft_signal = np.abs(np.fft.fft(signal)) + frequencies = np.fft.fftfreq(len(signal), 1/self.freq) + + # 只取正频率部分 + positive_freq_idx = frequencies >= 0 + freq_positive = frequencies[positive_freq_idx] + fft_positive = fft_signal[positive_freq_idx] + + # 更新频域图 + self.line_freq.set_data(freq_positive, fft_positive) + + # 自动调整频域坐标轴范围 + if len(fft_positive) > 0: + # 限制频率显示范围到0-4000Hz,避免过于密集 + max_freq_show = min(4000, self.freq // 2) + freq_mask = freq_positive <= max_freq_show + if np.any(freq_mask): + self.ax2.set_xlim(0, max_freq_show) + fft_masked = fft_positive[freq_mask] + if len(fft_masked) > 0: + fft_max = np.max(fft_masked) + if fft_max > 0: + self.ax2.set_ylim(0, fft_max * 1.1) + else: + self.ax2.set_ylim(0, 1) + + self.canvas.draw() + + +class MainWindow(QMainWindow): + def __init__(self): + super().__init__() + self.setWindowTitle("Acoustic Check") + self.setGeometry(100, 100, 1000, 800) + + # 主窗口部件 + main_widget = QWidget() + self.setCentralWidget(main_widget) + + # 主布局 + main_layout = QVBoxLayout(main_widget) + + # 绘图区域 + self.matplotlib_widget = MatplotlibWidget() + main_layout.addWidget(self.matplotlib_widget) + + # 控制面板 + control_panel = QWidget() + control_layout = QHBoxLayout(control_panel) + + # 监听地址和端口输入 + control_layout.addWidget(QLabel("监听地址:")) + self.address_input = QLineEdit("0.0.0.0") + self.address_input.setFixedWidth(120) + control_layout.addWidget(self.address_input) + + control_layout.addWidget(QLabel("端口:")) + self.port_input = QLineEdit("8000") + self.port_input.setFixedWidth(80) + control_layout.addWidget(self.port_input) + + # 监听按钮 + self.listen_button = QPushButton("开始监听") + self.listen_button.clicked.connect(self.toggle_listening) + control_layout.addWidget(self.listen_button) + + # 状态标签 + self.status_label = QLabel("状态: 未连接") + control_layout.addWidget(self.status_label) + + # 数据统计标签 + self.data_label = QLabel("接收数据: 0 bytes") + control_layout.addWidget(self.data_label) + + # 保存按钮 + self.save_button = QPushButton("保存音频") + self.save_button.clicked.connect(self.save_audio) + self.save_button.setEnabled(False) + control_layout.addWidget(self.save_button) + + control_layout.addStretch() # 添加弹性空间 + + main_layout.addWidget(control_panel) + + # 解码显示区域 + decode_panel = QWidget() + decode_layout = QVBoxLayout(decode_panel) + + # 解码标题 + decode_title = QLabel("实时AFSK解码结果:") + decode_title.setStyleSheet("font-weight: bold; font-size: 14px;") + decode_layout.addWidget(decode_title) + + # 解码文本显示 + self.decode_text = QTextEdit() + self.decode_text.setMaximumHeight(150) + self.decode_text.setReadOnly(True) + self.decode_text.setStyleSheet("font-family: 'Courier New', monospace; font-size: 12px;") + decode_layout.addWidget(self.decode_text) + + # 解码控制按钮 + decode_control_layout = QHBoxLayout() + + # 清空按钮 + self.clear_decode_button = QPushButton("清空解码") + self.clear_decode_button.clicked.connect(self.clear_decode_text) + decode_control_layout.addWidget(self.clear_decode_button) + + # 解码统计标签 + self.decode_stats_label = QLabel("解码统计: 0 bits, 0 chars") + decode_control_layout.addWidget(self.decode_stats_label) + + decode_control_layout.addStretch() + decode_layout.addLayout(decode_control_layout) + + main_layout.addWidget(decode_panel) + + # 设置解码回调 + self.matplotlib_widget.decode_callback = self.on_decode_text + + # UDP相关属性 + self.udp_transport = None + self.is_listening = False + + # 数据统计定时器 + self.stats_timer = QTimer(self) + self.stats_timer.setInterval(1000) # 每秒更新一次统计 + self.stats_timer.timeout.connect(self.update_stats) + + def on_decode_text(self, new_text: str): + """解码文本回调""" + if new_text: + # 添加新解码的文本 + current_text = self.decode_text.toPlainText() + updated_text = current_text + new_text + + # 限制文本长度,保留最新的1000个字符 + if len(updated_text) > 1000: + updated_text = updated_text[-1000:] + + self.decode_text.setPlainText(updated_text) + + # 滚动到底部 + cursor = self.decode_text.textCursor() + cursor.movePosition(cursor.MoveOperation.End) + self.decode_text.setTextCursor(cursor) + + def clear_decode_text(self): + """清空解码文本""" + self.decode_text.clear() + if hasattr(self.matplotlib_widget, 'decoder'): + self.matplotlib_widget.decoder.clear() + self.decode_stats_label.setText("解码统计: 0 bits, 0 chars") + + def update_decode_stats(self): + """更新解码统计""" + if hasattr(self.matplotlib_widget, 'decoder'): + stats = self.matplotlib_widget.decoder.get_stats() + stats_text = ( + f"前置: {stats['prelude_bits']} , 已接收{stats['total_chars']} chars, " + f"缓冲: {stats['buffer_bits']} bits, 状态: {stats['state']}" + ) + self.decode_stats_label.setText(stats_text) + + def toggle_listening(self): + """切换监听状态""" + if not self.is_listening: + self.start_listening() + else: + self.stop_listening() + + async def start_listening_async(self): + """异步启动UDP监听""" + try: + address = self.address_input.text().strip() + port = int(self.port_input.text().strip()) + + loop = asyncio.get_running_loop() + self.udp_transport, protocol = await loop.create_datagram_endpoint( + lambda: UDPServerProtocol(self.matplotlib_widget.wave_data), + local_addr=(address, port) + ) + + self.status_label.setText(f"状态: 监听中 ({address}:{port})") + print(f"UDP服务器启动, 监听 {address}:{port}") + + except Exception as e: + self.status_label.setText(f"状态: 启动失败 - {str(e)}") + print(f"UDP服务器启动失败: {e}") + self.is_listening = False + self.listen_button.setText("开始监听") + self.address_input.setEnabled(True) + self.port_input.setEnabled(True) + + def start_listening(self): + """开始监听""" + try: + int(self.port_input.text().strip()) # 验证端口号格式 + except ValueError: + self.status_label.setText("状态: 端口号必须是数字") + return + + self.is_listening = True + self.listen_button.setText("停止监听") + self.address_input.setEnabled(False) + self.port_input.setEnabled(False) + self.save_button.setEnabled(True) + + # 清空数据队列 + self.matplotlib_widget.wave_data.clear() + + # 启动绘图和统计更新 + self.matplotlib_widget.start_plotting() + self.stats_timer.start() + + # 异步启动UDP服务器 + loop = asyncio.get_event_loop() + loop.create_task(self.start_listening_async()) + + def stop_listening(self): + """停止监听""" + self.is_listening = False + self.listen_button.setText("开始监听") + self.address_input.setEnabled(True) + self.port_input.setEnabled(True) + + # 停止UDP服务器 + if self.udp_transport: + self.udp_transport.close() + self.udp_transport = None + + # 停止绘图和统计更新 + self.matplotlib_widget.stop_plotting() + self.matplotlib_widget.wave_data.clear() + self.stats_timer.stop() + + self.status_label.setText("状态: 已停止") + + def update_stats(self): + """更新数据统计""" + data_size = len(self.matplotlib_widget.signals) + self.data_label.setText(f"接收数据: {data_size} 采样") + + # 更新解码统计 + self.update_decode_stats() + + def save_audio(self): + """保存音频数据""" + if len(self.matplotlib_widget.signals) > 0: + try: + signal_data = np.array(self.matplotlib_widget.signals) + + # 保存为WAV文件 + with wave.open("received_audio.wav", "wb") as wf: + wf.setnchannels(1) # 单声道 + wf.setsampwidth(2) # 采样宽度为2字节 + wf.setframerate(self.matplotlib_widget.freq) # 设置采样率 + wf.writeframes(signal_data.tobytes()) # 写入数据 + + self.status_label.setText("状态: 音频已保存为 received_audio.wav") + print("音频已保存为 received_audio.wav") + + except Exception as e: + self.status_label.setText(f"状态: 保存失败 - {str(e)}") + print(f"保存音频失败: {e}") + else: + self.status_label.setText("状态: 没有足够的数据可保存") + + +async def main(): + """异步主函数""" + app = QApplication(sys.argv) + + # 设置异步事件循环 + loop = qasync.QEventLoop(app) + asyncio.set_event_loop(loop) + + window = MainWindow() + window.show() + + try: + with loop: + await loop.run_forever() + except KeyboardInterrupt: + print("程序被用户中断") + finally: + # 确保清理资源 + if window.udp_transport: + window.udp_transport.close() \ No newline at end of file diff --git a/scripts/acoustic_check/main.py b/scripts/acoustic_check/main.py new file mode 100644 index 0000000..63ec849 --- /dev/null +++ b/scripts/acoustic_check/main.py @@ -0,0 +1,18 @@ +#!/usr/bin/env python3 +""" +音频实时监听与绘图系统主程序 +基于Qt GUI + Matplotlib + UDP接收 + AFSK解码字符串 +""" + +import sys +import asyncio +from graphic import main + +if __name__ == '__main__': + try: + asyncio.run(main()) + except KeyboardInterrupt: + print("程序被用户中断") + except Exception as e: + print(f"程序执行出错: {e}") + sys.exit(1) diff --git a/scripts/acoustic_check/readme.md b/scripts/acoustic_check/readme.md new file mode 100644 index 0000000..9eb9c7c --- /dev/null +++ b/scripts/acoustic_check/readme.md @@ -0,0 +1,23 @@ +# 声波测试 +该gui用于测试接受小智设备通过`udp`回传的`pcm`转时域/频域, 可以保存窗口长度的声音, 用于判断噪音频率分布和测试声波传输ascii的准确度, + +固件测试需要打开`USE_AUDIO_DEBUGGER`, 并设置好`AUDIO_DEBUG_UDP_SERVER`是本机地址. +声波`demod`可以通过`sonic_wifi_config.html`或者上传至`PinMe`的[小智声波配网](https://iqf7jnhi.pinit.eth.limo)来输出声波测试 + +# 声波解码测试记录 + +> `✓`代表在I2S DIN接收原始PCM信号时就能成功解码, `△`代表需要降噪或额外操作可稳定解码, `X`代表降噪后效果也不好(可能能解部分但非常不稳定)。 +> 个别ADC需要I2C配置阶段做更精细的降噪调整, 由于设备不通用暂只按照boards内提供的config测试 + +| 设备 | ADC | MIC | 效果 | 备注 | +| ---- | ---- | --- | --- | ---- | +| bread-compact | INMP441 | 集成MEMEMIC | ✓ | +| atk-dnesp32s3-box | ES8311 | | ✓ | +| magiclick-2p5 | ES8311 | | ✓ | +| lichuang-dev | ES7210 | | △ | 测试时需要关掉INPUT_REFERENCE +| kevin-box-2 | ES7210 | | △ | 测试时需要关掉INPUT_REFERENCE +| m5stack-core-s3 | ES7210 | | △ | 测试时需要关掉INPUT_REFERENCE +| xmini-c3 | ES8311 | | △ | 需降噪 +| atoms3r-echo-base | ES8311 | | △ | 需降噪 +| atk-dnesp32s3-box0 | ES8311 | | X | 能接收且解码, 但是丢包率很高 +| movecall-moji-esp32s3 | ES8311 | | X | 能接收且解码, 但是丢包率很高 \ No newline at end of file diff --git a/scripts/acoustic_check/requirements.txt b/scripts/acoustic_check/requirements.txt new file mode 100644 index 0000000..91bc5ec --- /dev/null +++ b/scripts/acoustic_check/requirements.txt @@ -0,0 +1,4 @@ +matplotlib==3.10.5 +numpy==2.3.2 +PyQt6==6.9.1 +qasync==0.27.1 diff --git a/scripts/audio_debug_server.py b/scripts/audio_debug_server.py new file mode 100644 index 0000000..872c490 --- /dev/null +++ b/scripts/audio_debug_server.py @@ -0,0 +1,54 @@ +import socket +import wave +import argparse + + +''' + Create a UDP socket and bind it to the server's IP:8000. + Listen for incoming messages and print them to the console. + Save the audio to a WAV file. +''' +def main(samplerate, channels): + # Create a UDP socket + server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + server_socket.bind(('0.0.0.0', 8000)) + + # Create WAV file with parameters + filename = f"{samplerate}_{channels}.wav" + wav_file = wave.open(filename, "wb") + wav_file.setnchannels(channels) # channels parameter + wav_file.setsampwidth(2) # 2 bytes per sample (16-bit) + wav_file.setframerate(samplerate) # samplerate parameter + + print(f"Start saving audio from 0.0.0.0:8000 to {filename}...") + + try: + while True: + # Receive a message from the client + message, address = server_socket.recvfrom(8000) + + # Write PCM data to WAV file + wav_file.writeframes(message) + + # Print length of the message + print(f"Received {len(message)} bytes from {address}") + + except KeyboardInterrupt: + print("\nStopping recording...") + + finally: + # Close files and socket + wav_file.close() + server_socket.close() + print(f"WAV file '{filename}' saved successfully") + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='UDP音频数据接收器,保存为WAV文件') + parser.add_argument('--samplerate', '-s', type=int, default=16000, + help='采样率 (默认: 16000)') + parser.add_argument('--channels', '-c', type=int, default=2, + help='声道数 (默认: 2)') + + args = parser.parse_args() + main(args.samplerate, args.channels) diff --git a/scripts/build_default_assets.py b/scripts/build_default_assets.py new file mode 100644 index 0000000..9ca1464 --- /dev/null +++ b/scripts/build_default_assets.py @@ -0,0 +1,934 @@ +#!/usr/bin/env python3 +""" +Build default assets based on configuration + +This script reads configuration from sdkconfig and builds the appropriate assets.bin +for the current board configuration. + +Usage: + ./build_default_assets.py --sdkconfig --builtin_text_font \ + --default_emoji_collection --output +""" + +import argparse +import io +import os +import shutil +import sys +import json +import struct +from datetime import datetime + + +# ============================================================================= +# Pack model functions (from pack_model.py) +# ============================================================================= + +def struct_pack_string(string, max_len=None): + """ + pack string to binary data. + if max_len is None, max_len = len(string) + 1 + else len(string) < max_len, the left will be padded by struct.pack('x') + """ + if max_len == None : + max_len = len(string) + else: + assert len(string) <= max_len + + left_num = max_len - len(string) + out_bytes = None + for char in string: + if out_bytes == None: + out_bytes = struct.pack('b', ord(char)) + else: + out_bytes += struct.pack('b', ord(char)) + for i in range(left_num): + out_bytes += struct.pack('x') + return out_bytes + + +def read_data(filename): + """Read binary data, like index and mndata""" + data = None + with open(filename, "rb") as f: + data = f.read() + return data + + +def pack_models(model_path, out_file="srmodels.bin"): + """ + Pack all models into one binary file by the following format: + { + model_num: int + model1_info: model_info_t + model2_info: model_info_t + ... + model1_index,model1_data,model1_MODEL_INFO + model1_index,model1_data,model1_MODEL_INFO + ... + }model_pack_t + + { + model_name: char[32] + file_number: int + file1_name: char[32] + file1_start: int + file1_len: int + file2_name: char[32] + file2_start: int // data_len = info_start - data_start + file2_len: int + ... + }model_info_t + """ + models = {} + file_num = 0 + model_num = 0 + for root, dirs, _ in os.walk(model_path): + for model_name in dirs: + models[model_name] = {} + model_dir = os.path.join(root, model_name) + model_num += 1 + for _, _, files in os.walk(model_dir): + for file_name in files: + file_num += 1 + file_path = os.path.join(model_dir, file_name) + models[model_name][file_name] = read_data(file_path) + + model_num = len(models) + header_len = 4 + model_num*(32+4) + file_num*(32+4+4) + out_bin = struct.pack('I', model_num) # model number + data_bin = None + for key in models: + model_bin = struct_pack_string(key, 32) # + model name + model_bin += struct.pack('I', len(models[key])) # + file number in this model + + for file_name in models[key]: + model_bin += struct_pack_string(file_name, 32) # + file name + if data_bin == None: + model_bin += struct.pack('I', header_len) + data_bin = models[key][file_name] + model_bin += struct.pack('I', len(models[key][file_name])) + else: + model_bin += struct.pack('I', header_len+len(data_bin)) + data_bin += models[key][file_name] + model_bin += struct.pack('I', len(models[key][file_name])) + + out_bin += model_bin + assert len(out_bin) == header_len + if data_bin != None: + out_bin += data_bin + + out_file = os.path.join(model_path, out_file) + with open(out_file, "wb") as f: + f.write(out_bin) + + +# ============================================================================= +# Build assets functions (from build.py) +# ============================================================================= + +def ensure_dir(directory): + """Ensure directory exists, create if not""" + os.makedirs(directory, exist_ok=True) + + +def copy_file(src, dst): + """Copy file""" + if os.path.exists(src): + shutil.copy2(src, dst) + print(f"Copied: {src} -> {dst}") + return True + else: + print(f"Warning: Source file does not exist: {src}") + return False + + +def copy_directory(src, dst): + """Copy directory""" + if os.path.exists(src): + shutil.copytree(src, dst, dirs_exist_ok=True) + print(f"Copied directory: {src} -> {dst}") + return True + else: + print(f"Warning: Source directory does not exist: {src}") + return False + + +def process_sr_models(wakenet_model_dirs, multinet_model_dirs, build_dir, assets_dir): + """Process SR models (wakenet and multinet) and generate srmodels.bin""" + if not wakenet_model_dirs and not multinet_model_dirs: + return None + + # Create SR models build directory + sr_models_build_dir = os.path.join(build_dir, "srmodels") + if os.path.exists(sr_models_build_dir): + shutil.rmtree(sr_models_build_dir) + os.makedirs(sr_models_build_dir) + + models_processed = 0 + + # Copy wakenet models if available + if wakenet_model_dirs: + for wakenet_model_dir in wakenet_model_dirs: + wakenet_name = os.path.basename(wakenet_model_dir) + wakenet_dst = os.path.join(sr_models_build_dir, wakenet_name) + if copy_directory(wakenet_model_dir, wakenet_dst): + models_processed += 1 + print(f"Added wakenet model: {wakenet_name}") + + # Copy multinet models if available + if multinet_model_dirs: + for multinet_model_dir in multinet_model_dirs: + multinet_name = os.path.basename(multinet_model_dir) + multinet_dst = os.path.join(sr_models_build_dir, multinet_name) + if copy_directory(multinet_model_dir, multinet_dst): + models_processed += 1 + print(f"Added multinet model: {multinet_name}") + + if models_processed == 0: + print("Warning: No SR models were successfully processed") + return None + + # Use pack_models function to generate srmodels.bin + srmodels_output = os.path.join(sr_models_build_dir, "srmodels.bin") + try: + pack_models(sr_models_build_dir, "srmodels.bin") + print(f"Generated: {srmodels_output}") + # Copy srmodels.bin to assets directory + copy_file(srmodels_output, os.path.join(assets_dir, "srmodels.bin")) + return "srmodels.bin" + except Exception as e: + print(f"Error: Failed to generate srmodels.bin: {e}") + return None + + +def process_text_font(text_font_file, assets_dir): + """Process text_font parameter""" + if not text_font_file: + return None + + # Copy input file to build/assets directory + font_filename = os.path.basename(text_font_file) + font_dst = os.path.join(assets_dir, font_filename) + if copy_file(text_font_file, font_dst): + return font_filename + return None + + +def process_emoji_collection(emoji_collection_dir, assets_dir): + """Process emoji_collection parameter""" + if not emoji_collection_dir: + return [] + + emoji_list = [] + + # Check if this is otto-gif collection + is_otto_gif = 'otto-emoji-gif-component' in emoji_collection_dir or emoji_collection_dir.endswith('otto-gif') + + # Otto GIF emoji aliases mapping + otto_gif_aliases = { + "staticstate": ["neutral", "relaxed", "sleepy", "idle"], + "happy": ["laughing", "funny", "loving", "confident", "winking", "cool", "delicious", "kissy", "silly"], + "sad": ["crying"], + "anger": ["angry"], + "scare": ["surprised", "shocked"], + "buxue": ["thinking", "confused", "embarrassed"] + } + + # Copy each image from input directory to build/assets directory + for root, dirs, files in os.walk(emoji_collection_dir): + for file in files: + if file.lower().endswith(('.png', '.gif')): + # Copy file + src_file = os.path.join(root, file) + dst_file = os.path.join(assets_dir, file) + if copy_file(src_file, dst_file): + # Get filename without extension + filename_without_ext = os.path.splitext(file)[0] + + # Add main emoji entry + emoji_list.append({ + "name": filename_without_ext, + "file": file + }) + + # Add aliases for otto-gif emojis + if is_otto_gif and filename_without_ext in otto_gif_aliases: + for alias in otto_gif_aliases[filename_without_ext]: + emoji_list.append({ + "name": alias, + "file": file + }) + + return emoji_list + + +def process_extra_files(extra_files_dir, assets_dir): + """Process default_assets_extra_files parameter""" + if not extra_files_dir: + return [] + + if not os.path.exists(extra_files_dir): + print(f"Warning: Extra files directory not found: {extra_files_dir}") + return [] + + extra_files_list = [] + + # Copy each file from input directory to build/assets directory + for root, dirs, files in os.walk(extra_files_dir): + for file in files: + # Skip hidden files and directories + if file.startswith('.'): + continue + + # Copy file + src_file = os.path.join(root, file) + dst_file = os.path.join(assets_dir, file) + if copy_file(src_file, dst_file): + extra_files_list.append(file) + + if extra_files_list: + print(f"Processed {len(extra_files_list)} extra files from: {extra_files_dir}") + + return extra_files_list + + +def generate_index_json(assets_dir, srmodels, text_font, emoji_collection, extra_files=None, multinet_model_info=None): + """Generate index.json file""" + index_data = { + "version": 1 + } + + if srmodels: + index_data["srmodels"] = srmodels + + if text_font: + index_data["text_font"] = text_font + + if emoji_collection: + index_data["emoji_collection"] = emoji_collection + + if extra_files: + index_data["extra_files"] = extra_files + + if multinet_model_info: + index_data["multinet_model"] = multinet_model_info + + # Write index.json + index_path = os.path.join(assets_dir, "index.json") + with open(index_path, 'w', encoding='utf-8') as f: + json.dump(index_data, f, indent=4, ensure_ascii=False) + + print(f"Generated: {index_path}") + + +def generate_config_json(build_dir, assets_dir): + """Generate config.json file""" + config_data = { + "include_path": os.path.join(build_dir, "include"), + "assets_path": assets_dir, + "image_file": os.path.join(build_dir, "output", "assets.bin"), + "lvgl_ver": "9.3.0", + "assets_size": "0x400000", + "support_format": ".png, .gif, .jpg, .bin, .json", + "name_length": "32", + "split_height": "0", + "support_qoi": False, + "support_spng": False, + "support_sjpg": False, + "support_sqoi": False, + "support_raw": False, + "support_raw_dither": False, + "support_raw_bgr": False + } + + # Write config.json + config_path = os.path.join(build_dir, "config.json") + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(config_data, f, indent=4, ensure_ascii=False) + + print(f"Generated: {config_path}") + return config_path + + +# ============================================================================= +# Simplified SPIFFS assets generation (from spiffs_assets_gen.py) +# ============================================================================= + +def compute_checksum(data): + checksum = sum(data) & 0xFFFF + return checksum + + +def sort_key(filename): + basename, extension = os.path.splitext(filename) + return extension, basename + + +def pack_assets_simple(target_path, include_path, out_file, assets_path, max_name_len=32): + """ + Simplified version of pack_assets that handles basic file packing + """ + merged_data = bytearray() + file_info_list = [] + skip_files = ['config.json'] + + # Ensure output directory exists + os.makedirs(os.path.dirname(out_file), exist_ok=True) + os.makedirs(include_path, exist_ok=True) + + file_list = sorted(os.listdir(target_path), key=sort_key) + for filename in file_list: + if filename in skip_files: + continue + + file_path = os.path.join(target_path, filename) + if not os.path.isfile(file_path): + continue + + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + file_info_list.append((file_name, len(merged_data), file_size, 0, 0)) + # Add 0x5A5A prefix to merged_data + merged_data.extend(b'\x5A' * 2) + + with open(file_path, 'rb') as bin_file: + bin_data = bin_file.read() + + merged_data.extend(bin_data) + + total_files = len(file_info_list) + + mmap_table = bytearray() + for file_name, offset, file_size, width, height in file_info_list: + if len(file_name) > max_name_len: + print(f'Warning: "{file_name}" exceeds {max_name_len} bytes and will be truncated.') + fixed_name = file_name.ljust(max_name_len, '\0')[:max_name_len] + mmap_table.extend(fixed_name.encode('utf-8')) + mmap_table.extend(file_size.to_bytes(4, byteorder='little')) + mmap_table.extend(offset.to_bytes(4, byteorder='little')) + mmap_table.extend(width.to_bytes(2, byteorder='little')) + mmap_table.extend(height.to_bytes(2, byteorder='little')) + + combined_data = mmap_table + merged_data + combined_checksum = compute_checksum(combined_data) + combined_data_length = len(combined_data).to_bytes(4, byteorder='little') + header_data = total_files.to_bytes(4, byteorder='little') + combined_checksum.to_bytes(4, byteorder='little') + final_data = header_data + combined_data_length + combined_data + + with open(out_file, 'wb') as output_bin: + output_bin.write(final_data) + + # Generate header file + current_year = datetime.now().year + asset_name = os.path.basename(assets_path) + header_file_path = os.path.join(include_path, f'mmap_generate_{asset_name}.h') + with open(header_file_path, 'w') as output_header: + output_header.write('/*\n') + output_header.write(' * SPDX-FileCopyrightText: 2022-{} Espressif Systems (Shanghai) CO LTD\n'.format(current_year)) + output_header.write(' *\n') + output_header.write(' * SPDX-License-Identifier: Apache-2.0\n') + output_header.write(' */\n\n') + output_header.write('/**\n') + output_header.write(' * @file\n') + output_header.write(" * @brief This file was generated by esp_mmap_assets, don't modify it\n") + output_header.write(' */\n\n') + output_header.write('#pragma once\n\n') + output_header.write("#include \"esp_mmap_assets.h\"\n\n") + output_header.write(f'#define MMAP_{asset_name.upper()}_FILES {total_files}\n') + output_header.write(f'#define MMAP_{asset_name.upper()}_CHECKSUM 0x{combined_checksum:04X}\n\n') + output_header.write(f'enum MMAP_{asset_name.upper()}_LISTS {{\n') + + for i, (file_name, _, _, _, _) in enumerate(file_info_list): + enum_name = file_name.replace('.', '_') + output_header.write(f' MMAP_{asset_name.upper()}_{enum_name.upper()} = {i}, /*!< {file_name} */\n') + + output_header.write('};\n') + + print(f'All files have been merged into {os.path.basename(out_file)}') + + +# ============================================================================= +# Configuration and main functions +# ============================================================================= + +def read_wakenet_from_sdkconfig(sdkconfig_path): + """ + Read wakenet models from sdkconfig (based on movemodel.py logic) + Returns a list of wakenet model names + """ + if not os.path.exists(sdkconfig_path): + print(f"Warning: sdkconfig file not found: {sdkconfig_path}") + return [] + + models = [] + with io.open(sdkconfig_path, "r") as f: + for label in f: + label = label.strip("\n") + if 'CONFIG_SR_WN' in label and '#' not in label[0]: + if '_NONE' in label: + continue + if '=' in label: + label = label.split("=")[0] + if '_MULTI' in label: + label = label[:-6] + model_name = label.split("_SR_WN_")[-1].lower() + models.append(model_name) + + return models + + +def read_multinet_from_sdkconfig(sdkconfig_path): + """ + Read multinet models from sdkconfig (based on movemodel.py logic) + Returns a list of multinet model names + """ + if not os.path.exists(sdkconfig_path): + print(f"Warning: sdkconfig file not found: {sdkconfig_path}") + return [] + + with io.open(sdkconfig_path, "r") as f: + models_string = '' + for label in f: + label = label.strip("\n") + if 'CONFIG_SR_MN' in label and label[0] != '#': + models_string += label + + models = [] + if "CONFIG_SR_MN_CN_MULTINET3_SINGLE_RECOGNITION" in models_string: + models.append('mn3_cn') + elif "CONFIG_SR_MN_CN_MULTINET4_5_SINGLE_RECOGNITION_QUANT8" in models_string: + models.append('mn4q8_cn') + elif "CONFIG_SR_MN_CN_MULTINET4_5_SINGLE_RECOGNITION" in models_string: + models.append('mn4_cn') + elif "CONFIG_SR_MN_CN_MULTINET5_RECOGNITION_QUANT8" in models_string: + models.append('mn5q8_cn') + elif "CONFIG_SR_MN_CN_MULTINET6_QUANT" in models_string: + models.append('mn6_cn') + elif "CONFIG_SR_MN_CN_MULTINET6_AC_QUANT" in models_string: + models.append('mn6_cn_ac') + elif "CONFIG_SR_MN_CN_MULTINET7_QUANT" in models_string: + models.append('mn7_cn') + elif "CONFIG_SR_MN_CN_MULTINET7_AC_QUANT" in models_string: + models.append('mn7_cn_ac') + + if "CONFIG_SR_MN_EN_MULTINET5_SINGLE_RECOGNITION_QUANT8" in models_string: + models.append('mn5q8_en') + elif "CONFIG_SR_MN_EN_MULTINET5_SINGLE_RECOGNITION" in models_string: + models.append('mn5_en') + elif "CONFIG_SR_MN_EN_MULTINET6_QUANT" in models_string: + models.append('mn6_en') + elif "CONFIG_SR_MN_EN_MULTINET7_QUANT" in models_string: + models.append('mn7_en') + + if "MULTINET6" in models_string or "MULTINET7" in models_string: + models.append('fst') + + return models + + +def read_wake_word_type_from_sdkconfig(sdkconfig_path): + """ + Read wake word type configuration from sdkconfig + Returns a dict with wake word type info + """ + if not os.path.exists(sdkconfig_path): + print(f"Warning: sdkconfig file not found: {sdkconfig_path}") + return { + 'use_esp_wake_word': False, + 'use_afe_wake_word': False, + 'use_custom_wake_word': False, + 'wake_word_disabled': True + } + + config_values = { + 'use_esp_wake_word': False, + 'use_afe_wake_word': False, + 'use_custom_wake_word': False, + 'wake_word_disabled': False + } + + with io.open(sdkconfig_path, "r") as f: + for line in f: + line = line.strip("\n") + if line.startswith('#'): + continue + + # Check for wake word type configuration + if 'CONFIG_USE_ESP_WAKE_WORD=y' in line: + config_values['use_esp_wake_word'] = True + elif 'CONFIG_USE_AFE_WAKE_WORD=y' in line: + config_values['use_afe_wake_word'] = True + elif 'CONFIG_USE_CUSTOM_WAKE_WORD=y' in line: + config_values['use_custom_wake_word'] = True + elif 'CONFIG_WAKE_WORD_DISABLED=y' in line: + config_values['wake_word_disabled'] = True + + return config_values + + +def read_custom_wake_word_from_sdkconfig(sdkconfig_path): + """ + Read custom wake word configuration from sdkconfig + Returns a dict with custom wake word info or None if not configured + """ + if not os.path.exists(sdkconfig_path): + print(f"Warning: sdkconfig file not found: {sdkconfig_path}") + return None + + config_values = {} + with io.open(sdkconfig_path, "r") as f: + for line in f: + line = line.strip("\n") + if line.startswith('#') or '=' not in line: + continue + + # Check for custom wake word configuration + if 'CONFIG_USE_CUSTOM_WAKE_WORD=y' in line: + config_values['use_custom_wake_word'] = True + elif 'CONFIG_CUSTOM_WAKE_WORD=' in line and not line.startswith('#'): + # Extract string value (remove quotes) + value = line.split('=', 1)[1].strip('"') + config_values['wake_word'] = value + elif 'CONFIG_CUSTOM_WAKE_WORD_DISPLAY=' in line and not line.startswith('#'): + # Extract string value (remove quotes) + value = line.split('=', 1)[1].strip('"') + config_values['display'] = value + elif 'CONFIG_CUSTOM_WAKE_WORD_THRESHOLD=' in line and not line.startswith('#'): + # Extract numeric value + value = line.split('=', 1)[1] + try: + config_values['threshold'] = int(value) + except ValueError: + try: + config_values['threshold'] = float(value) + except ValueError: + print(f"Warning: Invalid threshold value: {value}") + config_values['threshold'] = 20 # default (will be converted to 0.2) + + # Return config only if custom wake word is enabled and required fields are present + if (config_values.get('use_custom_wake_word', False) and + 'wake_word' in config_values and + 'display' in config_values and + 'threshold' in config_values): + return { + 'wake_word': config_values['wake_word'], + 'display': config_values['display'], + 'threshold': config_values['threshold'] / 100.0 # Convert to decimal (20 -> 0.2) + } + + return None + + +def get_language_from_multinet_models(multinet_models): + """ + Determine language from multinet model names + Returns 'cn', 'en', or None + """ + if not multinet_models: + return None + + # Check for Chinese models + cn_indicators = ['_cn', 'cn_'] + en_indicators = ['_en', 'en_'] + + has_cn = any(any(indicator in model for indicator in cn_indicators) for model in multinet_models) + has_en = any(any(indicator in model for indicator in en_indicators) for model in multinet_models) + + # If both or neither, default to cn + if has_cn and not has_en: + return 'cn' + elif has_en and not has_cn: + return 'en' + else: + return 'cn' # Default to Chinese + + +def get_wakenet_model_paths(model_names, esp_sr_model_path): + """ + Get the full paths to the wakenet model directories + Returns a list of valid model paths + """ + if not model_names: + return [] + + valid_paths = [] + for model_name in model_names: + wakenet_model_path = os.path.join(esp_sr_model_path, 'wakenet_model', model_name) + if os.path.exists(wakenet_model_path): + valid_paths.append(wakenet_model_path) + else: + print(f"Warning: Wakenet model directory not found: {wakenet_model_path}") + + return valid_paths + + +def get_multinet_model_paths(model_names, esp_sr_model_path): + """ + Get the full paths to the multinet model directories + Returns a list of valid model paths + """ + if not model_names: + return [] + + valid_paths = [] + for model_name in model_names: + multinet_model_path = os.path.join(esp_sr_model_path, 'multinet_model', model_name) + if os.path.exists(multinet_model_path): + valid_paths.append(multinet_model_path) + else: + print(f"Warning: Multinet model directory not found: {multinet_model_path}") + + return valid_paths + + +def get_text_font_path(builtin_text_font, xiaozhi_fonts_path): + """ + Get the text font path if needed + Returns the font file path or None if no font is needed + """ + if not builtin_text_font or 'basic' not in builtin_text_font: + return None + + # Convert from basic to common font name + # e.g., font_puhui_basic_16_4 -> font_puhui_common_16_4.bin + if builtin_text_font.startswith('font_noto_'): + font_name = builtin_text_font.replace('basic', 'qwen') + '.bin' + else: + font_name = builtin_text_font.replace('basic', 'common') + '.bin' + font_path = os.path.join(xiaozhi_fonts_path, 'cbin', font_name) + + if os.path.exists(font_path): + return font_path + else: + print(f"Warning: Font file not found: {font_path}") + return None + + +def get_emoji_collection_path(default_emoji_collection, xiaozhi_fonts_path, project_root=None): + """ + Get the emoji collection path if needed + Returns the emoji directory path or None if no emoji collection is needed + + Supports: + - PNG emoji collections from xiaozhi-fonts (e.g., emojis_32, twemoji_64) + - GIF emoji collections from xiaozhi-fonts (e.g., noto-emoji_128, noto-emoji_64) + - Otto GIF emoji collection (otto-gif) + """ + if not default_emoji_collection: + return None + + # Special handling for otto-gif collection + if default_emoji_collection == 'otto-gif': + if project_root: + otto_gif_path = os.path.join(project_root, 'managed_components', + 'txp666__otto-emoji-gif-component', 'gifs') + if os.path.exists(otto_gif_path): + return otto_gif_path + else: + print(f"Warning: Otto GIF emoji collection directory not found: {otto_gif_path}") + return None + else: + print("Warning: project_root not provided, cannot locate otto-gif collection") + return None + + # Try PNG emoji collections first (e.g., emojis_32, twemoji_64) + emoji_path = os.path.join(xiaozhi_fonts_path, 'png', default_emoji_collection) + if os.path.exists(emoji_path): + return emoji_path + + # Try GIF emoji collections (e.g., noto-emoji_128, noto-emoji_64, noto-emoji_32) + emoji_path = os.path.join(xiaozhi_fonts_path, 'gif', default_emoji_collection) + if os.path.exists(emoji_path): + return emoji_path + + print(f"Warning: Emoji collection directory not found in png/ or gif/: {default_emoji_collection}") + return None + + +def build_assets_integrated(wakenet_model_paths, multinet_model_paths, text_font_path, emoji_collection_path, extra_files_path, output_path, multinet_model_info=None): + """ + Build assets using integrated functions (no external dependencies) + """ + # Create temporary build directory + temp_build_dir = os.path.join(os.path.dirname(output_path), "temp_build") + assets_dir = os.path.join(temp_build_dir, "assets") + + try: + # Clean and create directories + if os.path.exists(temp_build_dir): + shutil.rmtree(temp_build_dir) + ensure_dir(temp_build_dir) + ensure_dir(assets_dir) + + print("Starting to build assets...") + + # Process each component + srmodels = process_sr_models(wakenet_model_paths, multinet_model_paths, temp_build_dir, assets_dir) if (wakenet_model_paths or multinet_model_paths) else None + text_font = process_text_font(text_font_path, assets_dir) if text_font_path else None + emoji_collection = process_emoji_collection(emoji_collection_path, assets_dir) if emoji_collection_path else None + extra_files = process_extra_files(extra_files_path, assets_dir) if extra_files_path else None + + # Generate index.json + generate_index_json(assets_dir, srmodels, text_font, emoji_collection, extra_files, multinet_model_info) + + # Generate config.json for packing + config_path = generate_config_json(temp_build_dir, assets_dir) + + # Load config and pack assets + with open(config_path, 'r') as f: + config_data = json.load(f) + + # Use simplified packing function + include_path = config_data['include_path'] + image_file = config_data['image_file'] + pack_assets_simple(assets_dir, include_path, image_file, "assets", int(config_data['name_length'])) + + # Copy final assets.bin to output location + if os.path.exists(image_file): + shutil.copy2(image_file, output_path) + print(f"Successfully generated assets.bin: {output_path}") + + # Show size information + total_size = os.path.getsize(output_path) + print(f"Assets file size: {total_size / 1024:.2f}K ({total_size} bytes)") + + return True + else: + print(f"Error: Generated assets.bin not found: {image_file}") + return False + + except Exception as e: + print(f"Error: Failed to build assets: {e}") + return False + finally: + # Clean up temporary directory + if os.path.exists(temp_build_dir): + shutil.rmtree(temp_build_dir) + + +def main(): + parser = argparse.ArgumentParser(description='Build default assets based on configuration') + parser.add_argument('--sdkconfig', required=True, help='Path to sdkconfig file') + parser.add_argument('--builtin_text_font', help='Builtin text font name (e.g., font_puhui_basic_16_4)') + parser.add_argument('--emoji_collection', help='Default emoji collection name (e.g., emojis_32)') + parser.add_argument('--output', required=True, help='Output path for assets.bin') + parser.add_argument('--esp_sr_model_path', help='Path to ESP-SR model directory') + parser.add_argument('--xiaozhi_fonts_path', help='Path to xiaozhi-fonts component directory') + parser.add_argument('--extra_files', help='Path to extra files directory to be included in assets') + + args = parser.parse_args() + + # Set default paths if not provided + if not args.esp_sr_model_path or not args.xiaozhi_fonts_path: + # Calculate project root from script location + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(script_dir) + + if not args.esp_sr_model_path: + args.esp_sr_model_path = os.path.join(project_root, "managed_components", "espressif__esp-sr", "model") + + if not args.xiaozhi_fonts_path: + args.xiaozhi_fonts_path = os.path.join(project_root, "components", "xiaozhi-fonts") + + print("Building default assets...") + print(f" sdkconfig: {args.sdkconfig}") + print(f" builtin_text_font: {args.builtin_text_font}") + print(f" emoji_collection: {args.emoji_collection}") + print(f" output: {args.output}") + + # Read wake word type configuration from sdkconfig + wake_word_config = read_wake_word_type_from_sdkconfig(args.sdkconfig) + + # Read SR models from sdkconfig + wakenet_model_names = read_wakenet_from_sdkconfig(args.sdkconfig) + multinet_model_names = read_multinet_from_sdkconfig(args.sdkconfig) + + # Apply wake word logic to decide which models to package + wakenet_model_paths = [] + multinet_model_paths = [] + + # 1. Only package wakenet models if USE_ESP_WAKE_WORD=y or USE_AFE_WAKE_WORD=y + if wake_word_config['use_esp_wake_word'] or wake_word_config['use_afe_wake_word']: + wakenet_model_paths = get_wakenet_model_paths(wakenet_model_names, args.esp_sr_model_path) + elif wakenet_model_names: + print(f" Note: Found wakenet models {wakenet_model_names} but wake word type is not ESP/AFE, skipping") + + # 2. Error check: if USE_CUSTOM_WAKE_WORD=y but no multinet models selected, report error + if wake_word_config['use_custom_wake_word'] and not multinet_model_names: + print("Error: USE_CUSTOM_WAKE_WORD is enabled but no multinet models are selected in sdkconfig") + 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']: + 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 model information (only for models that will actually be packaged) + if wakenet_model_paths: + print(f" wakenet models: {', '.join(wakenet_model_names)} (will be packaged)") + if multinet_model_paths: + print(f" multinet models: {', '.join(multinet_model_names)} (will be packaged)") + + # Get text font path if needed + text_font_path = get_text_font_path(args.builtin_text_font, args.xiaozhi_fonts_path) + + # Get emoji collection path if needed + # Calculate project root from script location for otto-gif support + script_dir = os.path.dirname(os.path.abspath(__file__)) + project_root = os.path.dirname(script_dir) + emoji_collection_path = get_emoji_collection_path(args.emoji_collection, args.xiaozhi_fonts_path, project_root) + + # Get extra files path if provided + extra_files_path = args.extra_files + + # Read custom wake word configuration + custom_wake_word_config = read_custom_wake_word_from_sdkconfig(args.sdkconfig) + multinet_model_info = None + + if custom_wake_word_config and multinet_model_paths: + # Determine language from multinet models + language = get_language_from_multinet_models(multinet_model_names) + + # Build multinet_model info structure + multinet_model_info = { + "language": language, + "duration": 3000, # Default duration in ms + "threshold": custom_wake_word_config['threshold'], + "commands": [ + { + "command": custom_wake_word_config['wake_word'], + "text": custom_wake_word_config['display'], + "action": "wake" + } + ] + } + print(f" custom wake word: {custom_wake_word_config['wake_word']} ({custom_wake_word_config['display']})") + print(f" wake word language: {language}") + print(f" wake word threshold: {custom_wake_word_config['threshold']}") + + # Check if we have anything to build + if not wakenet_model_paths and not multinet_model_paths and not text_font_path and not emoji_collection_path and not extra_files_path and not multinet_model_info: + print("Warning: No assets to build (no SR models, text font, emoji collection, extra files, or custom wake word)") + # Create an empty assets.bin file + os.makedirs(os.path.dirname(args.output), exist_ok=True) + with open(args.output, 'wb') as f: + pass # Create empty file + print(f"Created empty assets.bin: {args.output}") + return + + # Build the assets + success = build_assets_integrated(wakenet_model_paths, multinet_model_paths, text_font_path, emoji_collection_path, + extra_files_path, args.output, multinet_model_info) + + if not success: + sys.exit(1) + + print("Build completed successfully!") + + +if __name__ == "__main__": + main() diff --git a/scripts/download_github_runs.py b/scripts/download_github_runs.py new file mode 100644 index 0000000..d8bf0ee --- /dev/null +++ b/scripts/download_github_runs.py @@ -0,0 +1,294 @@ +#!/usr/bin/env python3 +""" +Download GitHub Actions artifacts and rename them with version numbers. + +Usage: + python download_github_runs.py 2.0.4 https://github.com/78/xiaozhi-esp32/actions/runs/18866246016 + +Output: + Files are downloaded to releases// directory relative to the project root. + Example: releases/2.0.4/v2.0.4_atk-dnesp32s3-box0.zip +""" + +import argparse +import os +import re +import sys +import zipfile +from pathlib import Path +from urllib.parse import urlparse + +import requests +from dotenv import load_dotenv + + +def parse_github_run_url(url: str) -> tuple[str, str, str]: + """ + Parse GitHub Actions run URL to extract owner, repo, and run_id. + + Args: + url: GitHub Actions run URL + + Returns: + Tuple of (owner, repo, run_id) + """ + # Example: https://github.com/78/xiaozhi-esp32/actions/runs/18866246016 + pattern = r'github\.com/([^/]+)/([^/]+)/actions/runs/(\d+)' + match = re.search(pattern, url) + + if not match: + raise ValueError(f"Invalid GitHub Actions URL: {url}") + + owner, repo, run_id = match.groups() + return owner, repo, run_id + + +def get_artifacts(owner: str, repo: str, run_id: str, token: str) -> list[dict]: + """ + Get all artifacts for a specific workflow run (with pagination support). + + Args: + owner: Repository owner + repo: Repository name + run_id: Workflow run ID + token: GitHub personal access token + + Returns: + List of artifact dictionaries + """ + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" + } + + all_artifacts = [] + page = 1 + per_page = 100 # Maximum allowed by GitHub API + + while True: + url = f"https://api.github.com/repos/{owner}/{repo}/actions/runs/{run_id}/artifacts" + params = { + "page": page, + "per_page": per_page + } + + response = requests.get(url, headers=headers, params=params) + response.raise_for_status() + + data = response.json() + artifacts = data.get("artifacts", []) + + if not artifacts: + break + + all_artifacts.extend(artifacts) + + # Check if there are more pages + total_count = data.get("total_count", 0) + if len(all_artifacts) >= total_count: + break + + page += 1 + + return all_artifacts + + +def download_artifact(artifact_url: str, token: str, output_path: Path) -> None: + """ + Download an artifact from GitHub. + + Args: + artifact_url: Artifact download URL + token: GitHub personal access token + output_path: Path to save the downloaded artifact + """ + headers = { + "Authorization": f"Bearer {token}", + "Accept": "application/vnd.github+json", + "X-GitHub-Api-Version": "2022-11-28" + } + + response = requests.get(artifact_url, headers=headers, stream=True) + response.raise_for_status() + + # Create parent directory if it doesn't exist + output_path.parent.mkdir(parents=True, exist_ok=True) + + # Download the file + with open(output_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=8192): + if chunk: + f.write(chunk) + + +def rename_artifact(original_name: str, version: str) -> str: + """ + Rename artifact according to the specified rules. + + Rules: + - Remove "xiaozhi_" prefix + - Remove hash suffix (underscore followed by hex string) + - Add version prefix (e.g., "v2.0.4_") + - Add .zip extension + + Examples: + xiaozhi_atk-dnesp32s3-box0_43ef2f4e7f0957dc62ec7d628ac2819d226127b8 + -> v2.0.4_atk-dnesp32s3-box0.zip + + xiaozhi_waveshare-esp32-p4-nano-10.1-a_43ef2f4e7f0957dc62ec7d628ac2819d226127b8 + -> v2.0.4_waveshare-esp32-p4-nano-10.1-a.zip + + Args: + original_name: Original artifact name + version: Version string (e.g., "2.0.4") + + Returns: + New filename + """ + # Remove "xiaozhi_" prefix + name = original_name + if name.startswith("xiaozhi_"): + name = name[len("xiaozhi_"):] + + # Remove known extensions only (not using splitext to avoid issues with + # names containing dots like "esp32-s3-touch-amoled-2.06") + known_extensions = ('.bin', '.zip') + for ext in known_extensions: + if name.endswith(ext): + name = name[:-len(ext)] + break + + # Remove hash suffix (pattern: underscore followed by 40+ hex characters) + # This matches Git commit hashes and similar identifiers + name_without_hash = re.sub(r'_[a-f0-9]{40,}$', '', name) + + # Add version prefix and .zip extension + new_name = f"v{version}_{name_without_hash}.zip" + + return new_name + + +def get_default_releases_dir() -> Path: + """ + Get the default releases directory path relative to this script's location. + + Returns: + Path to the releases directory (script_dir/../releases) + """ + script_dir = Path(__file__).resolve().parent + return script_dir.parent / "releases" + + +def main(): + """Main function to download and rename GitHub Actions artifacts.""" + parser = argparse.ArgumentParser( + description="Download GitHub Actions artifacts and rename them with version numbers." + ) + parser.add_argument( + "version", + help="Version number (e.g., 2.0.4)" + ) + parser.add_argument( + "url", + help="GitHub Actions run URL (e.g., https://github.com/owner/repo/actions/runs/12345)" + ) + parser.add_argument( + "--output-dir", + default=None, + help="Output directory for downloaded artifacts (default: releases/ relative to project root)" + ) + + args = parser.parse_args() + + # Load GitHub token from .env file + load_dotenv() + github_token = os.getenv("GITHUB_TOKEN") + + if not github_token: + print("Error: GITHUB_TOKEN not found in environment variables.", file=sys.stderr) + print("Please create a .env file with GITHUB_TOKEN=your_token_here", file=sys.stderr) + sys.exit(1) + + try: + # Parse the GitHub URL + owner, repo, run_id = parse_github_run_url(args.url) + print(f"Repository: {owner}/{repo}") + print(f"Run ID: {run_id}") + print(f"Version: {args.version}") + print() + + # Get artifacts + print("Fetching artifacts...") + artifacts = get_artifacts(owner, repo, run_id, github_token) + + if not artifacts: + print("No artifacts found for this run.") + return + + print(f"Found {len(artifacts)} artifact(s):") + for artifact in artifacts: + print(f" - {artifact['name']}") + print() + + # Determine output directory + if args.output_dir: + # User specified custom output directory + output_dir = Path(args.output_dir) / args.version + else: + # Default: releases/ relative to script location + output_dir = get_default_releases_dir() / args.version + + # Create output directory + output_dir.mkdir(parents=True, exist_ok=True) + + # Download and rename each artifact + downloaded_count = 0 + skipped_count = 0 + + for artifact in artifacts: + original_name = artifact['name'] + new_name = rename_artifact(original_name, args.version) + final_path = output_dir / new_name + + # Check if file already exists + if final_path.exists(): + print(f"Skipping (already exists): {original_name}") + print(f" -> {new_name}") + print(f" File: {final_path}") + print() + skipped_count += 1 + continue + + print(f"Downloading: {original_name}") + print(f" -> {new_name}") + + # Download to temporary path first + temp_path = output_dir / f"{original_name}.zip" + download_artifact( + artifact['archive_download_url'], + github_token, + temp_path + ) + + # Rename to final name + temp_path.rename(final_path) + + print(f" Saved to: {final_path}") + print() + downloaded_count += 1 + + print(f"Summary:") + print(f" Downloaded: {downloaded_count} artifact(s)") + print(f" Skipped: {skipped_count} artifact(s)") + print(f" Total: {len(artifacts)} artifact(s)") + print(f" Output directory: {output_dir.absolute()}") + + except Exception as e: + print(f"Error: {e}", file=sys.stderr) + sys.exit(1) + + +if __name__ == "__main__": + main() + diff --git a/scripts/gen_lang.py b/scripts/gen_lang.py new file mode 100644 index 0000000..81c0326 --- /dev/null +++ b/scripts/gen_lang.py @@ -0,0 +1,187 @@ +#!/usr/bin/env python3 +import argparse +import json +import os + +HEADER_TEMPLATE = """// Auto-generated language config +// Language: {lang_code} with en-US fallback +#pragma once + +#include + +#ifndef {lang_code_for_font} + #define {lang_code_for_font} // 預設語言 +#endif + +namespace Lang {{ + // 语言元数据 + constexpr const char* CODE = "{lang_code}"; + + // 字符串资源 (en-US as fallback for missing keys) + namespace Strings {{ +{strings} + }} + + // 音效资源 (en-US as fallback for missing audio files) + namespace Sounds {{ +{sounds} + }} +}} +""" + +def load_base_language(assets_dir): + """加载 en-US 基准语言数据""" + base_lang_path = os.path.join(assets_dir, 'locales', 'en-US', 'language.json') + if os.path.exists(base_lang_path): + try: + with open(base_lang_path, 'r', encoding='utf-8') as f: + base_data = json.load(f) + print(f"Loaded base language en-US with {len(base_data.get('strings', {}))} strings") + return base_data + except json.JSONDecodeError as e: + print(f"Warning: Failed to parse en-US language file: {e}") + else: + print("Warning: en-US base language file not found, fallback mechanism disabled") + return {'strings': {}} + +def get_sound_files(directory): + """获取目录中的音效文件列表""" + if not os.path.exists(directory): + return [] + return [f for f in os.listdir(directory) if f.endswith('.ogg')] + +def generate_header(lang_code, output_path): + # 从输出路径推导项目结构 + # output_path 通常是 main/assets/lang_config.h + main_dir = os.path.dirname(output_path) # main/assets + if os.path.basename(main_dir) == 'assets': + main_dir = os.path.dirname(main_dir) # main + project_dir = os.path.dirname(main_dir) # 项目根目录 + assets_dir = os.path.join(main_dir, 'assets') + + # 构建语言JSON文件路径 + input_path = os.path.join(assets_dir, 'locales', lang_code, 'language.json') + + print(f"Processing language: {lang_code}") + print(f"Input file path: {input_path}") + print(f"Output file path: {output_path}") + + if not os.path.exists(input_path): + raise FileNotFoundError(f"Language file not found: {input_path}") + + with open(input_path, 'r', encoding='utf-8') as f: + data = json.load(f) + + # 验证数据结构 + if 'language' not in data or 'strings' not in data: + raise ValueError("Invalid JSON structure") + + # 加载 en-US 基准语言数据 + base_data = load_base_language(assets_dir) + + # 合并字符串:以 en-US 为基准,用户语言覆盖 + base_strings = base_data.get('strings', {}) + user_strings = data['strings'] + merged_strings = base_strings.copy() + merged_strings.update(user_strings) + + # 统计信息 + base_count = len(base_strings) + user_count = len(user_strings) + total_count = len(merged_strings) + fallback_count = total_count - user_count + + print(f"Language {lang_code} string statistics:") + print(f" - Base language (en-US): {base_count} strings") + print(f" - User language: {user_count} strings") + print(f" - Total: {total_count} strings") + if fallback_count > 0: + print(f" - Fallback to en-US: {fallback_count} strings") + + # 生成字符串常量 + strings = [] + sounds = [] + for key, value in merged_strings.items(): + value = value.replace('"', '\\"') + strings.append(f' constexpr const char* {key.upper()} = "{value}";') + + # 收集音效文件:以 en-US 为基准,用户语言覆盖 + current_lang_dir = os.path.join(assets_dir, 'locales', lang_code) + base_lang_dir = os.path.join(assets_dir, 'locales', 'en-US') + common_dir = os.path.join(assets_dir, 'common') + + # 获取所有可能的音效文件 + base_sounds = get_sound_files(base_lang_dir) + current_sounds = get_sound_files(current_lang_dir) + common_sounds = get_sound_files(common_dir) + + # 合并音效文件列表:用户语言覆盖基准语言 + all_sound_files = set(base_sounds) + all_sound_files.update(current_sounds) + + # 音效统计信息 + base_sound_count = len(base_sounds) + user_sound_count = len(current_sounds) + common_sound_count = len(common_sounds) + sound_fallback_count = len(set(base_sounds) - set(current_sounds)) + + print(f"Language {lang_code} sound statistics:") + print(f" - Base language (en-US): {base_sound_count} sounds") + print(f" - User language: {user_sound_count} sounds") + print(f" - Common sounds: {common_sound_count} sounds") + if sound_fallback_count > 0: + print(f" - Sound fallback to en-US: {sound_fallback_count} sounds") + + # 生成语言特定音效常量 + for file in sorted(all_sound_files): + base_name = os.path.splitext(file)[0] + # 优先使用当前语言的音效,如果不存在则回退到 en-US + if file in current_sounds: + sound_lang = lang_code.replace('-', '_').lower() + else: + sound_lang = 'en_us' + + sounds.append(f''' + extern const char ogg_{base_name}_start[] asm("_binary_{base_name}_ogg_start"); + extern const char ogg_{base_name}_end[] asm("_binary_{base_name}_ogg_end"); + static const std::string_view OGG_{base_name.upper()} {{ + static_cast(ogg_{base_name}_start), + static_cast(ogg_{base_name}_end - ogg_{base_name}_start) + }};''') + + # 生成公共音效常量 + for file in sorted(common_sounds): + base_name = os.path.splitext(file)[0] + sounds.append(f''' + extern const char ogg_{base_name}_start[] asm("_binary_{base_name}_ogg_start"); + extern const char ogg_{base_name}_end[] asm("_binary_{base_name}_ogg_end"); + static const std::string_view OGG_{base_name.upper()} {{ + static_cast(ogg_{base_name}_start), + static_cast(ogg_{base_name}_end - ogg_{base_name}_start) + }};''') + + # 填充模板 + content = HEADER_TEMPLATE.format( + lang_code=lang_code, + lang_code_for_font=lang_code.replace('-', '_').lower(), + strings="\n".join(sorted(strings)), + sounds="\n".join(sorted(sounds)) + ) + + # 写入文件 + os.makedirs(os.path.dirname(output_path), exist_ok=True) + with open(output_path, 'w', encoding='utf-8') as f: + f.write(content) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description="Generate language configuration header file with en-US fallback") + parser.add_argument("--language", required=True, help="Language code (e.g: zh-CN, en-US, ja-JP)") + parser.add_argument("--output", required=True, help="Output header file path") + args = parser.parse_args() + + try: + generate_header(args.language, args.output) + print(f"Successfully generated language config file: {args.output}") + except Exception as e: + print(f"Error: {e}") + exit(1) \ No newline at end of file diff --git a/scripts/mp3_to_ogg.sh b/scripts/mp3_to_ogg.sh new file mode 100644 index 0000000..2cb870a --- /dev/null +++ b/scripts/mp3_to_ogg.sh @@ -0,0 +1,3 @@ +#!/bin/sh +# mp3_to_ogg.sh +ffmpeg -i $1 -c:a libopus -b:a 16k -ac 1 -ar 16000 -frame_duration 60 $2 diff --git a/scripts/ogg_converter/README.md b/scripts/ogg_converter/README.md new file mode 100644 index 0000000..eecbe42 --- /dev/null +++ b/scripts/ogg_converter/README.md @@ -0,0 +1,36 @@ +# ogg_covertor 小智AI OGG 音效批量转换器 + +本脚本为OGG批量转换工具,支持将输入的音频文件转换为小智可使用的OGG格式 + +基于Python第三方库 `ffmpeg-python` 实现,**需要** `ffmpeg` 环境 + +可前往[此处](https://ffmpeg.org/download.html)下载对应你自己系统的ffmpeg发行版,并添加到环境变量或者放在脚本所在目录 + +支持OGG和音频之间的互转,响度调节等功能 + +# 创建并激活虚拟环境 + +```bash +# 创建虚拟环境 +python -m venv venv +# 激活虚拟环境 +source venv/bin/activate # Mac/Linux +venv\Scripts\activate # Windows +``` +# 下载FFmpeg +前往[此处](https://ffmpeg.org/download.html)下载ffmpeg + +根据你当前的系统下载对应的版本,并将`ffmpeg`的可执行文件放置在脚本所在目录或者添加可执行文件所在目录到环境变量 + +# 安装依赖 +请在虚拟环境中执行 + +```bash +pip install ffmpeg-python +``` + +# 运行脚本 +```bash +python ogg_covertor.py +``` + diff --git a/scripts/ogg_converter/xiaozhi_ogg_converter.py b/scripts/ogg_converter/xiaozhi_ogg_converter.py new file mode 100644 index 0000000..5c3ddb2 --- /dev/null +++ b/scripts/ogg_converter/xiaozhi_ogg_converter.py @@ -0,0 +1,230 @@ +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import os +import threading +import sys +import ffmpeg + +class AudioConverterApp: + def __init__(self, master): + self.master = master + master.title("小智AI OGG音频批量转换工具") + master.geometry("680x600") # 调整窗口高度 + + # 初始化变量 + self.mode = tk.StringVar(value="audio_to_ogg") + self.output_dir = tk.StringVar() + self.output_dir.set(os.path.abspath("output")) + self.enable_loudnorm = tk.BooleanVar(value=True) + self.target_lufs = tk.DoubleVar(value=-16.0) + + # 创建UI组件 + self.create_widgets() + self.redirect_output() + + def create_widgets(self): + # 模式选择 + mode_frame = ttk.LabelFrame(self.master, text="转换模式") + mode_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew") + + ttk.Radiobutton(mode_frame, text="音频转到OGG", variable=self.mode, + value="audio_to_ogg", command=self.toggle_settings, + width=12).grid(row=0, column=0, padx=5) + ttk.Radiobutton(mode_frame, text="OGG转回音频", variable=self.mode, + value="ogg_to_audio", command=self.toggle_settings, + width=12).grid(row=0, column=1, padx=5) + + # 响度设置 + self.loudnorm_frame = ttk.Frame(self.master) + self.loudnorm_frame.grid(row=1, column=0, padx=10, pady=5, sticky="ew") + + ttk.Checkbutton(self.loudnorm_frame, text="启用响度调整", + variable=self.enable_loudnorm, width=15 + ).grid(row=0, column=0, padx=2) + ttk.Entry(self.loudnorm_frame, textvariable=self.target_lufs, + width=6).grid(row=0, column=1, padx=2) + ttk.Label(self.loudnorm_frame, text="LUFS").grid(row=0, column=2, padx=2) + + # 文件选择 + file_frame = ttk.LabelFrame(self.master, text="输入文件") + file_frame.grid(row=2, column=0, padx=10, pady=5, sticky="nsew") + + # 文件操作按钮 + ttk.Button(file_frame, text="选择文件", command=self.select_files, + width=12).grid(row=0, column=0, padx=5, pady=2) + ttk.Button(file_frame, text="移除选中", command=self.remove_selected, + width=12).grid(row=0, column=1, padx=5, pady=2) + ttk.Button(file_frame, text="清空列表", command=self.clear_files, + width=12).grid(row=0, column=2, padx=5, pady=2) + + # 文件列表(使用Treeview) + self.tree = ttk.Treeview(file_frame, columns=("selected", "filename"), + show="headings", height=8) + self.tree.heading("selected", text="选中", anchor=tk.W) + self.tree.heading("filename", text="文件名", anchor=tk.W) + self.tree.column("selected", width=60, anchor=tk.W) + self.tree.column("filename", width=600, anchor=tk.W) + self.tree.grid(row=1, column=0, columnspan=3, sticky="nsew", padx=5, pady=2) + self.tree.bind("", self.on_tree_click) + + # 输出目录 + output_frame = ttk.LabelFrame(self.master, text="输出目录") + output_frame.grid(row=3, column=0, padx=10, pady=5, sticky="ew") + + ttk.Entry(output_frame, textvariable=self.output_dir, width=60 + ).grid(row=0, column=0, padx=5, sticky="ew") + ttk.Button(output_frame, text="浏览", command=self.select_output_dir, + width=8).grid(row=0, column=1, padx=5) + + # 转换按钮区域 + button_frame = ttk.Frame(self.master) + button_frame.grid(row=4, column=0, padx=10, pady=10, sticky="ew") + + ttk.Button(button_frame, text="转换全部文件", command=lambda: self.start_conversion(True), + width=15).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="转换选中文件", command=lambda: self.start_conversion(False), + width=15).pack(side=tk.LEFT, padx=5) + + # 日志区域 + log_frame = ttk.LabelFrame(self.master, text="日志") + log_frame.grid(row=5, column=0, padx=10, pady=5, sticky="nsew") + + self.log_text = tk.Text(log_frame, height=14, width=80) + self.log_text.pack(fill=tk.BOTH, expand=True) + + # 配置布局权重 + self.master.columnconfigure(0, weight=1) + self.master.rowconfigure(2, weight=1) + self.master.rowconfigure(5, weight=3) + file_frame.columnconfigure(0, weight=1) + file_frame.rowconfigure(1, weight=1) + + def toggle_settings(self): + if self.mode.get() == "audio_to_ogg": + self.loudnorm_frame.grid() + else: + self.loudnorm_frame.grid_remove() + + def select_files(self): + file_types = [ + ("音频文件", "*.wav *.mogg *.ogg *.flac") if self.mode.get() == "audio_to_ogg" + else ("ogg文件", "*.ogg") + ] + + files = filedialog.askopenfilenames(filetypes=file_types) + for f in files: + self.tree.insert("", tk.END, values=("[ ]", os.path.basename(f)), tags=(f,)) + + def on_tree_click(self, event): + """处理复选框点击事件""" + region = self.tree.identify("region", event.x, event.y) + if region == "cell": + col = self.tree.identify_column(event.x) + item = self.tree.identify_row(event.y) + if col == "#1": # 点击的是选中列 + current_val = self.tree.item(item, "values")[0] + new_val = "[√]" if current_val == "[ ]" else "[ ]" + self.tree.item(item, values=(new_val, self.tree.item(item, "values")[1])) + + def remove_selected(self): + """移除选中的文件""" + to_remove = [] + for item in self.tree.get_children(): + if self.tree.item(item, "values")[0] == "[√]": + to_remove.append(item) + for item in reversed(to_remove): + self.tree.delete(item) + + def clear_files(self): + """清空所有文件""" + for item in self.tree.get_children(): + self.tree.delete(item) + + def select_output_dir(self): + path = filedialog.askdirectory() + if path: + self.output_dir.set(path) + + def redirect_output(self): + class StdoutRedirector: + def __init__(self, text_widget): + self.text_widget = text_widget + self.original_stdout = sys.stdout + + def write(self, message): + self.text_widget.insert(tk.END, message) + self.text_widget.see(tk.END) + self.original_stdout.write(message) + + def flush(self): + self.original_stdout.flush() + + sys.stdout = StdoutRedirector(self.log_text) + + def start_conversion(self, convert_all): + """开始转换""" + input_files = [] + for item in self.tree.get_children(): + if convert_all or self.tree.item(item, "values")[0] == "[√]": + input_files.append(self.tree.item(item, "tags")[0]) + + if not input_files: + msg = "没有找到可转换的文件" if convert_all else "没有选中任何文件" + messagebox.showwarning("警告", msg) + return + + os.makedirs(self.output_dir.get(), exist_ok=True) + + try: + if self.mode.get() == "audio_to_ogg": + target_lufs = self.target_lufs.get() if self.enable_loudnorm.get() else None + thread = threading.Thread(target=self.convert_audio_to_ogg, args=(target_lufs, input_files)) + else: + thread = threading.Thread(target=self.convert_ogg_to_audio, args=(input_files,)) + + thread.start() + except Exception as e: + print(f"转换初始化失败: {str(e)}") + + def convert_audio_to_ogg(self, target_lufs, input_files): + """音频转到ogg转换逻辑""" + for input_path in input_files: + try: + filename = os.path.basename(input_path) + base_name = os.path.splitext(filename)[0] + output_path = os.path.join(self.output_dir.get(), f"{base_name}.ogg") + + print(f"正在转换: {filename}") + ( + ffmpeg + .input(input_path) + .output(output_path, acodec='libopus', audio_bitrate='16k', ac=1, ar=16000, frame_duration=60) + .run(overwrite_output=True) + ) + print(f"转换成功: {filename}\n") + except Exception as e: + print(f"转换失败: {str(e)}\n") + + def convert_ogg_to_audio(self, input_files): + """ogg转回音频转换逻辑""" + for input_path in input_files: + try: + filename = os.path.basename(input_path) + base_name = os.path.splitext(filename)[0] + output_path = os.path.join(self.output_dir.get(), f"{base_name}.ogg") + + print(f"正在转换: {filename}") + ( + ffmpeg + .input(input_path) + .output(output_path, acodec='libopus', audio_bitrate='16k', ac=1, ar=16000, frame_duration=60) + .run(overwrite_output=True) + ) + print(f"转换成功: {filename}\n") + except Exception as e: + print(f"转换失败: {str(e)}\n") + +if __name__ == "__main__": + root = tk.Tk() + app = AudioConverterApp(root) + root.mainloop() diff --git a/scripts/p3_tools/README.md b/scripts/p3_tools/README.md new file mode 100644 index 0000000..0ee279c --- /dev/null +++ b/scripts/p3_tools/README.md @@ -0,0 +1,95 @@ +# P3音频格式转换与播放工具 + +这个目录包含两个用于处理P3格式音频文件的Python脚本: + +## 1. 音频转换工具 (convert_audio_to_p3.py) + +将普通音频文件转换为P3格式(4字节header + Opus数据包的流式结构)并进行响度标准化。 + +### 使用方法 + +```bash +python convert_audio_to_p3.py <输入音频文件> <输出P3文件> [-l LUFS] [-d] +``` + +其中,可选选项 `-l` 用于指定响度标准化的目标响度,默认为 -16 LUFS;可选选项 `-d` 可以禁用响度标准化。 + +如果输入的音频文件符合下面的任一条件,建议使用 `-d` 禁用响度标准化: +- 音频过短 +- 音频已经调整过响度 +- 音频来自默认 TTS (小智当前使用的 TTS 的默认响度已是 -16 LUFS) + +例如: +```bash +python convert_audio_to_p3.py input.mp3 output.p3 +``` + +## 2. P3音频播放工具 (play_p3.py) + +播放P3格式的音频文件。 + +### 特性 + +- 解码并播放P3格式的音频文件 +- 在播放结束或用户中断时应用淡出效果,避免破音 +- 支持通过命令行参数指定要播放的文件 + +### 使用方法 + +```bash +python play_p3.py +``` + +例如: +```bash +python play_p3.py output.p3 +``` + +## 3. 音频转回工具 (convert_p3_to_audio.py) + +将P3格式转换回普通音频文件。 + +### 使用方法 + +```bash +python convert_p3_to_audio.py <输入P3文件> <输出音频文件> +``` + +输出音频文件需要有扩展名。 + +例如: +```bash +python convert_p3_to_audio.py input.p3 output.wav +``` +## 4. 音频/P3批量转换工具 + +一个图形化的工具,支持批量转换音频到P3,P3到音频 + +![](./img/img.png) + +### 使用方法: +```bash +python batch_convert_gui.py +``` + +## 依赖安装 + +在使用这些脚本前,请确保安装了所需的Python库: + +```bash +pip install librosa opuslib numpy tqdm sounddevice pyloudnorm soundfile +``` + +或者使用提供的requirements.txt文件: + +```bash +pip install -r requirements.txt +``` + +## P3格式说明 + +P3格式是一种简单的流式音频格式,结构如下: +- 每个音频帧由一个4字节的头部和一个Opus编码的数据包组成 +- 头部格式:[1字节类型, 1字节保留, 2字节长度] +- 采样率固定为16000Hz,单声道 +- 每帧时长为60ms \ No newline at end of file diff --git a/scripts/p3_tools/batch_convert_gui.py b/scripts/p3_tools/batch_convert_gui.py new file mode 100644 index 0000000..8555e55 --- /dev/null +++ b/scripts/p3_tools/batch_convert_gui.py @@ -0,0 +1,221 @@ +import tkinter as tk +from tkinter import ttk, filedialog, messagebox +import os +import threading +import sys +from convert_audio_to_p3 import encode_audio_to_opus +from convert_p3_to_audio import decode_p3_to_audio + +class AudioConverterApp: + def __init__(self, master): + self.master = master + master.title("音频/P3 批量转换工具") + master.geometry("680x600") # 调整窗口高度 + + # 初始化变量 + self.mode = tk.StringVar(value="audio_to_p3") + self.output_dir = tk.StringVar() + self.output_dir.set(os.path.abspath("output")) + self.enable_loudnorm = tk.BooleanVar(value=True) + self.target_lufs = tk.DoubleVar(value=-16.0) + + # 创建UI组件 + self.create_widgets() + self.redirect_output() + + def create_widgets(self): + # 模式选择 + mode_frame = ttk.LabelFrame(self.master, text="转换模式") + mode_frame.grid(row=0, column=0, padx=10, pady=5, sticky="ew") + + ttk.Radiobutton(mode_frame, text="音频转P3", variable=self.mode, + value="audio_to_p3", command=self.toggle_settings, + width=12).grid(row=0, column=0, padx=5) + ttk.Radiobutton(mode_frame, text="P3转音频", variable=self.mode, + value="p3_to_audio", command=self.toggle_settings, + width=12).grid(row=0, column=1, padx=5) + + # 响度设置 + self.loudnorm_frame = ttk.Frame(self.master) + self.loudnorm_frame.grid(row=1, column=0, padx=10, pady=5, sticky="ew") + + ttk.Checkbutton(self.loudnorm_frame, text="启用响度调整", + variable=self.enable_loudnorm, width=15 + ).grid(row=0, column=0, padx=2) + ttk.Entry(self.loudnorm_frame, textvariable=self.target_lufs, + width=6).grid(row=0, column=1, padx=2) + ttk.Label(self.loudnorm_frame, text="LUFS").grid(row=0, column=2, padx=2) + + # 文件选择 + file_frame = ttk.LabelFrame(self.master, text="输入文件") + file_frame.grid(row=2, column=0, padx=10, pady=5, sticky="nsew") + + # 文件操作按钮 + ttk.Button(file_frame, text="选择文件", command=self.select_files, + width=12).grid(row=0, column=0, padx=5, pady=2) + ttk.Button(file_frame, text="移除选中", command=self.remove_selected, + width=12).grid(row=0, column=1, padx=5, pady=2) + ttk.Button(file_frame, text="清空列表", command=self.clear_files, + width=12).grid(row=0, column=2, padx=5, pady=2) + + # 文件列表(使用Treeview) + self.tree = ttk.Treeview(file_frame, columns=("selected", "filename"), + show="headings", height=8) + self.tree.heading("selected", text="选中", anchor=tk.W) + self.tree.heading("filename", text="文件名", anchor=tk.W) + self.tree.column("selected", width=60, anchor=tk.W) + self.tree.column("filename", width=600, anchor=tk.W) + self.tree.grid(row=1, column=0, columnspan=3, sticky="nsew", padx=5, pady=2) + self.tree.bind("", self.on_tree_click) + + # 输出目录 + output_frame = ttk.LabelFrame(self.master, text="输出目录") + output_frame.grid(row=3, column=0, padx=10, pady=5, sticky="ew") + + ttk.Entry(output_frame, textvariable=self.output_dir, width=60 + ).grid(row=0, column=0, padx=5, sticky="ew") + ttk.Button(output_frame, text="浏览", command=self.select_output_dir, + width=8).grid(row=0, column=1, padx=5) + + # 转换按钮区域 + button_frame = ttk.Frame(self.master) + button_frame.grid(row=4, column=0, padx=10, pady=10, sticky="ew") + + ttk.Button(button_frame, text="转换全部文件", command=lambda: self.start_conversion(True), + width=15).pack(side=tk.LEFT, padx=5) + ttk.Button(button_frame, text="转换选中文件", command=lambda: self.start_conversion(False), + width=15).pack(side=tk.LEFT, padx=5) + + # 日志区域 + log_frame = ttk.LabelFrame(self.master, text="日志") + log_frame.grid(row=5, column=0, padx=10, pady=5, sticky="nsew") + + self.log_text = tk.Text(log_frame, height=14, width=80) + self.log_text.pack(fill=tk.BOTH, expand=True) + + # 配置布局权重 + self.master.columnconfigure(0, weight=1) + self.master.rowconfigure(2, weight=1) + self.master.rowconfigure(5, weight=3) + file_frame.columnconfigure(0, weight=1) + file_frame.rowconfigure(1, weight=1) + + def toggle_settings(self): + if self.mode.get() == "audio_to_p3": + self.loudnorm_frame.grid() + else: + self.loudnorm_frame.grid_remove() + + def select_files(self): + file_types = [ + ("音频文件", "*.wav *.mp3 *.ogg *.flac") if self.mode.get() == "audio_to_p3" + else ("P3文件", "*.p3") + ] + + files = filedialog.askopenfilenames(filetypes=file_types) + for f in files: + self.tree.insert("", tk.END, values=("[ ]", os.path.basename(f)), tags=(f,)) + + def on_tree_click(self, event): + """处理复选框点击事件""" + region = self.tree.identify("region", event.x, event.y) + if region == "cell": + col = self.tree.identify_column(event.x) + item = self.tree.identify_row(event.y) + if col == "#1": # 点击的是选中列 + current_val = self.tree.item(item, "values")[0] + new_val = "[√]" if current_val == "[ ]" else "[ ]" + self.tree.item(item, values=(new_val, self.tree.item(item, "values")[1])) + + def remove_selected(self): + """移除选中的文件""" + to_remove = [] + for item in self.tree.get_children(): + if self.tree.item(item, "values")[0] == "[√]": + to_remove.append(item) + for item in reversed(to_remove): + self.tree.delete(item) + + def clear_files(self): + """清空所有文件""" + for item in self.tree.get_children(): + self.tree.delete(item) + + def select_output_dir(self): + path = filedialog.askdirectory() + if path: + self.output_dir.set(path) + + def redirect_output(self): + class StdoutRedirector: + def __init__(self, text_widget): + self.text_widget = text_widget + self.original_stdout = sys.stdout + + def write(self, message): + self.text_widget.insert(tk.END, message) + self.text_widget.see(tk.END) + self.original_stdout.write(message) + + def flush(self): + self.original_stdout.flush() + + sys.stdout = StdoutRedirector(self.log_text) + + def start_conversion(self, convert_all): + """开始转换""" + input_files = [] + for item in self.tree.get_children(): + if convert_all or self.tree.item(item, "values")[0] == "[√]": + input_files.append(self.tree.item(item, "tags")[0]) + + if not input_files: + msg = "没有找到可转换的文件" if convert_all else "没有选中任何文件" + messagebox.showwarning("警告", msg) + return + + os.makedirs(self.output_dir.get(), exist_ok=True) + + try: + if self.mode.get() == "audio_to_p3": + target_lufs = self.target_lufs.get() if self.enable_loudnorm.get() else None + thread = threading.Thread(target=self.convert_audio_to_p3, args=(target_lufs, input_files)) + else: + thread = threading.Thread(target=self.convert_p3_to_audio, args=(input_files,)) + + thread.start() + except Exception as e: + print(f"转换初始化失败: {str(e)}") + + def convert_audio_to_p3(self, target_lufs, input_files): + """音频转P3转换逻辑""" + for input_path in input_files: + try: + filename = os.path.basename(input_path) + base_name = os.path.splitext(filename)[0] + output_path = os.path.join(self.output_dir.get(), f"{base_name}.p3") + + print(f"正在转换: {filename}") + encode_audio_to_opus(input_path, output_path, target_lufs) + print(f"转换成功: {filename}\n") + except Exception as e: + print(f"转换失败: {str(e)}\n") + + def convert_p3_to_audio(self, input_files): + """P3转音频转换逻辑""" + for input_path in input_files: + try: + filename = os.path.basename(input_path) + base_name = os.path.splitext(filename)[0] + output_path = os.path.join(self.output_dir.get(), f"{base_name}.wav") + + print(f"正在转换: {filename}") + decode_p3_to_audio(input_path, output_path) + print(f"转换成功: {filename}\n") + except Exception as e: + print(f"转换失败: {str(e)}\n") + +if __name__ == "__main__": + root = tk.Tk() + app = AudioConverterApp(root) + root.mainloop() \ No newline at end of file diff --git a/scripts/p3_tools/convert_audio_to_p3.py b/scripts/p3_tools/convert_audio_to_p3.py new file mode 100644 index 0000000..519d662 --- /dev/null +++ b/scripts/p3_tools/convert_audio_to_p3.py @@ -0,0 +1,62 @@ +# convert audio files to protocol v3 stream +import librosa +import opuslib +import struct +import sys +import tqdm +import numpy as np +import argparse +import pyloudnorm as pyln + +def encode_audio_to_opus(input_file, output_file, target_lufs=None): + # Load audio file using librosa + audio, sample_rate = librosa.load(input_file, sr=None, mono=False, dtype=np.float32) + + # Convert to mono if stereo + if audio.ndim == 2: + audio = librosa.to_mono(audio) + + if target_lufs is not None: + print("Note: Automatic loudness adjustment is enabled, which may cause", file=sys.stderr) + print(" audio distortion. If the input audio has already been ", file=sys.stderr) + print(" loudness-adjusted or if the input audio is TTS audio, ", file=sys.stderr) + print(" please use the `-d` parameter to disable loudness adjustment.", file=sys.stderr) + meter = pyln.Meter(sample_rate) + current_loudness = meter.integrated_loudness(audio) + audio = pyln.normalize.loudness(audio, current_loudness, target_lufs) + print(f"Adjusted loudness: {current_loudness:.1f} LUFS -> {target_lufs} LUFS") + + # Convert sample rate to 16000Hz if necessary + target_sample_rate = 16000 + if sample_rate != target_sample_rate: + audio = librosa.resample(audio, orig_sr=sample_rate, target_sr=target_sample_rate) + sample_rate = target_sample_rate + + # Convert audio data back to int16 after processing + audio = (audio * 32767).astype(np.int16) + + # Initialize Opus encoder + encoder = opuslib.Encoder(sample_rate, 1, opuslib.APPLICATION_AUDIO) + + # Encode and save + with open(output_file, 'wb') as f: + duration = 60 # 60ms per frame + frame_size = int(sample_rate * duration / 1000) + for i in tqdm.tqdm(range(0, len(audio) - frame_size, frame_size)): + frame = audio[i:i + frame_size] + opus_data = encoder.encode(frame.tobytes(), frame_size=frame_size) + packet = struct.pack('>BBH', 0, 0, len(opus_data)) + opus_data + f.write(packet) + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description='Convert audio to Opus with loudness normalization') + parser.add_argument('input_file', help='Input audio file') + parser.add_argument('output_file', help='Output .opus file') + parser.add_argument('-l', '--lufs', type=float, default=-16.0, + help='Target loudness in LUFS (default: -16)') + parser.add_argument('-d', '--disable-loudnorm', action='store_true', + help='Disable loudness normalization') + args = parser.parse_args() + + target_lufs = None if args.disable_loudnorm else args.lufs + encode_audio_to_opus(args.input_file, args.output_file, target_lufs) \ No newline at end of file diff --git a/scripts/p3_tools/convert_p3_to_audio.py b/scripts/p3_tools/convert_p3_to_audio.py new file mode 100644 index 0000000..f870b01 --- /dev/null +++ b/scripts/p3_tools/convert_p3_to_audio.py @@ -0,0 +1,51 @@ +import struct +import sys +import opuslib +import numpy as np +from tqdm import tqdm +import soundfile as sf + + +def decode_p3_to_audio(input_file, output_file): + sample_rate = 16000 + channels = 1 + decoder = opuslib.Decoder(sample_rate, channels) + + pcm_frames = [] + frame_size = int(sample_rate * 60 / 1000) + + with open(input_file, "rb") as f: + f.seek(0, 2) + total_size = f.tell() + f.seek(0) + + with tqdm(total=total_size, unit="B", unit_scale=True) as pbar: + while True: + header = f.read(4) + if not header or len(header) < 4: + break + + pkt_type, reserved, opus_len = struct.unpack(">BBH", header) + opus_data = f.read(opus_len) + if len(opus_data) != opus_len: + break + + pcm = decoder.decode(opus_data, frame_size) + pcm_frames.append(np.frombuffer(pcm, dtype=np.int16)) + + pbar.update(4 + opus_len) + + if not pcm_frames: + raise ValueError("No valid audio data found") + + pcm_data = np.concatenate(pcm_frames) + + sf.write(output_file, pcm_data, sample_rate, subtype="PCM_16") + + +if __name__ == "__main__": + if len(sys.argv) != 3: + print("Usage: python convert_p3_to_audio.py ") + sys.exit(1) + + decode_p3_to_audio(sys.argv[1], sys.argv[2]) diff --git a/scripts/p3_tools/img/img.png b/scripts/p3_tools/img/img.png new file mode 100644 index 0000000..7ee34ee Binary files /dev/null and b/scripts/p3_tools/img/img.png differ diff --git a/scripts/p3_tools/p3_gui_player.py b/scripts/p3_tools/p3_gui_player.py new file mode 100644 index 0000000..3bbc8a3 --- /dev/null +++ b/scripts/p3_tools/p3_gui_player.py @@ -0,0 +1,241 @@ +import tkinter as tk +from tkinter import filedialog, messagebox +import threading +import time +import opuslib +import struct +import numpy as np +import sounddevice as sd +import os + + +def play_p3_file(input_file, stop_event=None, pause_event=None): + """ + 播放p3格式的音频文件 + p3格式: [1字节类型, 1字节保留, 2字节长度, Opus数据] + """ + # 初始化Opus解码器 + sample_rate = 16000 # 采样率固定为16000Hz + channels = 1 # 单声道 + decoder = opuslib.Decoder(sample_rate, channels) + + # 帧大小 (60ms) + frame_size = int(sample_rate * 60 / 1000) + + # 打开音频流 + stream = sd.OutputStream( + samplerate=sample_rate, + channels=channels, + dtype='int16' + ) + stream.start() + + try: + with open(input_file, 'rb') as f: + print(f"正在播放: {input_file}") + + while True: + if stop_event and stop_event.is_set(): + break + + if pause_event and pause_event.is_set(): + time.sleep(0.1) + continue + + # 读取头部 (4字节) + header = f.read(4) + if not header or len(header) < 4: + break + + # 解析头部 + packet_type, reserved, data_len = struct.unpack('>BBH', header) + + # 读取Opus数据 + opus_data = f.read(data_len) + if not opus_data or len(opus_data) < data_len: + break + + # 解码Opus数据 + pcm_data = decoder.decode(opus_data, frame_size) + + # 将字节转换为numpy数组 + audio_array = np.frombuffer(pcm_data, dtype=np.int16) + + # 播放音频 + stream.write(audio_array) + + except KeyboardInterrupt: + print("\n播放已停止") + finally: + stream.stop() + stream.close() + print("播放完成") + + +class P3PlayerApp: + def __init__(self, root): + self.root = root + self.root.title("P3 文件简易播放器") + self.root.geometry("500x400") + + self.playlist = [] + self.current_index = 0 + self.is_playing = False + self.is_paused = False + self.stop_event = threading.Event() + self.pause_event = threading.Event() + self.loop_playback = tk.BooleanVar(value=False) # 循环播放复选框的状态 + + # 创建界面组件 + self.create_widgets() + + def create_widgets(self): + # 播放列表 + self.playlist_label = tk.Label(self.root, text="播放列表:") + self.playlist_label.pack(pady=5) + + self.playlist_frame = tk.Frame(self.root) + self.playlist_frame.pack(fill=tk.BOTH, expand=True, padx=10, pady=5) + + self.playlist_listbox = tk.Listbox(self.playlist_frame, selectmode=tk.SINGLE) + self.playlist_listbox.pack(fill=tk.BOTH, expand=True) + + # 复选框和移除按钮 + self.checkbox_frame = tk.Frame(self.root) + self.checkbox_frame.pack(pady=5) + + self.remove_button = tk.Button(self.checkbox_frame, text="移除文件", command=self.remove_files) + self.remove_button.pack(side=tk.LEFT, padx=5) + + # 循环播放复选框 + self.loop_checkbox = tk.Checkbutton(self.checkbox_frame, text="循环播放", variable=self.loop_playback) + self.loop_checkbox.pack(side=tk.LEFT, padx=5) + + # 控制按钮 + self.control_frame = tk.Frame(self.root) + self.control_frame.pack(pady=10) + + self.add_button = tk.Button(self.control_frame, text="添加文件", command=self.add_file) + self.add_button.grid(row=0, column=0, padx=5) + + self.play_button = tk.Button(self.control_frame, text="播放", command=self.play) + self.play_button.grid(row=0, column=1, padx=5) + + self.pause_button = tk.Button(self.control_frame, text="暂停", command=self.pause) + self.pause_button.grid(row=0, column=2, padx=5) + + self.stop_button = tk.Button(self.control_frame, text="停止", command=self.stop) + self.stop_button.grid(row=0, column=3, padx=5) + + # 状态标签 + self.status_label = tk.Label(self.root, text="未在播放", fg="blue") + self.status_label.pack(pady=10) + + def add_file(self): + files = filedialog.askopenfilenames(filetypes=[("P3 文件", "*.p3")]) + if files: + self.playlist.extend(files) + self.update_playlist() + + def update_playlist(self): + self.playlist_listbox.delete(0, tk.END) + for file in self.playlist: + self.playlist_listbox.insert(tk.END, os.path.basename(file)) # 仅显示文件名 + + def update_status(self, status_text, color="blue"): + """更新状态标签的内容""" + self.status_label.config(text=status_text, fg=color) + + def play(self): + if not self.playlist: + messagebox.showwarning("警告", "播放列表为空!") + return + + if self.is_paused: + self.is_paused = False + self.pause_event.clear() + self.update_status(f"正在播放:{os.path.basename(self.playlist[self.current_index])}", "green") + return + + if self.is_playing: + return + + self.is_playing = True + self.stop_event.clear() + self.pause_event.clear() + self.current_index = self.playlist_listbox.curselection()[0] if self.playlist_listbox.curselection() else 0 + self.play_thread = threading.Thread(target=self.play_audio, daemon=True) + self.play_thread.start() + self.update_status(f"正在播放:{os.path.basename(self.playlist[self.current_index])}", "green") + + def play_audio(self): + while True: + if self.stop_event.is_set(): + break + + if self.pause_event.is_set(): + time.sleep(0.1) + continue + + # 检查当前索引是否有效 + if self.current_index >= len(self.playlist): + if self.loop_playback.get(): # 如果勾选了循环播放 + self.current_index = 0 # 回到第一首 + else: + break # 否则停止播放 + + file = self.playlist[self.current_index] + self.playlist_listbox.selection_clear(0, tk.END) + self.playlist_listbox.selection_set(self.current_index) + self.playlist_listbox.activate(self.current_index) + self.update_status(f"正在播放:{os.path.basename(self.playlist[self.current_index])}", "green") + play_p3_file(file, self.stop_event, self.pause_event) + + if self.stop_event.is_set(): + break + + if not self.loop_playback.get(): # 如果没有勾选循环播放 + break # 播放完当前文件后停止 + + self.current_index += 1 + if self.current_index >= len(self.playlist): + if self.loop_playback.get(): # 如果勾选了循环播放 + self.current_index = 0 # 回到第一首 + + self.is_playing = False + self.is_paused = False + self.update_status("播放已停止", "red") + + def pause(self): + if self.is_playing: + self.is_paused = not self.is_paused + if self.is_paused: + self.pause_event.set() + self.update_status("播放已暂停", "orange") + else: + self.pause_event.clear() + self.update_status(f"正在播放:{os.path.basename(self.playlist[self.current_index])}", "green") + + def stop(self): + if self.is_playing or self.is_paused: + self.is_playing = False + self.is_paused = False + self.stop_event.set() + self.pause_event.clear() + self.update_status("播放已停止", "red") + + def remove_files(self): + selected_indices = self.playlist_listbox.curselection() + if not selected_indices: + messagebox.showwarning("警告", "请先选择要移除的文件!") + return + + for index in reversed(selected_indices): + self.playlist.pop(index) + self.update_playlist() + + +if __name__ == "__main__": + root = tk.Tk() + app = P3PlayerApp(root) + root.mainloop() diff --git a/scripts/p3_tools/play_p3.py b/scripts/p3_tools/play_p3.py new file mode 100644 index 0000000..3c9ec81 --- /dev/null +++ b/scripts/p3_tools/play_p3.py @@ -0,0 +1,71 @@ +# 播放p3格式的音频文件 +import opuslib +import struct +import numpy as np +import sounddevice as sd +import argparse + +def play_p3_file(input_file): + """ + 播放p3格式的音频文件 + p3格式: [1字节类型, 1字节保留, 2字节长度, Opus数据] + """ + # 初始化Opus解码器 + sample_rate = 16000 # 采样率固定为16000Hz + channels = 1 # 单声道 + decoder = opuslib.Decoder(sample_rate, channels) + + # 帧大小 (60ms) + frame_size = int(sample_rate * 60 / 1000) + + # 打开音频流 + stream = sd.OutputStream( + samplerate=sample_rate, + channels=channels, + dtype='int16' + ) + stream.start() + + try: + with open(input_file, 'rb') as f: + print(f"正在播放: {input_file}") + + while True: + # 读取头部 (4字节) + header = f.read(4) + if not header or len(header) < 4: + break + + # 解析头部 + packet_type, reserved, data_len = struct.unpack('>BBH', header) + + # 读取Opus数据 + opus_data = f.read(data_len) + if not opus_data or len(opus_data) < data_len: + break + + # 解码Opus数据 + pcm_data = decoder.decode(opus_data, frame_size) + + # 将字节转换为numpy数组 + audio_array = np.frombuffer(pcm_data, dtype=np.int16) + + # 播放音频 + stream.write(audio_array) + + except KeyboardInterrupt: + print("\n播放已停止") + finally: + stream.stop() + stream.close() + print("播放完成") + +def main(): + parser = argparse.ArgumentParser(description='播放p3格式的音频文件') + parser.add_argument('input_file', help='输入的p3文件路径') + args = parser.parse_args() + + play_p3_file(args.input_file) + +if __name__ == "__main__": + main() diff --git a/scripts/p3_tools/requirements.txt b/scripts/p3_tools/requirements.txt new file mode 100644 index 0000000..d76d4cd --- /dev/null +++ b/scripts/p3_tools/requirements.txt @@ -0,0 +1,7 @@ +librosa>=0.9.2 +opuslib>=3.0.1 +numpy>=1.20.0 +tqdm>=4.62.0 +sounddevice>=0.4.4 +pyloudnorm>=0.1.1 +soundfile>=0.13.1 diff --git a/scripts/release.py b/scripts/release.py new file mode 100644 index 0000000..1989420 --- /dev/null +++ b/scripts/release.py @@ -0,0 +1,359 @@ +import sys +import os +import json +import zipfile +import argparse +from pathlib import Path +from typing import Optional + +# Switch to project root directory +os.chdir(Path(__file__).resolve().parent.parent) + +################################################################################ +# Common utility functions +################################################################################ + +def get_board_type_from_compile_commands() -> Optional[str]: + """Parse the current compiled BOARD_TYPE from build/compile_commands.json""" + compile_file = Path("build/compile_commands.json") + if not compile_file.exists(): + return None + with compile_file.open() as f: + data = json.load(f) + for item in data: + if not item["file"].endswith("main.cc"): + continue + cmd = item["command"] + if "-DBOARD_TYPE=\\\"" in cmd: + return cmd.split("-DBOARD_TYPE=\\\"")[1].split("\\\"")[0].strip() + return None + + +def get_project_version() -> Optional[str]: + """Read set(PROJECT_VER "x.y.z") from root CMakeLists.txt""" + with Path("CMakeLists.txt").open() as f: + for line in f: + if line.startswith("set(PROJECT_VER"): + return line.split("\"")[1] + return None + + +def merge_bin() -> None: + if os.system("idf.py merge-bin") != 0: + print("merge-bin failed", file=sys.stderr) + sys.exit(1) + + +def zip_bin(name: str, version: str) -> None: + """Zip build/merged-binary.bin to releases/v{version}_{name}.zip""" + out_dir = Path("releases") + out_dir.mkdir(exist_ok=True) + output_path = out_dir / f"v{version}_{name}.zip" + + if output_path.exists(): + output_path.unlink() + + with zipfile.ZipFile(output_path, "w", compression=zipfile.ZIP_DEFLATED) as zipf: + zipf.write("build/merged-binary.bin", arcname="merged-binary.bin") + print(f"zip bin to {output_path} done") + +def _get_manufacturer(cfg: dict) -> Optional[str]: + """Read manufacturer from config.json""" + m = cfg.get("manufacturer") + if isinstance(m, str) and m.strip(): + return m.strip() + return None + +################################################################################ +# board / variant related functions +################################################################################ + +_BOARDS_DIR = Path("main/boards") + +def _collect_variants(config_filename: str = "config.json") -> list[dict[str, str]]: + """Traverse all boards under main/boards, collect variant information. + + Return example: + [{"board": "bread-compact-ml307", "name": "bread-compact-ml307", "full_name": "bread-compact-ml307"}, ...] + [{"board": "waveshare/esp32-p4-nano", "name": "esp32-p4-nano-10.1-a", "full_name": "waveshare-esp32-p4-nano-10.1-a"}, ...] + """ + variants: list[dict[str, str]] = [] + errors: list[str] = [] + + for cfg_path in _BOARDS_DIR.rglob(config_filename): + board_dir = cfg_path.parent + if board_dir.name == "common": + continue + board = board_dir.relative_to(_BOARDS_DIR).as_posix() + + try: + with cfg_path.open() as f: + cfg = json.load(f) + + manufacturer = _get_manufacturer(cfg) + + # Check manufacturer consistency with directory structure + if "/" in board: + # Board is in a subdirectory (e.g., waveshare/esp32-p4-nano) + expected_manufacturer = board.split("/")[0] + if not manufacturer: + errors.append( + f"{cfg_path}: Board is in '{expected_manufacturer}/' subdirectory, " + f"but config.json is missing \"manufacturer\": \"{expected_manufacturer}\"" + ) + elif manufacturer != expected_manufacturer: + errors.append( + f"{cfg_path}: manufacturer mismatch, " + f"directory is '{expected_manufacturer}/' but config.json has \"{manufacturer}\"" + ) + else: + # Board is directly under boards/ directory + if manufacturer: + errors.append( + f"{cfg_path}: Board is not in a manufacturer subdirectory, " + f"but config.json defines manufacturer \"{manufacturer}\", " + f"please move board to main/boards/{manufacturer}/{board}/" + ) + + for build in cfg.get("builds", []): + name = build["name"] + full_name = f"{manufacturer}-{name}" if manufacturer else name + variants.append({ + "board": board, + "name": name, + "full_name": full_name + }) + + except Exception as e: + print(f"[ERROR] Failed to parse {cfg_path}: {e}", file=sys.stderr) + + # Report all errors at once + if errors: + print("\n[ERROR] Found manufacturer configuration issues:", file=sys.stderr) + for err in errors: + print(f" - {err}", file=sys.stderr) + print(file=sys.stderr) + sys.exit(1) + + return variants + + + +def _find_board_config(board_type: str) -> Optional[str]: + """Find the corresponding CONFIG_BOARD_TYPE_xxx for the given board_type + + Search backwards from 'set(BOARD_TYPE "xxx")' to find the nearest if(CONFIG_BOARD_TYPE_). + """ + board_leaf = board_type.split("/")[-1] + pattern = f'set(BOARD_TYPE "{board_leaf}")' + + cmake_file = Path("main/CMakeLists.txt") + lines = cmake_file.read_text(encoding="utf-8").splitlines() + + for idx, line in enumerate(lines): + if pattern in line: + # Found the BOARD_TYPE line, search backwards for the config + for back_idx in range(idx - 1, -1, -1): + back_line = lines[back_idx] + if "if(CONFIG_BOARD_TYPE_" in back_line: + return back_line.strip().split("if(")[1].split(")")[0] + break + return None + + +# Kconfig "select" entries are not automatically applied when we simply append +# sdkconfig lines from config.json, so add the required dependencies here to +# mimic menuconfig behaviour. +_AUTO_SELECT_RULES: dict[str, list[str]] = { + "CONFIG_USE_ESP_BLUFI_WIFI_PROVISIONING": [ + "CONFIG_BT_ENABLED=y", + "CONFIG_BT_BLUEDROID_ENABLED=y", + "CONFIG_BT_BLE_42_FEATURES_SUPPORTED=y", + "CONFIG_BT_BLE_50_FEATURES_SUPPORTED=n", + "CONFIG_BT_BLE_BLUFI_ENABLE=y", + "CONFIG_MBEDTLS_DHM_C=y", + ], +} + + +def _apply_auto_selects(sdkconfig_append: list[str]) -> list[str]: + """Apply hardcoded auto-select rules to sdkconfig_append.""" + items: list[str] = [] + existing_keys: set[str] = set() + + def _append_if_missing(entry: str) -> None: + key = entry.split("=", 1)[0] + if key not in existing_keys: + items.append(entry) + existing_keys.add(key) + + # Preserve original order while tracking keys + for entry in sdkconfig_append: + _append_if_missing(entry) + + # Apply auto-select rules + for key, deps in _AUTO_SELECT_RULES.items(): + for entry in sdkconfig_append: + name, _, value = entry.partition("=") + if name == key and value.lower().startswith("y"): + for dep in deps: + _append_if_missing(dep) + break + + return items + +################################################################################ +# Check board_type in CMakeLists +################################################################################ + +def _board_type_exists(board_type: str) -> bool: + cmake_file = Path("main/CMakeLists.txt").read_text(encoding="utf-8") + board_leaf = board_type.split("/")[-1] + pattern = f'set(BOARD_TYPE "{board_leaf}")' + return pattern in cmake_file + +################################################################################ +# Compile implementation +################################################################################ + +def release(board_type: str, config_filename: str = "config.json", *, filter_name: Optional[str] = None) -> None: + """Compile and package all/specified variants of the specified board_type + + Args: + board_type: directory name under main/boards + config_filename: config.json name (default: config.json) + filter_name: if specified, only compile the build["name"] that matches + """ + cfg_path = _BOARDS_DIR / Path(board_type) / config_filename + if not cfg_path.exists(): + print(f"[WARN] {cfg_path} does not exist, skipping {board_type}") + return + + project_version = get_project_version() + print(f"Project Version: {project_version} ({cfg_path})") + + with cfg_path.open() as f: + cfg = json.load(f) + target = cfg["target"] + manufacturer = _get_manufacturer(cfg) + + builds = cfg.get("builds", []) + if filter_name: + builds = [b for b in builds if b["name"] == filter_name] + if not builds: + print(f"[ERROR] Variant {filter_name} not found in {board_type}'s {config_filename}", file=sys.stderr) + sys.exit(1) + + for build in builds: + name = build["name"] + board_leaf = board_type.split("/")[-1] + + if board_leaf not in name: + raise ValueError(f"build.name {name} must contain {board_leaf}") + + final_name = f"{manufacturer}-{name}" if manufacturer else name + output_path = Path("releases") / f"v{project_version}_{final_name}.zip" + if output_path.exists(): + print(f"Skipping {final_name} because {output_path} already exists") + continue + + # Process sdkconfig_append + board_type_config = _find_board_config(board_type) + sdkconfig_append = [f"{board_type_config}=y"] + sdkconfig_append.extend(build.get("sdkconfig_append", [])) + sdkconfig_append = _apply_auto_selects(sdkconfig_append) + + print("-" * 80) + print(f"name: {final_name}") + print(f"target: {target}") + if manufacturer: + print(f"manufacturer: {manufacturer}") + for item in sdkconfig_append: + print(f"sdkconfig_append: {item}") + + os.environ.pop("IDF_TARGET", None) + + # Call set-target + if os.system(f"idf.py set-target {target}") != 0: + print("set-target failed", file=sys.stderr) + sys.exit(1) + + # Append sdkconfig + with Path("sdkconfig").open("a") as f: + f.write("\n") + f.write("# Append by release.py\n") + for append in sdkconfig_append: + f.write(f"{append}\n") + # Build with macro BOARD_NAME defined to name + if os.system(f"idf.py -DBOARD_NAME={name} -DBOARD_TYPE={board_type} build") != 0: + print("build failed") + sys.exit(1) + + # merge-bin + merge_bin() + + # Zip + zip_bin(final_name, project_version) + +################################################################################ +# CLI entry +################################################################################ + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("board", nargs="?", default=None, help="Board type or 'all'") + parser.add_argument("-c", "--config", default="config.json", help="Config filename (default: config.json)") + parser.add_argument("--list-boards", action="store_true", help="List all supported boards and variants") + parser.add_argument("--json", action="store_true", help="Output in JSON format (use with --list-boards)") + parser.add_argument("--name", help="Variant name to compile (original name without manufacturer prefix)") + + args = parser.parse_args() + + # List mode + if args.list_boards: + variants = _collect_variants(config_filename=args.config) + if args.json: + print(json.dumps(variants)) + else: + for v in variants: + print(f"{v['board']}: {v['name']}") + sys.exit(0) + + # Current directory firmware packaging mode + if args.board is None: + merge_bin() + curr_board_type = get_board_type_from_compile_commands() + if curr_board_type is None: + print("Failed to parse board_type from compile_commands.json", file=sys.stderr) + sys.exit(1) + project_ver = get_project_version() + zip_bin(curr_board_type, project_ver) + sys.exit(0) + + # Compile mode + board_type_input: str = args.board + name_filter: Optional[str] = args.name + + # Check board_type in CMakeLists + if board_type_input != "all" and not _board_type_exists(board_type_input): + print(f"[ERROR] board_type {board_type_input} not found in main/CMakeLists.txt", file=sys.stderr) + sys.exit(1) + + variants_all = _collect_variants(config_filename=args.config) + + # Filter board_type list + target_board_types: set[str] + if board_type_input == "all": + target_board_types = {v["board"] for v in variants_all} + else: + target_board_types = {board_type_input} + + for bt in sorted(target_board_types): + if not _board_type_exists(bt): + print(f"[ERROR] board_type {bt} not found in main/CMakeLists.txt", file=sys.stderr) + sys.exit(1) + cfg_path = _BOARDS_DIR / bt / args.config + if bt == board_type_input and not cfg_path.exists(): + print(f"Board {bt} has no {args.config} config file, skipping") + sys.exit(0) + release(bt, config_filename=args.config, filter_name=name_filter if bt == board_type_input else None) diff --git a/scripts/sonic_wifi_config.html b/scripts/sonic_wifi_config.html new file mode 100644 index 0000000..b5c5093 --- /dev/null +++ b/scripts/sonic_wifi_config.html @@ -0,0 +1,208 @@ + + + + + 小智声波配网 + + + + +
+

📶 小智声波配网

+ + + + + + +
+ +
+ + + + +
+ + + + diff --git a/scripts/spiffs_assets/README.md b/scripts/spiffs_assets/README.md new file mode 100644 index 0000000..9cd0f92 --- /dev/null +++ b/scripts/spiffs_assets/README.md @@ -0,0 +1,110 @@ +# SPIFFS Assets Builder + +这个脚本用于构建 ESP32 项目的 SPIFFS 资源分区,将各种资源文件打包成可在设备上使用的格式。 + +## 功能特性 + +- 处理唤醒网络模型 (WakeNet Model) +- 集成文本字体文件 +- 处理表情符号图片集合 +- 自动生成资源索引文件 +- 打包生成最终的 `assets.bin` 文件 + +## 依赖要求 + +- Python 3.6+ +- 相关资源文件 + +## 使用方法 + +### 基本语法 + +```bash +./build.py --wakenet_model \ + --text_font \ + --emoji_collection +``` + +### 参数说明 + +| 参数 | 类型 | 必需 | 说明 | +|------|------|------|------| +| `--wakenet_model` | 目录路径 | 否 | 唤醒网络模型目录路径 | +| `--text_font` | 文件路径 | 否 | 文本字体文件路径 | +| `--emoji_collection` | 目录路径 | 否 | 表情符号图片集合目录路径 | + +### 使用示例 + +```bash +# 完整参数示例 +./build.py \ + --wakenet_model ../../managed_components/espressif__esp-sr/model/wakenet_model/wn9_nihaoxiaozhi_tts \ + --text_font ../../components/xiaozhi-fonts/build/font_puhui_common_20_4.bin \ + --emoji_collection ../../components/xiaozhi-fonts/build/emojis_64/ + +# 仅处理字体文件 +./build.py --text_font ../../components/xiaozhi-fonts/build/font_puhui_common_20_4.bin + +# 仅处理表情符号 +./build.py --emoji_collection ../../components/xiaozhi-fonts/build/emojis_64/ +``` + +## 工作流程 + +1. **创建构建目录结构** + - `build/` - 主构建目录 + - `build/assets/` - 资源文件目录 + - `build/output/` - 输出文件目录 + +2. **处理唤醒网络模型** + - 复制模型文件到构建目录 + - 使用 `pack_model.py` 生成 `srmodels.bin` + - 将生成的模型文件复制到资源目录 + +3. **处理文本字体** + - 复制字体文件到资源目录 + - 支持 `.bin` 格式的字体文件 + +4. **处理表情符号集合** + - 扫描指定目录中的图片文件 + - 支持 `.png` 和 `.gif` 格式 + - 自动生成表情符号索引 + +5. **生成配置文件** + - `index.json` - 资源索引文件 + - `config.json` - 构建配置文件 + +6. **打包最终资源** + - 使用 `spiffs_assets_gen.py` 生成 `assets.bin` + - 复制到构建根目录 + +## 输出文件 + +构建完成后,会在 `build/` 目录下生成以下文件: + +- `assets/` - 所有资源文件 +- `assets.bin` - 最终的 SPIFFS 资源文件 +- `config.json` - 构建配置 +- `output/` - 中间输出文件 + +## 支持的资源格式 + +- **模型文件**: `.bin` (通过 pack_model.py 处理) +- **字体文件**: `.bin` +- **图片文件**: `.png`, `.gif` +- **配置文件**: `.json` + +## 错误处理 + +脚本包含完善的错误处理机制: + +- 检查源文件/目录是否存在 +- 验证子进程执行结果 +- 提供详细的错误信息和警告 + +## 注意事项 + +1. 确保所有依赖的 Python 脚本都在同一目录下 +2. 资源文件路径使用绝对路径或相对于脚本目录的路径 +3. 构建过程会清理之前的构建文件 +4. 生成的 `assets.bin` 文件大小受 SPIFFS 分区大小限制 diff --git a/scripts/spiffs_assets/build.py b/scripts/spiffs_assets/build.py new file mode 100644 index 0000000..47069d2 --- /dev/null +++ b/scripts/spiffs_assets/build.py @@ -0,0 +1,400 @@ +#!/usr/bin/env python3 +""" +Build the spiffs assets partition + +Usage: + ./build.py --wakenet_model \ + --text_font \ + --emoji_collection + +Example: + ./build.py --wakenet_model ../../managed_components/espressif__esp-sr/model/wakenet_model/wn9_nihaoxiaozhi_tts \ + --text_font ../../components/xiaozhi-fonts/build/font_puhui_common_20_4.bin \ + --emoji_collection ../../components/xiaozhi-fonts/build/emojis_64/ +""" + +import os +import sys +import shutil +import argparse +import subprocess +import json +from pathlib import Path + + +def ensure_dir(directory): + """Ensure directory exists, create if not""" + os.makedirs(directory, exist_ok=True) + + +def copy_file(src, dst): + """Copy file""" + if os.path.exists(src): + shutil.copy2(src, dst) + print(f"Copied: {src} -> {dst}") + else: + print(f"Warning: Source file does not exist: {src}") + + +def copy_directory(src, dst): + """Copy directory""" + if os.path.exists(src): + shutil.copytree(src, dst, dirs_exist_ok=True) + print(f"Copied directory: {src} -> {dst}") + else: + print(f"Warning: Source directory does not exist: {src}") + + +def process_wakenet_model(wakenet_model_dir, build_dir, assets_dir): + """Process wakenet_model parameter""" + if not wakenet_model_dir: + return None + + # Copy input directory to build directory + wakenet_build_dir = os.path.join(build_dir, "wakenet_model") + if os.path.exists(wakenet_build_dir): + shutil.rmtree(wakenet_build_dir) + copy_directory(wakenet_model_dir, os.path.join(wakenet_build_dir, os.path.basename(wakenet_model_dir))) + + # Use pack_model.py to generate srmodels.bin + srmodels_output = os.path.join(wakenet_build_dir, "srmodels.bin") + try: + subprocess.run([ + sys.executable, "pack_model.py", + "-m", wakenet_build_dir, + "-o", "srmodels.bin" + ], check=True, cwd=os.path.dirname(__file__)) + print(f"Generated: {srmodels_output}") + # Copy srmodels.bin to assets directory + copy_file(srmodels_output, os.path.join(assets_dir, "srmodels.bin")) + return "srmodels.bin" + except subprocess.CalledProcessError as e: + print(f"Error: Failed to generate srmodels.bin: {e}") + return None + + +def process_text_font(text_font_file, assets_dir): + """Process text_font parameter""" + if not text_font_file: + return None + + # Copy input file to build/assets directory + font_filename = os.path.basename(text_font_file) + font_dst = os.path.join(assets_dir, font_filename) + copy_file(text_font_file, font_dst) + + return font_filename + + +def process_emoji_collection(emoji_collection_dir, assets_dir): + """Process emoji_collection parameter""" + if not emoji_collection_dir: + return [] + + emoji_list = [] + + # Copy each image from input directory to build/assets directory + for root, dirs, files in os.walk(emoji_collection_dir): + for file in files: + if file.lower().endswith(('.png', '.gif')): + # Copy file + src_file = os.path.join(root, file) + dst_file = os.path.join(assets_dir, file) + copy_file(src_file, dst_file) + + # Get filename without extension + filename_without_ext = os.path.splitext(file)[0] + + # Add to emoji list + emoji_list.append({ + "name": filename_without_ext, + "file": file + }) + + return emoji_list + +def load_emoji_config(emoji_collection_dir): + """Load emoji config from config.json file""" + config_path = os.path.join(emoji_collection_dir, "emote.json") + if not os.path.exists(config_path): + print(f"Warning: Config file not found: {config_path}") + return {} + + try: + with open(config_path, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + # Convert list format to dict for easy lookup + config_dict = {} + for item in config_data: + if "emote" in item: + config_dict[item["emote"]] = item + + return config_dict + except Exception as e: + print(f"Error loading config file {config_path}: {e}") + return {} + +def process_board_emoji_collection(emoji_collection_dir, target_board_dir, assets_dir): + """Process emoji_collection parameter""" + if not emoji_collection_dir: + return [] + + emoji_config = load_emoji_config(target_board_dir) + print(f"Loaded emoji config with {len(emoji_config)} entries") + + emoji_list = [] + + for emote_name, config in emoji_config.items(): + + if "src" not in config: + print(f"Error: No src field found for emote '{emote_name}' in config") + continue + + eaf_file_path = os.path.join(emoji_collection_dir, config["src"]) + file_exists = os.path.exists(eaf_file_path) + + if not file_exists: + print(f"Warning: EAF file not found for emote '{emote_name}': {eaf_file_path}") + else: + # Copy eaf file to assets directory + copy_file(eaf_file_path, os.path.join(assets_dir, config["src"])) + + # Create emoji entry with src as file (merge file and src) + emoji_entry = { + "name": emote_name, + "file": config["src"] # Use src as the actual file + } + + eaf_properties = {} + + if not file_exists: + eaf_properties["lack"] = True + + if "loop" in config: + eaf_properties["loop"] = config["loop"] + + if "fps" in config: + eaf_properties["fps"] = config["fps"] + + if eaf_properties: + emoji_entry["eaf"] = eaf_properties + + status = "MISSING" if not file_exists else "OK" + eaf_info = emoji_entry.get('eaf', {}) + print(f"emote '{emote_name}': file='{emoji_entry['file']}', status={status}, lack={eaf_info.get('lack', False)}, loop={eaf_info.get('loop', 'none')}, fps={eaf_info.get('fps', 'none')}") + + emoji_list.append(emoji_entry) + + print(f"Successfully processed {len(emoji_list)} emotes from config") + return emoji_list + +def process_board_icon_collection(icon_collection_dir, assets_dir): + """Process emoji_collection parameter""" + if not icon_collection_dir: + return [] + + icon_list = [] + + for root, dirs, files in os.walk(icon_collection_dir): + for file in files: + if file.lower().endswith(('.bin')) or file.lower() == 'listen.eaf': + src_file = os.path.join(root, file) + dst_file = os.path.join(assets_dir, file) + copy_file(src_file, dst_file) + + filename_without_ext = os.path.splitext(file)[0] + + icon_list.append({ + "name": filename_without_ext, + "file": file + }) + + return icon_list +def process_board_layout(layout_json_file, assets_dir): + """Process layout_json parameter""" + if not layout_json_file: + print(f"Warning: Layout json file not provided") + return [] + + print(f"Processing layout_json: {layout_json_file}") + print(f"assets_dir: {assets_dir}") + + if os.path.isdir(layout_json_file): + layout_json_path = os.path.join(layout_json_file, "layout.json") + if not os.path.exists(layout_json_path): + print(f"Warning: layout.json not found in directory: {layout_json_file}") + return [] + layout_json_file = layout_json_path + elif not os.path.isfile(layout_json_file): + print(f"Warning: Layout json file not found: {layout_json_file}") + return [] + + try: + with open(layout_json_file, 'r', encoding='utf-8') as f: + layout_data = json.load(f) + + # Layout data is now directly an array, no need to get "layout" key + layout_items = layout_data if isinstance(layout_data, list) else layout_data.get("layout", []) + + processed_layout = [] + for item in layout_items: + processed_item = { + "name": item.get("name", ""), + "align": item.get("align", ""), + "x": item.get("x", 0), + "y": item.get("y", 0) + } + + if "width" in item: + processed_item["width"] = item["width"] + if "height" in item: + processed_item["height"] = item["height"] + + processed_layout.append(processed_item) + + print(f"Processed {len(processed_layout)} layout elements") + return processed_layout + + except Exception as e: + print(f"Error reading/processing layout.json: {e}") + return [] + +def process_board_collection(target_board_dir, res_path, assets_dir): + """Process board collection - merge icon, emoji, and layout processing""" + + # Process all collections + if os.path.exists(res_path) and os.path.exists(target_board_dir): + emoji_collection = process_board_emoji_collection(res_path, target_board_dir, assets_dir) + icon_collection = process_board_icon_collection(res_path, assets_dir) + layout_json = process_board_layout(target_board_dir, assets_dir) + else: + print(f"Warning: EAF directory not found: {res_path} or {target_board_dir}") + emoji_collection = [] + icon_collection = [] + layout_json = [] + + return emoji_collection, icon_collection, layout_json + +def generate_index_json(assets_dir, srmodels, text_font, emoji_collection, icon_collection, layout_json): + """Generate index.json file""" + index_data = { + "version": 1 + } + + if srmodels: + index_data["srmodels"] = srmodels + + if text_font: + index_data["text_font"] = text_font + + if emoji_collection: + index_data["emoji_collection"] = emoji_collection + + if icon_collection: + index_data["icon_collection"] = icon_collection + + if layout_json: + index_data["layout"] = layout_json + + # Write index.json + index_path = os.path.join(assets_dir, "index.json") + with open(index_path, 'w', encoding='utf-8') as f: + json.dump(index_data, f, indent=4, ensure_ascii=False) + + print(f"Generated: {index_path}") + + +def generate_config_json(build_dir, assets_dir): + """Generate config.json file""" + # Get absolute path of current working directory + workspace_dir = os.path.abspath(os.path.join(os.path.dirname(__file__))) + + config_data = { + "include_path": os.path.join(workspace_dir, "build/include"), + "assets_path": os.path.join(workspace_dir, "build/assets"), + "image_file": os.path.join(workspace_dir, "build/output/assets.bin"), + "lvgl_ver": "9.3.0", + "assets_size": "0x400000", + "support_format": ".png, .gif, .jpg, .bin, .json, .eaf", + "name_length": "32", + "split_height": "0", + "support_qoi": False, + "support_spng": False, + "support_sjpg": False, + "support_sqoi": False, + "support_raw": False, + "support_raw_dither": False, + "support_raw_bgr": False + } + + # Write config.json + config_path = os.path.join(build_dir, "config.json") + with open(config_path, 'w', encoding='utf-8') as f: + json.dump(config_data, f, indent=4, ensure_ascii=False) + + print(f"Generated: {config_path}") + return config_path + + +def main(): + parser = argparse.ArgumentParser(description='Build the spiffs assets partition') + parser.add_argument('--wakenet_model', help='Path to wakenet model directory') + parser.add_argument('--text_font', help='Path to text font file') + parser.add_argument('--emoji_collection', help='Path to emoji collection directory') + + parser.add_argument('--res_path', help='Path to res directory') + parser.add_argument('--target_board', help='Path to target board directory') + + args = parser.parse_args() + + # Get script directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + + # Set directory paths + build_dir = os.path.join(script_dir, "build") + assets_dir = os.path.join(build_dir, "assets") + if os.path.exists(assets_dir): + shutil.rmtree(assets_dir) + + # Ensure directories exist + ensure_dir(build_dir) + ensure_dir(assets_dir) + + print("Starting to build SPIFFS assets partition...") + + # Process each parameter + srmodels = process_wakenet_model(args.wakenet_model, build_dir, assets_dir) + text_font = process_text_font(args.text_font, assets_dir) + + if(args.target_board): + emoji_collection, icon_collection, layout_json = process_board_collection(args.target_board, args.res_path, assets_dir) + else: + emoji_collection = process_emoji_collection(args.emoji_collection, assets_dir) + icon_collection = [] + layout_json = [] + + # Generate index.json + generate_index_json(assets_dir, srmodels, text_font, emoji_collection, icon_collection, layout_json) + + # Generate config.json + config_path = generate_config_json(build_dir, assets_dir) + + # Use spiffs_assets_gen.py to package final build/assets.bin + try: + subprocess.run([ + sys.executable, "spiffs_assets_gen.py", + "--config", config_path + ], check=True, cwd=script_dir) + print("Successfully packaged assets.bin") + except subprocess.CalledProcessError as e: + print(f"Error: Failed to package assets.bin: {e}") + sys.exit(1) + + # Copy build/output/assets.bin to build/assets.bin + shutil.copy(os.path.join(build_dir, "output", "assets.bin"), os.path.join(build_dir, "assets.bin")) + print("Build completed!") + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/spiffs_assets/build_all.py b/scripts/spiffs_assets/build_all.py new file mode 100644 index 0000000..33efbbc --- /dev/null +++ b/scripts/spiffs_assets/build_all.py @@ -0,0 +1,148 @@ +#!/usr/bin/env python3 +""" +Build multiple spiffs assets partitions with different parameter combinations + +This script calls build.py with different combinations of: +- wakenet_models +- text_fonts +- emoji_collections + +And generates assets.bin files with names like: +wn9_nihaoxiaozhi_tts-font_puhui_common_20_4-emojis_32.bin +""" + +import os +import sys +import shutil +import subprocess + + +def ensure_dir(directory): + """Ensure directory exists, create if not""" + os.makedirs(directory, exist_ok=True) + + +def get_file_path(base_dir, filename): + """Get full path for a file, handling 'none' case""" + if filename == "none": + return None + return os.path.join(base_dir, f"{filename}.bin" if not filename.startswith("emojis_") else filename) + + +def build_assets(wakenet_model, text_font, emoji_collection, build_dir, final_dir): + """Build assets.bin using build.py with given parameters""" + + # Prepare arguments for build.py + cmd = [sys.executable, "build.py"] + + if wakenet_model != "none": + wakenet_path = os.path.join("../../managed_components/espressif__esp-sr/model/wakenet_model", wakenet_model) + cmd.extend(["--wakenet_model", wakenet_path]) + + if text_font != "none": + text_font_path = os.path.join("../../components/78__xiaozhi-fonts/cbin", f"{text_font}.bin") + cmd.extend(["--text_font", text_font_path]) + + if emoji_collection != "none": + emoji_path = os.path.join("../../components/xiaozhi-fonts/build", emoji_collection) + cmd.extend(["--emoji_collection", emoji_path]) + + print(f"\n正在构建: {wakenet_model}-{text_font}-{emoji_collection}") + print(f"执行命令: {' '.join(cmd)}") + + try: + # Run build.py + result = subprocess.run(cmd, check=True, cwd=os.path.dirname(__file__)) + + # Generate output filename + output_name = f"{wakenet_model}-{text_font}-{emoji_collection}.bin" + + # Copy generated assets.bin to final directory with new name + src_path = os.path.join(build_dir, "assets.bin") + dst_path = os.path.join(final_dir, output_name) + + if os.path.exists(src_path): + shutil.copy2(src_path, dst_path) + print(f"✓ 成功生成: {output_name}") + return True + else: + print(f"✗ 错误: 未找到生成的 assets.bin 文件") + return False + + except subprocess.CalledProcessError as e: + print(f"✗ 构建失败: {e}") + return False + except Exception as e: + print(f"✗ 未知错误: {e}") + return False + + +def main(): + # Configuration + wakenet_models = [ + "none", + "wn9_nihaoxiaozhi_tts", + "wn9s_nihaoxiaozhi" + ] + + text_fonts = [ + "none", + "font_puhui_common_14_1", + "font_puhui_common_16_4", + "font_puhui_common_20_4", + "font_puhui_common_30_4", + ] + + emoji_collections = [ + "none", + "emojis_32", + "emojis_64", + ] + + # Get script directory + script_dir = os.path.dirname(os.path.abspath(__file__)) + + # Set directory paths + build_dir = os.path.join(script_dir, "build") + final_dir = os.path.join(build_dir, "final") + + # Ensure directories exist + ensure_dir(build_dir) + ensure_dir(final_dir) + + print("开始构建多个 SPIFFS assets 分区...") + print(f"输出目录: {final_dir}") + + # Calculate total combinations + total_combinations = len(wakenet_models) * len(text_fonts) * len(emoji_collections) + + # Track successful builds + successful_builds = 0 + + # Build all combinations with emoji_collections + for wakenet_model in wakenet_models: + for text_font in text_fonts: + for emoji_collection in emoji_collections: + if build_assets(wakenet_model, text_font, emoji_collection, build_dir, final_dir): + successful_builds += 1 + + print(f"\n构建完成!") + print(f"成功构建: {successful_builds}/{total_combinations}") + print(f"输出文件位置: {final_dir}") + + # List generated files + if os.path.exists(final_dir): + files = [f for f in os.listdir(final_dir) if f.endswith('.bin')] + if files: + print("\n生成的文件:") + for file in sorted(files): + file_size = os.path.getsize(os.path.join(final_dir, file)) + print(f" {file} ({file_size:,} bytes)") + else: + print("\n未找到生成的 .bin 文件") + + +if __name__ == "__main__": + main() + + diff --git a/scripts/spiffs_assets/pack_model.py b/scripts/spiffs_assets/pack_model.py new file mode 100644 index 0000000..1fc85aa --- /dev/null +++ b/scripts/spiffs_assets/pack_model.py @@ -0,0 +1,123 @@ +import os +import struct +import argparse + + +def struct_pack_string(string, max_len=None): + """ + pack string to binary data. + if max_len is None, max_len = len(string) + 1 + else len(string) < max_len, the left will be padded by struct.pack('x') + + string: input python string + max_len: output + """ + + if max_len == None : + max_len = len(string) + else: + assert len(string) <= max_len + + left_num = max_len - len(string) + out_bytes = None + for char in string: + if out_bytes == None: + out_bytes = struct.pack('b', ord(char)) + else: + out_bytes += struct.pack('b', ord(char)) + for i in range(left_num): + out_bytes += struct.pack('x') + return out_bytes + +def read_data(filename): + """ + Read binary data, like index and mndata + """ + data = None + with open(filename, "rb") as f: + data = f.read() + return data + +def pack_models(model_path, out_file="srmodels.bin"): + """ + Pack all models into one binary file by the following format: + { + model_num: int + model1_info: model_info_t + model2_info: model_info_t + ... + model1_index,model1_data,model1_MODEL_INFO + model1_index,model1_data,model1_MODEL_INFO + ... + }model_pack_t + + { + model_name: char[32] + file_number: int + file1_name: char[32] + file1_start: int + file1_len: int + file2_name: char[32] + file2_start: int // data_len = info_start - data_start + file2_len: int + ... + }model_info_t + + model_path: the path of models + out_file: the ouput binary filename + """ + + models = {} + file_num = 0 + model_num = 0 + for root, dirs, _ in os.walk(model_path): + for model_name in dirs: + models[model_name] = {} + model_dir = os.path.join(root, model_name) + model_num += 1 + for _, _, files in os.walk(model_dir): + for file_name in files: + file_num += 1 + file_path = os.path.join(model_dir, file_name) + models[model_name][file_name] = read_data(file_path) + + model_num = len(models) + header_len = 4 + model_num*(32+4) + file_num*(32+4+4) + out_bin = struct.pack('I', model_num) # model number + data_bin = None + for key in models: + model_bin = struct_pack_string(key, 32) # + model name + model_bin += struct.pack('I', len(models[key])) # + file number in this model + + for file_name in models[key]: + model_bin += struct_pack_string(file_name, 32) # + file name + if data_bin == None: + model_bin += struct.pack('I', header_len) + data_bin = models[key][file_name] + model_bin += struct.pack('I', len(models[key][file_name])) + # print(file_name, header_len, len(models[key][file_name]), len(data_bin)) + else: + model_bin += struct.pack('I', header_len+len(data_bin)) + # print(file_name, header_len+len(data_bin), len(models[key][file_name])) + data_bin += models[key][file_name] + model_bin += struct.pack('I', len(models[key][file_name])) + + out_bin += model_bin + assert len(out_bin) == header_len + if data_bin != None: + out_bin += data_bin + + out_file = os.path.join(model_path, out_file) + with open(out_file, "wb") as f: + f.write(out_bin) + + +if __name__ == "__main__": + # input parameter + parser = argparse.ArgumentParser(description='Model package tool') + parser.add_argument('-m', '--model_path', help="the path of model files") + parser.add_argument('-o', '--out_file', default="srmodels.bin", help="the path of binary file") + args = parser.parse_args() + + # convert(args.model_path, args.out_file) + pack_models(model_path=args.model_path, out_file=args.out_file) diff --git a/scripts/spiffs_assets/spiffs_assets_gen.py b/scripts/spiffs_assets/spiffs_assets_gen.py new file mode 100644 index 0000000..e07aaaa --- /dev/null +++ b/scripts/spiffs_assets/spiffs_assets_gen.py @@ -0,0 +1,647 @@ +# SPDX-FileCopyrightText: 2024-2025 Espressif Systems (Shanghai) CO LTD +# SPDX-License-Identifier: Apache-2.0 +import io +import os +import argparse +import json +import shutil +import math +import sys +import time +import numpy as np +import importlib +import subprocess +import urllib.request + +from PIL import Image +from datetime import datetime +from dataclasses import dataclass +from typing import List +from pathlib import Path +from packaging import version + +sys.dont_write_bytecode = True + +GREEN = '\033[1;32m' +RED = '\033[1;31m' +RESET = '\033[0m' + +@dataclass +class AssetCopyConfig: + assets_path: str + target_path: str + spng_enable: bool + sjpg_enable: bool + qoi_enable: bool + sqoi_enable: bool + row_enable: bool + support_format: List[str] + split_height: int + +@dataclass +class PackModelsConfig: + target_path: str + include_path: str + image_file: str + assets_path: str + name_length: int + +def generate_header_filename(path): + asset_name = os.path.basename(path) + + header_filename = f'mmap_generate_{asset_name}.h' + return header_filename + +def compute_checksum(data): + checksum = sum(data) & 0xFFFF + return checksum + +def sort_key(filename): + basename, extension = os.path.splitext(filename) + return extension, basename + +def download_v8_script(convert_path): + """ + Ensure that the lvgl_image_converter repository is present at the specified path. + If not, clone the repository. Then, checkout to a specific commit. + + Parameters: + - convert_path (str): The directory path where lvgl_image_converter should be located. + """ + + # Check if convert_path is not empty + if convert_path: + # If the directory does not exist, create it and clone the repository + if not os.path.exists(convert_path): + os.makedirs(convert_path, exist_ok=True) + try: + subprocess.run( + ['git', 'clone', 'https://github.com/W-Mai/lvgl_image_converter.git', convert_path], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True + ) + except subprocess.CalledProcessError as e: + print(f'Git clone failed: {e}') + sys.exit(1) + + # Checkout to the specific commit + try: + subprocess.run( + ['git', 'checkout', '9174634e9dcc1b21a63668969406897aad650f35'], + cwd=convert_path, + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + check=True + ) + except subprocess.CalledProcessError as e: + print(f'Failed to checkout to the specific commit: {e}') + sys.exit(1) + else: + print('Error: convert_path is NULL') + sys.exit(1) + +def download_v9_script(url: str, destination: str) -> None: + """ + Download a Python script from a URL to a local destination. + + Parameters: + - url (str): URL to download the script from. + - destination (str): Local path to save the downloaded script. + + Raises: + - Exception: If the download fails. + """ + file_path = Path(destination) + + # Check if the file already exists + if file_path.exists(): + if file_path.is_file(): + return + + try: + # Create the parent directories if they do not exist + file_path.parent.mkdir(parents=True, exist_ok=True) + + # Open the URL and retrieve the data + with urllib.request.urlopen(url) as response, open(file_path, 'wb') as out_file: + data = response.read() # Read the entire response + out_file.write(data) # Write data to the local file + + except urllib.error.HTTPError as e: + print(f'HTTP Error: {e.code} - {e.reason} when accessing {url}') + sys.exit(1) + except urllib.error.URLError as e: + print(f'URL Error: {e.reason} when accessing {url}') + sys.exit(1) + except Exception as e: + print(f'An unexpected error occurred: {e}') + sys.exit(1) + +def split_image(im, block_size, input_dir, ext, convert_to_qoi): + """Splits the image into blocks based on the block size.""" + width, height = im.size + + if block_size: + splits = math.ceil(height / block_size) + else: + splits = 1 + + for i in range(splits): + if i < splits - 1: + crop = im.crop((0, i * block_size, width, (i + 1) * block_size)) + else: + crop = im.crop((0, i * block_size, width, height)) + + output_path = os.path.join(input_dir, str(i) + ext) + crop.save(output_path, quality=100) + + qoi_module = importlib.import_module('qoi-conv.qoi') + Qoi = qoi_module.Qoi + replace_extension = qoi_module.replace_extension + + if convert_to_qoi: + with Image.open(output_path) as img: + if img.mode != 'RGBA': + img = img.convert('RGBA') + + img_data = np.asarray(img) + out_path = qoi_module.replace_extension(output_path, 'qoi') + new_image = qoi_module.Qoi().save(out_path, img_data) + os.remove(output_path) + + + return width, height, splits + +def create_header(width, height, splits, split_height, lenbuf, ext): + """Creates the header for the output file based on the format.""" + header = bytearray() + + if ext.lower() == '.jpg': + header += bytearray('_SJPG__'.encode('UTF-8')) + elif ext.lower() == '.png': + header += bytearray('_SPNG__'.encode('UTF-8')) + elif ext.lower() == '.qoi': + header += bytearray('_SQOI__'.encode('UTF-8')) + + # 6 BYTES VERSION + header += bytearray(('\x00V1.00\x00').encode('UTF-8')) + + # WIDTH 2 BYTES + header += width.to_bytes(2, byteorder='little') + + # HEIGHT 2 BYTES + header += height.to_bytes(2, byteorder='little') + + # NUMBER OF ITEMS 2 BYTES + header += splits.to_bytes(2, byteorder='little') + + # SPLIT HEIGHT 2 BYTES + header += split_height.to_bytes(2, byteorder='little') + + for item_len in lenbuf: + # LENGTH 2 BYTES + header += item_len.to_bytes(2, byteorder='little') + + return header + +def save_image(output_file_path, header, split_data): + """Saves the image with the constructed header and split data.""" + with open(output_file_path, 'wb') as f: + if header is not None: + f.write(header + split_data) + else: + f.write(split_data) + +def handle_lvgl_version_v9(input_file: str, input_dir: str, + input_filename: str, convert_path: str) -> None: + """ + Handle conversion for LVGL versions greater than 9.0. + + Parameters: + - input_file (str): Path to the input image file. + - input_dir (str): Directory of the input file. + - input_filename (str): Name of the input file. + - convert_path (str): Path for conversion scripts and outputs. + """ + + convert_file = os.path.join(convert_path, 'LVGLImage.py') + lvgl_image_url = 'https://raw.githubusercontent.com/lvgl/lvgl/master/scripts/LVGLImage.py' + + download_v9_script(url=lvgl_image_url, destination=convert_file) + lvgl_script = Path(convert_file) + + cmd = [ + 'python', + str(lvgl_script), + '--ofmt', 'BIN', + '--cf', config_data['support_raw_cf'], + '--compress', 'NONE', + '--output', str(input_dir), + input_file + ] + + try: + result = subprocess.run( + cmd, + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True + ) + print(f'Completed {input_filename} -> BIN') + except subprocess.CalledProcessError as e: + print('An error occurred while executing LVGLImage.py:') + print(e.stderr) + sys.exit(e.returncode) + +def handle_lvgl_version_v8(input_file: str, input_dir: str, input_filename: str, convert_path: str) -> None: + """ + Handle conversion for supported LVGL versions (<= 9.0). + + Parameters: + - input_file (str): Path to the input image file. + - input_dir (str): Directory of the input file. + - input_filename (str): Name of the input file. + - convert_path (str): Path for conversion scripts and outputs. + """ + + download_v8_script(convert_path=convert_path) + + if convert_path not in sys.path: + sys.path.append(convert_path) + + try: + import lv_img_conv + except ImportError as e: + print(f"Failed to import 'lv_img_conv' from '{convert_path}': {e}") + sys.exit(1) + + try: + lv_img_conv.conv_one_file( + root=Path(input_dir), + filepath=Path(input_file), + f=config_data['support_raw_ff'], + cf=config_data['support_raw_cf'], + ff='BIN', + dither=config_data['support_raw_dither'], + bgr_mode=config_data['support_raw_bgr'], + ) + print(f'Completed {input_filename} -> BIN') + except KeyError as e: + print(f'Missing configuration key: {e}') + sys.exit(1) + except Exception as e: + print(f'An error occurred during conversion: {e}') + sys.exit(1) + +def process_image(input_file, height_str, output_extension, convert_to_qoi=False): + """Main function to process the image and save it as .sjpg, .spng, or .sqoi.""" + try: + SPLIT_HEIGHT = int(height_str) + if SPLIT_HEIGHT < 0: + raise ValueError('Height must be a positive integer') + except ValueError as e: + print('Error: Height must be a positive integer') + sys.exit(1) + + input_dir, input_filename = os.path.split(input_file) + base_filename, ext = os.path.splitext(input_filename) + OUTPUT_FILE_NAME = base_filename + + try: + im = Image.open(input_file) + except Exception as e: + print('Error:', e) + sys.exit(0) + + width, height, splits = split_image(im, SPLIT_HEIGHT, input_dir, ext, convert_to_qoi) + + split_data = bytearray() + lenbuf = [] + + if convert_to_qoi: + ext = '.qoi' + + for i in range(splits): + with open(os.path.join(input_dir, str(i) + ext), 'rb') as f: + a = f.read() + split_data += a + lenbuf.append(len(a)) + os.remove(os.path.join(input_dir, str(i) + ext)) + + header = None + if splits == 1 and convert_to_qoi: + output_file_path = os.path.join(input_dir, OUTPUT_FILE_NAME + ext) + else: + header = create_header(width, height, splits, SPLIT_HEIGHT, lenbuf, ext) + output_file_path = os.path.join(input_dir, OUTPUT_FILE_NAME + output_extension) + + save_image(output_file_path, header, split_data) + + print('Completed', input_filename, '->', os.path.basename(output_file_path)) + +def convert_image_to_qoi(input_file, height_str): + process_image(input_file, height_str, '.sqoi', convert_to_qoi=True) + +def convert_image_to_simg(input_file, height_str): + input_dir, input_filename = os.path.split(input_file) + _, ext = os.path.splitext(input_filename) + output_extension = '.sjpg' if ext.lower() == '.jpg' else '.spng' + process_image(input_file, height_str, output_extension, convert_to_qoi=False) + +def convert_image_to_raw(input_file: str) -> None: + """ + Convert an image to raw binary format compatible with LVGL. + + Parameters: + - input_file (str): Path to the input image file. + + Raises: + - FileNotFoundError: If required scripts are not found. + - subprocess.CalledProcessError: If the external conversion script fails. + - KeyError: If required keys are missing in config_data. + """ + input_dir, input_filename = os.path.split(input_file) + _, ext = os.path.splitext(input_filename) + convert_path = os.path.join(os.path.dirname(input_file), 'lvgl_image_converter') + lvgl_ver_str = config_data.get('lvgl_ver', '9.0.0') + + try: + lvgl_version = version.parse(lvgl_ver_str) + except version.InvalidVersion: + print(f'Invalid LVGL version format: {lvgl_ver_str}') + sys.exit(1) + + if lvgl_version >= version.parse('9.0.0'): + handle_lvgl_version_v9( + input_file=input_file, + input_dir=input_dir, + input_filename=input_filename, + convert_path=convert_path + ) + else: + handle_lvgl_version_v8( + input_file=input_file, + input_dir=input_dir, + input_filename=input_filename, + convert_path=convert_path + ) + +def pack_assets(config: PackModelsConfig): + """ + Pack models based on the provided configuration. + """ + + target_path = config.target_path + assets_include_path = config.include_path + out_file = config.image_file + assets_path = config.assets_path + max_name_len = config.name_length + + merged_data = bytearray() + file_info_list = [] + skip_files = ['config.json', 'lvgl_image_converter'] + + file_list = sorted(os.listdir(target_path), key=sort_key) + for filename in file_list: + if filename in skip_files: + continue + + file_path = os.path.join(target_path, filename) + file_name = os.path.basename(file_path) + file_size = os.path.getsize(file_path) + + try: + img = Image.open(file_path) + width, height = img.size + except Exception as e: + # print("Error:", e) + _, file_extension = os.path.splitext(file_path) + if file_extension.lower() in ['.sjpg', '.spng', '.sqoi']: + offset = 14 + with open(file_path, 'rb') as f: + f.seek(offset) + width_bytes = f.read(2) + height_bytes = f.read(2) + width = int.from_bytes(width_bytes, byteorder='little') + height = int.from_bytes(height_bytes, byteorder='little') + else: + width, height = 0, 0 + + file_info_list.append((file_name, len(merged_data), file_size, width, height)) + # Add 0x5A5A prefix to merged_data + merged_data.extend(b'\x5A' * 2) + + with open(file_path, 'rb') as bin_file: + bin_data = bin_file.read() + + merged_data.extend(bin_data) + + total_files = len(file_info_list) + + mmap_table = bytearray() + for file_name, offset, file_size, width, height in file_info_list: + if len(file_name) > int(max_name_len): + print(f'\033[1;33mWarn:\033[0m "{file_name}" exceeds {max_name_len} bytes and will be truncated.') + fixed_name = file_name.ljust(int(max_name_len), '\0')[:int(max_name_len)] + mmap_table.extend(fixed_name.encode('utf-8')) + mmap_table.extend(file_size.to_bytes(4, byteorder='little')) + mmap_table.extend(offset.to_bytes(4, byteorder='little')) + mmap_table.extend(width.to_bytes(2, byteorder='little')) + mmap_table.extend(height.to_bytes(2, byteorder='little')) + + combined_data = mmap_table + merged_data + combined_checksum = compute_checksum(combined_data) + combined_data_length = len(combined_data).to_bytes(4, byteorder='little') + header_data = total_files.to_bytes(4, byteorder='little') + combined_checksum.to_bytes(4, byteorder='little') + final_data = header_data + combined_data_length + combined_data + + with open(out_file, 'wb') as output_bin: + output_bin.write(final_data) + + os.makedirs(assets_include_path, exist_ok=True) + current_year = datetime.now().year + + asset_name = os.path.basename(assets_path) + file_path = os.path.join(assets_include_path, f'mmap_generate_{asset_name}.h') + with open(file_path, 'w') as output_header: + output_header.write('/*\n') + output_header.write(' * SPDX-FileCopyrightText: 2022-{} Espressif Systems (Shanghai) CO LTD\n'.format(current_year)) + output_header.write(' *\n') + output_header.write(' * SPDX-License-Identifier: Apache-2.0\n') + output_header.write(' */\n\n') + output_header.write('/**\n') + output_header.write(' * @file\n') + output_header.write(" * @brief This file was generated by esp_mmap_assets, don't modify it\n") + output_header.write(' */\n\n') + output_header.write('#pragma once\n\n') + output_header.write("#include \"esp_mmap_assets.h\"\n\n") + output_header.write(f'#define MMAP_{asset_name.upper()}_FILES {total_files}\n') + output_header.write(f'#define MMAP_{asset_name.upper()}_CHECKSUM 0x{combined_checksum:04X}\n\n') + output_header.write(f'enum MMAP_{asset_name.upper()}_LISTS {{\n') + + for i, (file_name, _, _, _, _) in enumerate(file_info_list): + enum_name = file_name.replace('.', '_') + output_header.write(f' MMAP_{asset_name.upper()}_{enum_name.upper()} = {i}, /*!< {file_name} */\n') + + output_header.write('};\n') + + print(f'All bin files have been merged into {os.path.basename(out_file)}') + +def copy_assets(config: AssetCopyConfig): + """ + Copy assets to target_path based on the provided configuration. + """ + format_tuple = tuple(config.support_format) + assets_path = config.assets_path + target_path = config.target_path + + for filename in os.listdir(assets_path): + if any(filename.endswith(suffix) for suffix in format_tuple): + source_file = os.path.join(assets_path, filename) + target_file = os.path.join(target_path, filename) + shutil.copyfile(source_file, target_file) + + conversion_map = { + '.jpg': [ + (config.sjpg_enable, convert_image_to_simg), + (config.qoi_enable, convert_image_to_qoi), + ], + '.png': [ + (config.spng_enable, convert_image_to_simg), + (config.qoi_enable, convert_image_to_qoi), + ], + } + + file_ext = os.path.splitext(filename)[1].lower() + conversions = conversion_map.get(file_ext, []) + converted = False + + for enable_flag, convert_func in conversions: + if enable_flag: + convert_func(target_file, config.split_height) + os.remove(target_file) + converted = True + break + + if not converted and config.row_enable: + convert_image_to_raw(target_file) + os.remove(target_file) + else: + print(f'No match found for file: {filename}, format_tuple: {format_tuple}') + +def process_assets_build(config_data): + assets_path = config_data['assets_path'] + image_file = config_data['image_file'] + target_path = os.path.dirname(image_file) + include_path = config_data['include_path'] + name_length = config_data['name_length'] + split_height = config_data['split_height'] + support_format = [fmt.strip() for fmt in config_data['support_format'].split(',')] + + copy_config = AssetCopyConfig( + assets_path=assets_path, + target_path=target_path, + spng_enable=config_data['support_spng'], + sjpg_enable=config_data['support_sjpg'], + qoi_enable=config_data['support_qoi'], + sqoi_enable=config_data['support_sqoi'], + row_enable=config_data['support_raw'], + support_format=support_format, + split_height=split_height + ) + + pack_config = PackModelsConfig( + target_path=target_path, + include_path=include_path, + image_file=image_file, + assets_path=assets_path, + name_length=name_length + ) + + print('--support_format:', support_format) + + if '.jpg' in support_format or '.png' in support_format: + print('--support_spng:', copy_config.spng_enable) + print('--support_sjpg:', copy_config.sjpg_enable) + print('--support_qoi:', copy_config.qoi_enable) + print('--support_raw:', copy_config.row_enable) + + if copy_config.sqoi_enable: + print('--support_sqoi:', copy_config.sqoi_enable) + if copy_config.spng_enable or copy_config.sjpg_enable or copy_config.sqoi_enable: + print('--split_height:', copy_config.split_height) + if copy_config.row_enable: + print('--lvgl_version:', config_data['lvgl_ver']) + + if not os.path.exists(target_path): + os.makedirs(target_path, exist_ok=True) + for filename in os.listdir(target_path): + file_path = os.path.join(target_path, filename) + if os.path.isfile(file_path) or os.path.islink(file_path): + os.unlink(file_path) + elif os.path.isdir(file_path): + shutil.rmtree(file_path) + + copy_assets(copy_config) + pack_assets(pack_config) + + total_size = os.path.getsize(os.path.join(target_path, image_file)) + recommended_size = math.ceil(total_size / 1024) + partition_size = math.ceil(int(config_data['assets_size'], 16)) + + print(f'{"Total size:":<30} {GREEN}{total_size / 1024:>8.2f}K ({total_size}){RESET}') + print(f'{"Partition size:":<30} {GREEN}{partition_size / 1024:>8.2f}K ({partition_size}){RESET}') + + if int(config_data['assets_size'], 16) <= total_size: + print(f'Recommended partition size: {GREEN}{recommended_size}K{RESET}') + print(f'{RED}Error:Binary size exceeds partition size.{RESET}') + sys.exit(1) + +def process_assets_merge(config_data): + app_bin_path = config_data['app_bin_path'] + image_file = config_data['image_file'] + target_path = os.path.dirname(image_file) + + combined_bin_path = os.path.join(target_path, 'combined.bin') + append_bin_path = os.path.join(target_path, image_file) + + app_size = os.path.getsize(app_bin_path) + asset_size = os.path.getsize(append_bin_path) + total_size = asset_size + app_size + recommended_size = math.ceil(total_size / 1024) + partition_size = math.ceil(int(config_data['assets_size'], 16)) + + print(f'{"Asset size:":<30} {GREEN}{asset_size / 1024:>8.2f}K ({asset_size}){RESET}') + print(f'{"App size:":<30} {GREEN}{app_size / 1024:>8.2f}K ({app_size}){RESET}') + print(f'{"Total size:":<30} {GREEN}{total_size / 1024:>8.2f}K ({total_size}){RESET}') + print(f'{"Partition size:":<30} {GREEN}{partition_size / 1024:>8.2f}K ({partition_size}){RESET}') + + if total_size > partition_size: + print(f'Recommended partition size: {GREEN}{recommended_size}K{RESET}') + print(f'{RED}Error:Binary size exceeds partition size.{RESET}') + sys.exit(1) + + with open(combined_bin_path, 'wb') as combined_bin: + with open(app_bin_path, 'rb') as app_bin: + combined_bin.write(app_bin.read()) + with open(append_bin_path, 'rb') as img_bin: + combined_bin.write(img_bin.read()) + + shutil.move(combined_bin_path, app_bin_path) + print(f'Append bin created: {os.path.basename(app_bin_path)}') + +if __name__ == '__main__': + parser = argparse.ArgumentParser(description='Move and Pack assets.') + parser.add_argument('--config', required=True, help='Path to the configuration file') + parser.add_argument('--merge', action='store_true', help='Merge assets with app binary') + args = parser.parse_args() + + with open(args.config, 'r') as f: + config_data = json.load(f) + + if args.merge: + process_assets_merge(config_data) + else: + process_assets_build(config_data) diff --git a/scripts/versions.py b/scripts/versions.py new file mode 100644 index 0000000..ac9472c --- /dev/null +++ b/scripts/versions.py @@ -0,0 +1,250 @@ +#! /usr/bin/env python3 +from dotenv import load_dotenv +load_dotenv() + +import os +import struct +import zipfile +import oss2 +import json +import requests +from requests.exceptions import RequestException + +# 切换到项目根目录 +os.chdir(os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +def get_chip_id_string(chip_id): + return { + 0x0000: "esp32", + 0x0002: "esp32s2", + 0x0005: "esp32c3", + 0x0009: "esp32s3", + 0x000C: "esp32c2", + 0x000D: "esp32c6", + 0x0010: "esp32h2", + 0x0011: "esp32c5", + 0x0012: "esp32p4", + 0x0017: "esp32c5", + }[chip_id] + +def get_flash_size(flash_size): + MB = 1024 * 1024 + return { + 0x00: 1 * MB, + 0x01: 2 * MB, + 0x02: 4 * MB, + 0x03: 8 * MB, + 0x04: 16 * MB, + 0x05: 32 * MB, + 0x06: 64 * MB, + 0x07: 128 * MB, + }[flash_size] + +def get_app_desc(data): + magic = struct.unpack("> 4) + chip_id = get_chip_id_string(app_data[0xC]) + # get segments + segment_count = app_data[0x1] + segments = [] + offset = 0x18 + image_size = 0x18 + for i in range(segment_count): + segment_size = struct.unpack("