diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..ceaa7f62d5daa7acc85ba28320b00946103426db 100644 --- a/.gitattributes +++ b/.gitattributes @@ -33,3 +33,38 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text *.zip filter=lfs diff=lfs merge=lfs -text *.zst filter=lfs diff=lfs merge=lfs -text *tfevents* filter=lfs diff=lfs merge=lfs -text +assets/emojis/angry.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/confident.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/confused.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/cool.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/crying.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/delicious.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/embarrassed.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/funny.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/happy.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/kissy.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/laughing.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/loving.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/neutral.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/relaxed.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/sad.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/shocked.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/silly.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/sleepy.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/surprised.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/thinking.gif filter=lfs diff=lfs merge=lfs -text +assets/emojis/winking.gif filter=lfs diff=lfs merge=lfs -text +documents/docs/ecosystem/projects/open-xiaoai/images/logo.png filter=lfs diff=lfs merge=lfs -text +documents/docs/ecosystem/projects/xiaozhi-android-client/images/界面2.jpg filter=lfs diff=lfs merge=lfs -text +documents/docs/ecosystem/projects/xiaozhi-unity/images/界面1.png filter=lfs diff=lfs merge=lfs -text +documents/docs/ecosystem/projects/xiaozhi-unity/images/界面2.png filter=lfs diff=lfs merge=lfs -text +documents/docs/ecosystem/projects/xiaozhi-unity/images/logo.png filter=lfs diff=lfs merge=lfs -text +libs/libopus/linux/arm64/libopus.so filter=lfs diff=lfs merge=lfs -text +libs/libopus/linux/x64/libopus.so filter=lfs diff=lfs merge=lfs -text +libs/libopus/mac/arm64/libopus.dylib filter=lfs diff=lfs merge=lfs -text +libs/libopus/mac/x64/libopus.dylib filter=lfs diff=lfs merge=lfs -text +libs/libopus/win/x86_64/opus.dll filter=lfs diff=lfs merge=lfs -text +libs/webrtc_apm/linux/x64/libwebrtc_apm.so filter=lfs diff=lfs merge=lfs -text +libs/webrtc_apm/mac/arm64/libwebrtc_apm.dylib filter=lfs diff=lfs merge=lfs -text +libs/webrtc_apm/mac/x64/libwebrtc_apm.dylib filter=lfs diff=lfs merge=lfs -text +libs/webrtc_apm/win/x86_64/libwebrtc_apm.dll filter=lfs diff=lfs merge=lfs -text diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000000000000000000000000000000000000..2ae40a6b1b0d0017b0f61f623227ab6adb8eb293 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,32 @@ +--- +name: Bug 报告(Bug Report) +about: 反馈项目中的缺陷或问题 +title: "[Bug] 简短描述问题" +labels: bug +assignees: '' +--- + +## 🐛 问题描述 + + +## 🔍 复现步骤 + +1. 打开 '...' +2. 点击 '...' +3. 滚动到 '...' +4. 看到错误 + +## 🤔 预期行为 + + +## 😯 截图 + + +## 🖥️ 环境信息 +- 操作系统: [例如 Windows 10] +- 项目版本: [例如 1.0.0] +- Python版本: [例如 3.9.13] +- Nodejs版本: [例如 v20.14.0] + +## 📋 其他信息 + diff --git a/.github/ISSUE_TEMPLATE/code_improvement.md b/.github/ISSUE_TEMPLATE/code_improvement.md new file mode 100644 index 0000000000000000000000000000000000000000..0ec0b145fe56ac2e40e9a894a3cad904b28578d4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/code_improvement.md @@ -0,0 +1,19 @@ +--- +name: 代码优化建议(Code Improvement) +about: 提出对现有代码的优化或改进建议 +title: "[Improvement] 简短描述改进内容" +labels: refactor +assignees: '' +--- + +## 💡 改进描述 + + +## 🌟 改进建议 + + +## 🛠️ 相关代码 + + +## 📋 其他信息 + diff --git a/.github/ISSUE_TEMPLATE/documentation_improvement.md b/.github/ISSUE_TEMPLATE/documentation_improvement.md new file mode 100644 index 0000000000000000000000000000000000000000..9cdc10e08874e1a67ae0dcfa2b18396ae139fd65 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/documentation_improvement.md @@ -0,0 +1,16 @@ +--- +name: 文档改进建议(Documentation Improvement) +about: 提出对项目文档的改进或补充建议 +title: "[Docs] 简短描述改进内容" +labels: documentation +assignees: '' +--- + +## 📚 改进描述 + + +## ✨ 改进建议 + + +## 📋 其他信息 + diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000000000000000000000000000000000000..df1c029d9492696720f90382bfe55b5671c6b8f8 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,19 @@ +--- +name: 功能请求(Feature Request) +about: 提出新的功能或改进建议 +title: "[Feature] 简短描述功能" +labels: enhancement +assignees: '' +--- + +## 🚀 需求描述 + + +## 🎯 解决方案 + + +## 📝 备选方案 + + +## 📋 其他信息 + diff --git a/.github/workflows/vitepress.yml b/.github/workflows/vitepress.yml new file mode 100644 index 0000000000000000000000000000000000000000..dd48e20b38c6bc5caa12abd329b710a4e0cd6671 --- /dev/null +++ b/.github/workflows/vitepress.yml @@ -0,0 +1,46 @@ +# workflow 名称,可以自定义 +name: Deploy GitHub Pages + +# 触发条件:在代码 push 到 master 分支后,自动执行该 workflow +on: + push: + branches: + - main + +# 任务 +jobs: + build-and-deploy: + # 服务器环境:最新版 Ubuntu,也可以自定义版本 + runs-on: ubuntu-latest + steps: + # 拉取代码 + - name: Checkout + uses: actions/checkout@v2 + with: + fetch-depth: 0 + + # 设置 Node.js 版本 + - name: Setup Node.js environment + uses: actions/setup-node@v1 + with: + node-version: "18.20.3" + # 安装yarn + - name: Install yarn + run: npm i yarn -g + + # 如果缓存没有命中,安装依赖 + - name: Install dependencies + run: cd documents && yarn install + + # 生成静态文件 + - name: Build + run: cd documents && yarn docs:build + + # 部署到 GitHub Pages + - name: Deploy + uses: crazy-max/ghaction-github-pages@v2 + env: + GITHUB_TOKEN: ${{ secrets.ACCESS_TOKEN }} # ACCESS_TOKEN 是创建的 Secret 名称,替换为你自己创建的名称 + with: + target-branch: gh-pages # 部署到 gh-pages 分支,master 分支存放的是项目源码,而 gh-pages 分支则用来存放生成的静态文件 + build_dir: documents/docs/.vitepress/dist # vuepress 生成的静态文件存放的地方 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..194822cbd17d7914f266ecdf82895c6252638b33 --- /dev/null +++ b/.gitignore @@ -0,0 +1,32 @@ +/logs +/config + +.venv +**/__pycache__/ + +.idea/ +venv/ + +# 音乐缓存 +cache/ + + +/models + +# 打包相关 +/build +/dist + +# MACOS +.DS_Store + +# IDE - VSCode +.vscode/* +!.vscode/settings.json +!.vscode/tasks.json +!.vscode/launch.json +!.vscode/extensions.json + +xiaozhi.spec + +/installer \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..8738d3b5d71497e283db285ddb653800c4a18230 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 Junsen + +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.en.md b/README.en.md new file mode 100644 index 0000000000000000000000000000000000000000..3c9b7334b2d3741c2a5cea3a0085dc3012399275 --- /dev/null +++ b/README.en.md @@ -0,0 +1,165 @@ +# py-xiaozhi +

+ + Release + + + License: MIT + + + Stars + + + Download + + + Gitee + +

+ +English | [简体中文](README.md) + +## Project Introduction +py-xiaozhi is a Python-based Xiaozhi voice client, designed to learn coding and experience AI voice interaction without hardware requirements. This repository is ported from [xiaozhi-esp32](https://github.com/78/xiaozhi-esp32). + +## Demo +- [Bilibili Demo Video](https://www.bilibili.com/video/BV1HmPjeSED2/#reply255921347937) + +![Image](https://github.com/user-attachments/assets/df8bd5d2-a8e6-4203-8084-46789fc8e9ad) + +## Features +- **AI Voice Interaction**: Supports voice input and recognition, enabling smart human-computer interaction with natural conversation flow. +- **Visual Multimodal**: Supports image recognition and processing, providing multimodal interaction capabilities and image content understanding. +- **IoT Device Integration**: Supports smart home device control, enabling more IoT functions and building a smart home ecosystem. +- **Online Music Playback**: Advanced Music Player: A high-performance music player built on Pygame, supporting play/pause/stop, progress control, lyric display, and local caching, delivering a more stable and smooth listening experience. +- **Voice Wake-up**: Supports wake word activation, eliminating manual operation (disabled by default, manual activation required). +- **Auto Dialogue Mode**: Implements continuous dialogue experience, enhancing user interaction fluidity. +- **Graphical Interface**: Provides intuitive GUI with Xiaozhi expressions and text display, enhancing visual experience. +- **Command Line Mode**: Supports CLI operation, suitable for embedded devices or environments without GUI. +- **Cross-platform Support**: Compatible with Windows 10+, macOS 10.15+, and Linux systems for use anywhere. +- **Volume Control**: Supports volume adjustment to adapt to different environmental requirements with unified sound control interface. +- **Session Management**: Effectively manages multi-turn dialogues to maintain interaction continuity. +- **Encrypted Audio Transmission**: Supports WSS protocol to ensure audio data security and prevent information leakage. +- **Automatic Verification Code Handling**: Automatically copies verification codes and opens browsers during first use, simplifying user operations. +- **Automatic MAC Address Acquisition**: Avoids MAC address conflicts and improves connection stability. +- **Modular Code**: Code is split and encapsulated into classes with clear responsibilities, facilitating secondary development. +- **Stability Optimization**: Fixes multiple issues including reconnection and cross-platform compatibility. + +## System Requirements +- Python version: 3.9 >= version <= 3.12 +- Supported operating systems: Windows 10+, macOS 10.15+, Linux +- Microphone and speaker devices + +## Read This First! +- Carefully read [项目文档](https://huangjunsen0406.github.io/py-xiaozhi/) for startup tutorials and file descriptions +- The main branch has the latest code; manually reinstall pip dependencies after each update to ensure you have new dependencies + +[Zero to Xiaozhi Client (Video Tutorial)](https://www.bilibili.com/video/BV1dWQhYEEmq/?vd_source=2065ec11f7577e7107a55bbdc3d12fce) + +## State Transition Diagram + +``` + +----------------+ + | | + v | ++------+ Wake/Button +------------+ | +------------+ +| IDLE | -----------> | CONNECTING | --+-> | LISTENING | ++------+ +------------+ +------------+ + ^ | + | | Voice Recognition Complete + | +------------+ v + +--------- | SPEAKING | <-----------------+ + Playback +------------+ + Complete +``` + +## Upcoming Features +- [ ] **New GUI (Electron)**: Provides a more modern and beautiful user interface, optimizing the interaction experience. + +## FAQ +- **Can't find audio device**: Please check if your microphone and speakers are properly connected and enabled. +- **Wake word not responding**: Check if the `USE_WAKE_WORD` setting in `config.json` is set to `true` and the model path is correct. +- **Network connection failure**: Check network settings and firewall configuration to ensure WebSocket or MQTT communication is not blocked. +- **Packaging failure**: Make sure PyInstaller is installed (`pip install pyinstaller`) and all dependencies are installed. Then re-execute `python scripts/build.py` + +## Related Third-party Open Source Projects +[Xiaozhi Mobile Client](https://github.com/TOM88812/xiaozhi-android-client) + +[xiaozhi-esp32-server (Open source server)](https://github.com/xinnan-tech/xiaozhi-esp32-server) + +[XiaoZhiAI_server32_Unity(Unity Development)](https://gitee.com/vw112266/XiaoZhiAI_server32_Unity) + +[IntelliConnect(Aiot Middleware)](https://github.com/ruanrongman/IntelliConnect) + +[open-xiaoai(Xiaoai Audio Access Xiaozhi)](https://github.com/idootop/open-xiaoai.git) + +## Related Branches +- main: Main branch +- feature/v1: First version +- feature/visual: Visual branch +- feature/raspberry_pi embedded device branch +## Project Structure + +``` +├── .github # GitHub related configurations +├── assets # Resource files (emotion animations, etc.) +├── cache # Cache directory (music and temporary files) +├── config # Configuration directory +├── documents # Documentation directory +├── hooks # PyInstaller hooks directory +├── libs # Dependencies directory +├── scripts # Utility scripts directory +├── src # Source code directory +│ ├── audio_codecs # Audio encoding/decoding module +│ ├── audio_processing # Audio processing module +│ ├── constants # Constants definition +│ ├── display # Display interface module +│ ├── iot # IoT device related module +│ │ └── things # Specific device implementation directory +│ ├── network # Network communication module +│ ├── protocols # Communication protocol module +│ └── utils # Utility classes module +``` + +## Contribution Guidelines +We welcome issue reports and code contributions. Please ensure you follow these specifications: + +1. Code style complies with PEP8 standards +2. PR submissions include appropriate tests +3. Update relevant documentation + +## Community and Support + +### Thanks to the Following Open Source Contributors +> In no particular order + +[Xiaoxia](https://github.com/78) +[zhh827](https://github.com/zhh827) +[SmartArduino-Li Honggang](https://github.com/SmartArduino) +[HonestQiao](https://github.com/HonestQiao) +[vonweller](https://github.com/vonweller) +[Sun Weigong](https://space.bilibili.com/416954647) +[isamu2025](https://github.com/isamu2025) +[Rain120](https://github.com/Rain120) +[kejily](https://github.com/kejily) +[Radio bilibili Jun](https://space.bilibili.com/119751) + +### Sponsorship Support + +
+

Thanks to All Sponsors ❤️

+

Whether it's API resources, device compatibility testing, or financial support, every contribution makes the project more complete

+ + + View Sponsors + + + Become a Sponsor + +
+ +## Project Statistics +[![Star History Chart](https://api.star-history.com/svg?repos=huangjunsen0406/py-xiaozhi&type=Date)](https://www.star-history.com/#huangjunsen0406/py-xiaozhi&Date) + +## License +[MIT License](LICENSE) \ No newline at end of file diff --git a/README.md b/README.md index b4010f42b68a6cb201d2dc01f68c314ee2f8fff6..0d64d0c65d04d9eccca7bc9b35f7368a79a95de3 100644 --- a/README.md +++ b/README.md @@ -1,12 +1,167 @@ ---- -title: Xiaozhi -emoji: 🌖 -colorFrom: blue -colorTo: blue -sdk: gradio -sdk_version: 5.29.0 -app_file: app.py -pinned: false ---- - -Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference +# py-xiaozhi +

+ + Release + + + License: MIT + + + Stars + + + Download + + + Gitee + +

+ + + +简体中文 | [English](README.en.md) + +## 项目简介 +py-xiaozhi 是一个使用 Python 实现的小智语音客户端,旨在通过代码学习和在没有硬件条件下体验 AI 小智的语音功能。 +本仓库是基于[xiaozhi-esp32](https://github.com/78/xiaozhi-esp32)移植 + +## 演示 +- [Bilibili 演示视频](https://www.bilibili.com/video/BV1HmPjeSED2/#reply255921347937) + +![Image](https://github.com/user-attachments/assets/df8bd5d2-a8e6-4203-8084-46789fc8e9ad) + +## 功能特点 +- **AI语音交互**:支持语音输入与识别,实现智能人机交互,提供自然流畅的对话体验。 +- **视觉多模态**:支持图像识别和处理,提供多模态交互能力,理解图像内容。 +- **IoT 设备集成**:支持智能家居设备控制,实现更多物联网功能,打造智能家居生态。 +- **联网音乐播放**:基于pygame实现的高性能音乐播放器,支持歌词显示和本地缓存,支持播放/暂停/停止、进度控制、歌词显示和本地缓存,提供更稳定的音乐播放体验。 +- **语音唤醒**:支持唤醒词激活交互,免去手动操作的烦恼(默认关闭需要手动开启)。 +- **自动对话模式**:实现连续对话体验,提升用户交互流畅度。 +- **图形化界面**:提供直观易用的 GUI,支持小智表情与文本显示,增强视觉体验。 +- **命令行模式**:支持 CLI 运行,适用于嵌入式设备或无 GUI 环境。 +- **跨平台支持**:兼容 Windows 10+、macOS 10.15+ 和 Linux 系统,随时随地使用。 +- **音量控制**:支持音量调节,适应不同环境需求,统一声音控制接口。 +- **会话管理**:有效管理多轮对话,保持交互的连续性。 +- **加密音频传输**:支持 WSS 协议,保障音频数据的安全性,防止信息泄露。 +- **自动验证码处理**:首次使用时,程序自动复制验证码并打开浏览器,简化用户操作。 +- **自动获取 MAC 地址**:避免 MAC 地址冲突,提高连接稳定性。 +- **代码模块化**:拆分代码并封装为类,职责分明,便于二次开发。 +- **稳定性优化**:修复多项问题,包括断线重连、跨平台兼容等。 + +## 系统要求 +- 3.9 >= Python版本 <= 3.12 +- 支持的操作系统:Windows 10+、macOS 10.15+、Linux +- 麦克风和扬声器设备 + +## 请先看这里! +- 仔细阅读 [项目文档](https://huangjunsen0406.github.io/py-xiaozhi/) 启动教程和文件说明都在里面了 +- main是最新代码,每次更新都需要手动重新安装一次pip依赖防止我新增依赖后你们本地没有 + +[从零开始使用小智客户端(视频教程)](https://www.bilibili.com/video/BV1dWQhYEEmq/?vd_source=2065ec11f7577e7107a55bbdc3d12fce) + +## 状态流转图 + +``` + +----------------+ + | | + v | ++------+ 唤醒词/按钮 +------------+ | +------------+ +| IDLE | -----------> | CONNECTING | --+-> | LISTENING | ++------+ +------------+ +------------+ + ^ | + | | 语音识别完成 + | +------------+ v + +--------- | SPEAKING | <-----------------+ + 完成播放 +------------+ +``` + +## 待实现功能 +- [ ] **新 GUI(Electron)**:提供更现代、美观的用户界面,优化交互体验。 + +## 常见问题 +- **找不到音频设备**:请检查麦克风和扬声器是否正常连接和启用。 +- **唤醒词不响应**:请检查`config.json`中的`USE_WAKE_WORD`设置是否为`true`,以及模型路径是否正确。 +- **网络连接失败**:请检查网络设置和防火墙配置,确保WebSocket或MQTT通信未被阻止。 +- **打包失败**:确保已安装PyInstaller (`pip install pyinstaller`),并且所有依赖项都已安装。然后重新执行`python scripts/build.py` + +## 相关第三方开源项目 +[小智手机端](https://github.com/TOM88812/xiaozhi-android-client) + +[xiaozhi-esp32-server(开源服务端)](https://github.com/xinnan-tech/xiaozhi-esp32-server) + +[XiaoZhiAI_server32_Unity(Unity开发)](https://gitee.com/vw112266/XiaoZhiAI_server32_Unity) + +[IntelliConnect(Aiot中间件)](https://github.com/ruanrongman/IntelliConnect) + +[open-xiaoai(小爱音响接入小智)](https://github.com/idootop/open-xiaoai.git) + +## 相关分支 +- main 主分支 +- feature/v1 第一个版本 +- feature/visual 视觉分支 +- feature/raspberry_pi 嵌入式设备分支 +## 项目结构 + +``` +├── .github # GitHub 相关配置 +├── assets # 资源文件(表情动画等) +├── cache # 缓存目录(音乐等临时文件) +├── config # 配置文件目录 +├── documents # 文档目录 +├── hooks # PyInstaller钩子目录 +├── libs # 依赖库目录 +├── scripts # 实用脚本目录 +├── src # 源代码目录 +│ ├── audio_codecs # 音频编解码模块 +│ ├── audio_processing # 音频处理模块 +│ ├── constants # 常量定义 +│ ├── display # 显示界面模块 +│ ├── iot # IoT设备相关模块 +│ │ └── things # 具体设备实现目录 +│ ├── network # 网络通信模块 +│ ├── protocols # 通信协议模块 +│ └── utils # 工具类模块 +``` + +## 贡献指南 +欢迎提交问题报告和代码贡献。请确保遵循以下规范: + +1. 代码风格符合PEP8规范 +2. 提交的PR包含适当的测试 +3. 更新相关文档 + +## 社区与支持 + +### 感谢以下开源人员 +> 排名不分前后 + +[Xiaoxia](https://github.com/78) +[zhh827](https://github.com/zhh827) +[四博智联-李洪刚](https://github.com/SmartArduino) +[HonestQiao](https://github.com/HonestQiao) +[vonweller](https://github.com/vonweller) +[孙卫公](https://space.bilibili.com/416954647) +[isamu2025](https://github.com/isamu2025) +[Rain120](https://github.com/Rain120) +[kejily](https://github.com/kejily) +[电波bilibili君](https://space.bilibili.com/119751) + +### 赞助支持 + +
+

感谢所有赞助者的支持 ❤️

+

无论是接口资源、设备兼容测试还是资金支持,每一份帮助都让项目更加完善

+ + + 赞助者名单 + + + 成为赞助者 + +
+ +## 项目统计 +[![Star History Chart](https://api.star-history.com/svg?repos=huangjunsen0406/py-xiaozhi&type=Date)](https://www.star-history.com/#huangjunsen0406/py-xiaozhi&Date) + +## 许可证 +[MIT License](LICENSE) \ No newline at end of file diff --git a/assets/emojis/angry.gif b/assets/emojis/angry.gif new file mode 100644 index 0000000000000000000000000000000000000000..228938bf496ee8bf7e621e3458e50fc92d00d379 --- /dev/null +++ b/assets/emojis/angry.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0673bb640a15a1fb5448239cd1047604d8eed5418811b019befceb4082f8f5c2 +size 498671 diff --git a/assets/emojis/confident.gif b/assets/emojis/confident.gif new file mode 100644 index 0000000000000000000000000000000000000000..47c92ff02a045fbbf64a2c4c0efcef171ffe6ff1 --- /dev/null +++ b/assets/emojis/confident.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:feb116bf68b916d9ec1f670fa9245a83b1ff7daf3e3e7753874b036a7addf738 +size 576820 diff --git a/assets/emojis/confused.gif b/assets/emojis/confused.gif new file mode 100644 index 0000000000000000000000000000000000000000..7bcf4b9e0ab710eb338075d41fa91769bb74e178 --- /dev/null +++ b/assets/emojis/confused.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b5476b3bdeafd0f8ddf6127ddcf441515fdbe2489556d8b02185671e7ca0656c +size 1194403 diff --git a/assets/emojis/cool.gif b/assets/emojis/cool.gif new file mode 100644 index 0000000000000000000000000000000000000000..8fbcedbb0793c1812ab96286bb9dbea17fd46555 --- /dev/null +++ b/assets/emojis/cool.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1c9fef50fe50983698b64527baecf69c335507454388c70ee6c36be254ecf553 +size 657735 diff --git a/assets/emojis/crying.gif b/assets/emojis/crying.gif new file mode 100644 index 0000000000000000000000000000000000000000..7522673eebf11ddb1f5c77e7db75b85c2ec01689 --- /dev/null +++ b/assets/emojis/crying.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c249821d6147d05e6b9c8af18ecee746e2028afa675f9dc7be978ea343c9c4fc +size 707618 diff --git a/assets/emojis/delicious.gif b/assets/emojis/delicious.gif new file mode 100644 index 0000000000000000000000000000000000000000..b1b50a0082d4a286f61acf50d4ff2427980577bb --- /dev/null +++ b/assets/emojis/delicious.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ca598acddccb07884886f2454cf1eedef63d0b7aa38ef1d47ceff8e43f2c3871 +size 867317 diff --git a/assets/emojis/embarrassed.gif b/assets/emojis/embarrassed.gif new file mode 100644 index 0000000000000000000000000000000000000000..11fdb305b8be48697b8de2607df71c2a6607adbf --- /dev/null +++ b/assets/emojis/embarrassed.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0c74ed655cbd57f03c6408207a8ca5bcf3d02b0e1bb57154abb6399613b6b5e6 +size 667139 diff --git a/assets/emojis/funny.gif b/assets/emojis/funny.gif new file mode 100644 index 0000000000000000000000000000000000000000..a8a082e4e7f3fd9a34f9c5b734cd250ddb984d4e --- /dev/null +++ b/assets/emojis/funny.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c068683ecc25b26faf2ef872cb3173e332f37b2e85c5741dce83516ed3ac1569 +size 727898 diff --git a/assets/emojis/happy.gif b/assets/emojis/happy.gif new file mode 100644 index 0000000000000000000000000000000000000000..1536fc22e2e8db2bf48b117e70f78fa17483003d --- /dev/null +++ b/assets/emojis/happy.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c9e7ac3295397cd4d346cc73c75074eef8c6f9f7eb0928ed87962a9310124635 +size 585246 diff --git a/assets/emojis/kissy.gif b/assets/emojis/kissy.gif new file mode 100644 index 0000000000000000000000000000000000000000..fde22e9d7d549b9a4a08603d054d6f174d23cf14 --- /dev/null +++ b/assets/emojis/kissy.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0789f325a125615f1939f1fea14b99ed75891e2c80c2705a9829312d29105d55 +size 676284 diff --git a/assets/emojis/laughing.gif b/assets/emojis/laughing.gif new file mode 100644 index 0000000000000000000000000000000000000000..1a20a446b2cf16bf04a54dc89bc87c894129d413 --- /dev/null +++ b/assets/emojis/laughing.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:64df463cf84ffe760d411a1e93362562eafc0a15a24782e638ca157b9012d22a +size 1208922 diff --git a/assets/emojis/loving.gif b/assets/emojis/loving.gif new file mode 100644 index 0000000000000000000000000000000000000000..5d0662dc3b098494a0e9bb8d7f8a36719ce1b431 --- /dev/null +++ b/assets/emojis/loving.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:82d76ff36a0b703af760e29e6c20cd9b1c943d7f8811536d3a12b1add5bc0b99 +size 586823 diff --git a/assets/emojis/neutral.gif b/assets/emojis/neutral.gif new file mode 100644 index 0000000000000000000000000000000000000000..1c8a41b25809e81b2a7f453113e0e06a26fc385d --- /dev/null +++ b/assets/emojis/neutral.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:bfa6e2b4f53a1e02325c151689701a119dfaf450640b5b49d869c7058fff7220 +size 200775 diff --git a/assets/emojis/relaxed.gif b/assets/emojis/relaxed.gif new file mode 100644 index 0000000000000000000000000000000000000000..2c386bbf51e515214e91a44ad80deae282683826 --- /dev/null +++ b/assets/emojis/relaxed.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8bd404ff2356f27cb46ccc664de64dae7d2e2892db50f8f96eb4722d2d7a46b8 +size 922969 diff --git a/assets/emojis/sad.gif b/assets/emojis/sad.gif new file mode 100644 index 0000000000000000000000000000000000000000..2f185e353062882eecf6a85c45c87f95c1ee2569 --- /dev/null +++ b/assets/emojis/sad.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15561422b976be6036bb3d2f6901cfe71cc553d74aa4bc201f23faf637751349 +size 1079071 diff --git a/assets/emojis/shocked.gif b/assets/emojis/shocked.gif new file mode 100644 index 0000000000000000000000000000000000000000..89a78d99dfdbde29b92c561b7ab22534deb4751f --- /dev/null +++ b/assets/emojis/shocked.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:80d6df157b1ca033603cde678fa7d821951dd897a7455e36c4c62ce9e2204ad3 +size 702681 diff --git a/assets/emojis/silly.gif b/assets/emojis/silly.gif new file mode 100644 index 0000000000000000000000000000000000000000..e2032b460802a2f0dcaf32c614b7260cadb28fe4 --- /dev/null +++ b/assets/emojis/silly.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:28dec678b5d68fa34aacc899070c75e9c182d5ca67fc9d3370ce14f61a69e29b +size 505424 diff --git a/assets/emojis/sleepy.gif b/assets/emojis/sleepy.gif new file mode 100644 index 0000000000000000000000000000000000000000..5de54ee44ed39af0bec3878e7da38b833d0985f6 --- /dev/null +++ b/assets/emojis/sleepy.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2bb3a222de60e0bfe652dbb050e83bd77b13fad09940b5cedfda617f3474512d +size 779198 diff --git a/assets/emojis/surprised.gif b/assets/emojis/surprised.gif new file mode 100644 index 0000000000000000000000000000000000000000..793e55da8cf540acaf8d5ef23e013aeac276dd1c --- /dev/null +++ b/assets/emojis/surprised.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cd633a3d48a6f8926342fdc8497865794e483323cb99c7ab03a955d01abbdcbc +size 304717 diff --git a/assets/emojis/thinking.gif b/assets/emojis/thinking.gif new file mode 100644 index 0000000000000000000000000000000000000000..78353b6f0bd3f6db082940a78b0036d74efbb462 --- /dev/null +++ b/assets/emojis/thinking.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:7d484a353294e9347fc3753ddab60ce5cc13b5787b00b80f759308ae57a693e9 +size 820694 diff --git a/assets/emojis/winking.gif b/assets/emojis/winking.gif new file mode 100644 index 0000000000000000000000000000000000000000..9ab3c5752017069ec004236b28034f266ed16055 --- /dev/null +++ b/assets/emojis/winking.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4d2ca8090e29d90f87d817a83b26f1461c9c9aff6133be949021613c577ea774 +size 671167 diff --git a/build.json b/build.json new file mode 100644 index 0000000000000000000000000000000000000000..aaa660abc3a950beabff6ef06654865f19f16a2e --- /dev/null +++ b/build.json @@ -0,0 +1,46 @@ +{ + "name": "xiaozhi", + "version": "1.0.0", + "publisher": "Junsen", + "entry": "main.py", + "icon": "assets/xiaozhi_icon.ico", + "hooks": "hooks", + "onefile": false, + "additional_pyinstaller_args": "--add-data assets;assets --add-data libs;libs --add-data src;src --add-data models;models --hidden-import=PyQt5", + "inno_setup_path": "E:\\application\\Inno Setup 6\\ISCC.exe", + "platform_specific": { + "windows": { + "format": "exe", + "additional_pyinstaller_args": "--add-data assets;assets --add-data libs;libs --add-data src;src --add-data models;models --hidden-import=PyQt5 --noconsole", + "desktop_entry": true, + "installer_options": { + "languages": ["ChineseSimplified", "English"], + "license_file": "LICENSE", + "readme_file": "README.md", + "create_desktop_icon": true, + "allow_run_after_install": true + } + }, + "linux": { + "format": "deb", + "desktop_entry": true, + "categories": "Utility;Development;", + "description": "小智Ai客户端", + "requires": "libc6,libgtk-3-0,libx11-6,libopenblas-dev", + "additional_pyinstaller_args": "--add-data assets:assets --add-data libs:libs --add-data src:src --add-data models:models --hidden-import=PyQt5" + }, + "macos": { + "format": "app", + "additional_pyinstaller_args": "--add-data assets:assets --add-data libs:libs --add-data src:src --add-data models:models --hidden-import=PyQt5 --windowed", + "app_bundle_name": "XiaoZhi.app", + "bundle_identifier": "com.junsen.xiaozhi", + "sign_bundle": false, + "create_dmg": true, + "installer_options": { + "license_file": "LICENSE", + "readme_file": "README.md" + } + } + }, + "build_installer": true +} \ No newline at end of file diff --git a/documents/.gitignore b/documents/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..db4c6d9b6797601f6677263cb201a46743f9d545 --- /dev/null +++ b/documents/.gitignore @@ -0,0 +1,2 @@ +dist +node_modules \ No newline at end of file diff --git a/documents/README.md b/documents/README.md new file mode 100644 index 0000000000000000000000000000000000000000..311d3d891c8701b1efd7af9efbb2f7575913d9e4 --- /dev/null +++ b/documents/README.md @@ -0,0 +1,74 @@ +# py-xiaozhi 文档 + +这是 py-xiaozhi 项目的文档网站,基于 VitePress 构建。 + +## 功能 + +- 项目指南:提供项目的详细使用说明和开发文档 +- 赞助商页面:展示并感谢项目的所有赞助者 +- 贡献指南:说明如何为项目贡献代码 +- 贡献者名单:展示所有为项目做出贡献的开发者 +- 响应式设计:适配桌面和移动设备 + +## 本地开发 + +```bash +# 安装依赖 +pnpm install + +# 启动开发服务器 +pnpm docs:dev + +# 构建静态文件 +pnpm docs:build + +# 预览构建结果 +pnpm docs:preview +``` + +## 目录结构 + +``` +documents/ +├── docs/ # 文档源文件 +│ ├── .vitepress/ # VitePress 配置 +│ ├── guide/ # 指南文档 +│ ├── sponsors/ # 赞助商页面 +│ ├── contributing.md # 贡献指南 +│ ├── contributors.md # 贡献者名单 +│ └── index.md # 首页 +├── package.json # 项目配置 +└── README.md # 项目说明 +``` + +## 赞助商页面 + +赞助商页面通过以下方式实现: + +1. `/sponsors/` 目录包含了赞助商相关的内容 +2. `data.json` 文件存储赞助商数据 +3. 使用 Vue 组件在客户端动态渲染赞助商列表 +4. 提供成为赞助者的详细说明和支付方式 + +## 贡献指南 + +贡献指南页面提供了以下内容: + +1. 开发环境准备指南 +2. 代码贡献流程说明 +3. 编码规范和提交规范 +4. Pull Request 创建和审核流程 +5. 文档贡献指南 + +## 贡献者名单 + +贡献者名单页面展示了所有为项目做出贡献的开发者,包括: + +1. 核心开发团队成员 +2. 代码贡献者 +3. 文档贡献者 +4. 测试和反馈提供者 + +## 部署 + +文档网站通过 GitHub Actions 自动部署到 GitHub Pages。 \ No newline at end of file diff --git a/documents/docs/.vitepress/config.mts b/documents/docs/.vitepress/config.mts new file mode 100644 index 0000000000000000000000000000000000000000..04e03f6582fa547e2c1e80bddb2df5736ae2fe1a --- /dev/null +++ b/documents/docs/.vitepress/config.mts @@ -0,0 +1,74 @@ +import { defineConfig } from 'vitepress' +import { getGuideSideBarItems } from './guide' +import tailwindcss from '@tailwindcss/vite' +// https://vitepress.dev/reference/site-config +export default defineConfig({ + title: "PY-XIAOZHI", + description: "py-xiaozhi 是一个使用 Python 实现的小智语音客户端,旨在通过代码学习和在没有硬件条件下体验 AI 小智的语音功能。", + base: '/py-xiaozhi/', + themeConfig: { + // https://vitepress.dev/reference/default-theme-config + nav: [ + { text: '主页', link: '/' }, + { text: '指南', link: '/guide/00_文档目录' }, + { text: '系统架构', link: '/architecture/' }, + { text: '相关生态', link: '/ecosystem/' }, + { text: '团队', link: '/about/team' }, + { text: '贡献指南', link: '/contributing' }, + { text: '赞助', link: '/sponsors/' } + ], + + sidebar: { + '/guide/': [ + { + text: '指南', + // 默认展开 + collapsed: false, + items: getGuideSideBarItems(), + }, + { + text: '旧版文档', + collapsed: true, + items: [ + { text: '使用文档', link: '/guide/old_docs/使用文档' } + ] + } + ], + '/ecosystem/': [ + { + text: '生态系统概览', + link: '/ecosystem/' + }, + { + text: '相关项目', + collapsed: false, + items: [ + { text: '小智手机端', link: '/ecosystem/projects/xiaozhi-android-client/' }, + { text: 'xiaozhi-esp32-server', link: '/ecosystem/projects/xiaozhi-esp32-server/' }, + { text: 'XiaoZhiAI_server32_Unity', link: '/ecosystem/projects/xiaozhi-unity/' }, + { text: 'IntelliConnect', link: '/ecosystem/projects/intelliconnect/' }, + { text: 'open-xiaoai', link: '/ecosystem/projects/open-xiaoai/' } + ] + }, + ], + '/about/': [], + // 赞助页面不显示侧边栏 + '/sponsors/': [], + // 贡献指南页面不显示侧边栏 + '/contributing': [], + // 系统架构页面不显示侧边栏 + '/architecture/': [], + // 团队页面不显示侧边栏 + '/about/team': [] + }, + + socialLinks: [ + { icon: 'github', link: 'https://github.com/huangjunsen0406/py-xiaozhi' } + ] + }, + vite: { + plugins: [ + tailwindcss() + ] + } +}) diff --git a/documents/docs/.vitepress/guide/index.ts b/documents/docs/.vitepress/guide/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5b887c790428284d54b1807341679d0c8b1ce72 --- /dev/null +++ b/documents/docs/.vitepress/guide/index.ts @@ -0,0 +1,15 @@ +import { getMdFilesAsync } from "../utils"; +import path from 'path'; +import { DefaultTheme } from 'vitepress'; + +export function getGuideSideBarItems(): (DefaultTheme.SidebarItem)[] { + return getMdFilesAsync(path.resolve(__dirname, '../../guide')) + .map(item => { + return { + text: item, + link: `/guide/${item}` + } + }).filter(item => { + return !item.text.includes('使用文档') + }) +} diff --git a/documents/docs/.vitepress/theme/index.ts b/documents/docs/.vitepress/theme/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..d90a02d9d1d50c2fce99cbd982b3726511ae3f63 --- /dev/null +++ b/documents/docs/.vitepress/theme/index.ts @@ -0,0 +1,7 @@ +import DefaultTheme from 'vitepress/theme' +import './styles/index.css' +import './styles/custom.scss' + +export default { + ...DefaultTheme, +} diff --git a/documents/docs/.vitepress/theme/styles/badges.css b/documents/docs/.vitepress/theme/styles/badges.css new file mode 100644 index 0000000000000000000000000000000000000000..df66ff636ddcbd0fd4bc618a9ba5cfd36041002c --- /dev/null +++ b/documents/docs/.vitepress/theme/styles/badges.css @@ -0,0 +1,28 @@ +.vt-badge.wip:before { + content: 'WIP'; +} + +.vt-badge.ts { + background-color: #3178c6; +} +.vt-badge.ts:before { + content: 'TS'; +} + +.vt-badge.dev-only, +.vt-badge.experimental { + color: var(--vt-c-text-light-1); + background-color: var(--vt-c-yellow); +} + +.vt-badge.dev-only:before { + content: 'Dev only'; +} + +.vt-badge.experimental:before { + content: 'Experimental'; +} + +.vt-badge[data-text]:before { + content: attr(data-text); +} diff --git a/documents/docs/.vitepress/theme/styles/custom.scss b/documents/docs/.vitepress/theme/styles/custom.scss new file mode 100644 index 0000000000000000000000000000000000000000..b25f840a4a81e6025f3a9b76e6c0482857a3d197 --- /dev/null +++ b/documents/docs/.vitepress/theme/styles/custom.scss @@ -0,0 +1,26 @@ +.architecture-page-class { + @media (min-width: 1440px) { + + .VPDoc:not(.has-sidebar) .content { + + max-width: 1284px !important; /* <-- update your values */ + + } + + .VPDoc:not(.has-sidebar) .container { + + max-width: 1604px !important; + + } + .VPDoc.has-aside .content-container { + max-width: 1188px !important; + } + + .vp-doc h3 { + margin: 0; + } + .vp-doc ul { + padding-left: 0; + } + } +} \ No newline at end of file diff --git a/documents/docs/.vitepress/theme/styles/index.css b/documents/docs/.vitepress/theme/styles/index.css new file mode 100644 index 0000000000000000000000000000000000000000..7407b4bd87bdabf4f3a69c651fd12b44a4e1f925 --- /dev/null +++ b/documents/docs/.vitepress/theme/styles/index.css @@ -0,0 +1,7 @@ +@import "./pages.css"; +@import "./badges.css"; +@import "./options-boxes.css"; +@import "./inline-demo.css"; +@import "./utilities.css"; +@import "./style-guide.css"; +@import "tailwindcss"; \ No newline at end of file diff --git a/documents/docs/.vitepress/theme/styles/inline-demo.css b/documents/docs/.vitepress/theme/styles/inline-demo.css new file mode 100644 index 0000000000000000000000000000000000000000..21e088e08754da205b186dff580be6bcbef4fdfb --- /dev/null +++ b/documents/docs/.vitepress/theme/styles/inline-demo.css @@ -0,0 +1,90 @@ +.vt-doc a[href^="https://play.vuejs.org"]:before +{ + content: '▶'; + width: 20px; + height: 20px; + display: inline-flex; + border-radius: 10px; + vertical-align: middle; + position: relative; + top: -2px; + color: var(--vt-c-green); + border: 2px solid var(--vt-c-green); + margin-right: 8px; + margin-left: 4px; + line-height: 16px; + padding-left: 4.2px; + font-size: 11px; +} + +.demo { + padding: 22px 24px; + border-radius: 8px; + box-shadow: var(--vt-shadow-2); + margin-bottom: 1.2em; + transition: background-color 0.5s ease; +} + +.dark .demo { + background-color: var(--vt-c-bg-soft); +} + +.demo p { + margin: 0; +} + +.demo button { + background-color: var(--vt-c-bg-mute); + transition: background-color 0.5s; + padding: 5px 12px; + border: 1px solid var(--vt-c-divider); + border-radius: 8px; + font-size: 0.9em; + font-weight: 600; +} + +.demo button + button { + margin-left: 1em; +} + +.demo input, +.demo textarea, +.demo select { + border: 1px solid var(--vt-c-divider); + border-radius: 4px; + padding: 0.2em 0.6em; + margin-top: 10px; + background: transparent; + transition: background-color 0.5s; +} + +.dark .demo select { + background: var(--vt-c-bg-soft); +} + +.dark .demo select option { + background: transparent; +} + +.demo input:not([type]):focus, +.demo textarea:focus, +.demo select:focus { + outline: 1px solid blue; +} + +.demo select { + /* this was set by normalize.css */ + -webkit-appearance: listbox; +} + +.demo label { + margin: 0 1em 0 0.4em; +} + +.demo select[multiple] { + width: 100px; +} + +.demo h1 { + margin: 10px 0 0; +} diff --git a/documents/docs/.vitepress/theme/styles/options-boxes.css b/documents/docs/.vitepress/theme/styles/options-boxes.css new file mode 100644 index 0000000000000000000000000000000000000000..69e629659de0b8d8cbdd2f4d1a5da7cf181feeff --- /dev/null +++ b/documents/docs/.vitepress/theme/styles/options-boxes.css @@ -0,0 +1,27 @@ +.next-steps { + margin-top: 3rem; +} + +.next-steps .vt-box { + border: 1px solid var(--vt-c-bg-soft); +} + +.next-steps .vt-box:hover { + border-color: var(--vt-c-green-light); + transition: border-color 0.3s cubic-bezier(0.25, 0.8, 0.25, 1); +} + +.vt-doc .next-steps-link { + font-size: 20px; + line-height: 1.4; + letter-spacing: -0.02em; + margin-bottom: 0.75em; + display: block; + color: var(--vt-c-green); +} + +.vt-doc .next-steps-caption { + margin-bottom: 0; + color: var(--vt-c-text-2); + transition: color 0.5s; +} diff --git a/documents/docs/.vitepress/theme/styles/pages.css b/documents/docs/.vitepress/theme/styles/pages.css new file mode 100644 index 0000000000000000000000000000000000000000..4721cec9bc0fdb66e19902be42a0311ac646a7d6 --- /dev/null +++ b/documents/docs/.vitepress/theme/styles/pages.css @@ -0,0 +1,15 @@ +/* always show anchors on /api/ and /style-guide/ pages */ +.vt-doc.api h2 .header-anchor, +.vt-doc.style-guide h2 .header-anchor { + opacity: 1; +} + +.vt-doc.sponsor h3 { + text-align: center; + padding-bottom: 1em; + border-bottom: 1px solid var(--vt-c-divider-light); +} + +.vt-doc.sponsor h3 .header-anchor { + display: none; +} diff --git a/documents/docs/.vitepress/theme/styles/style-guide.css b/documents/docs/.vitepress/theme/styles/style-guide.css new file mode 100644 index 0000000000000000000000000000000000000000..6fc03a26cab8c2ceeafc7c273dd94c5d89632283 --- /dev/null +++ b/documents/docs/.vitepress/theme/styles/style-guide.css @@ -0,0 +1,65 @@ +.style-example { + border-radius: 8px 8px 12px 12px; + margin: 1.6em 0; + padding: 1.6em 1.6em 0.1px; + position: relative; + border: 1px solid transparent; + transition: background-color 0.25s ease, border-color 0.25s ease; +} + +.vt-doc .style-example h3 { + margin: 0; + font-size: 1.1em; +} + +.style-example-bad { + background: #f7e8e8; +} +.dark .style-example-bad { + background: transparent; + border-color: var(--vt-c-red); +} + +.style-example-bad h3 { + color: var(--vt-c-red); +} + +.style-example-good { + background: #ecfaf7; +} +.dark .style-example-good { + background: transparent; + border-color: var(--vt-c-green); +} + +.style-example-good h3 { + color: var(--vt-c-green); +} + +.details summary { + font-weight: bold !important; +} + +.style-verb { + font-size: 0.6em; + display: inline-block; + border-radius: 6px; + font-size: 0.65em; + line-height: 1; + font-weight: 600; + padding: 0.35em 0.4em 0.3em; + position: relative; + top: -0.15em; + margin-right: 0.5em; + color: var(--vt-c-bg); + transition: color 0.5s; + background-color: var(--vt-c-brand); +} + +.style-verb.avoid { + background-color: var(--vt-c-red); +} +.vt-doc summary { + width: fit-content; + cursor: pointer; +} \ No newline at end of file diff --git a/documents/docs/.vitepress/theme/styles/utilities.css b/documents/docs/.vitepress/theme/styles/utilities.css new file mode 100644 index 0000000000000000000000000000000000000000..f9e054b3b59004377eec54ab29362167a4dc9952 --- /dev/null +++ b/documents/docs/.vitepress/theme/styles/utilities.css @@ -0,0 +1,14 @@ +.nowrap { + white-space: nowrap; +} + +.sr-only { + position: absolute; + width: 1px; + height: 1px; + padding: 0; + margin: -1px; + overflow: hidden; + clip: rect(0, 0, 0, 0); + border: 0; +} diff --git a/documents/docs/.vitepress/theme/styles/vue-mastery.css b/documents/docs/.vitepress/theme/styles/vue-mastery.css new file mode 100644 index 0000000000000000000000000000000000000000..d14bf556e9df6ad0b2255559f433ef244e5d29e5 --- /dev/null +++ b/documents/docs/.vitepress/theme/styles/vue-mastery.css @@ -0,0 +1,65 @@ +.vue-mastery-link { + background-color: var(--vt-c-bg-soft); + border-radius: 8px; + padding: 8px 16px 8px 8px; + transition: color 0.5s, background-color 0.5s; +} + +.vue-mastery-link a { + display: flex; + align-items: center; +} + +.vue-mastery-link .banner { + background-color: var(--vt-c-white-soft); + border-radius: 4px; + width: 96px; + height: 56px; + object-fit: cover; +} + +.vue-mastery-link .description { + flex: 1; + font-weight: 500; + font-size: 14px; + line-height: 20px; + color: var(--vt-c-text-1); + margin: 0 0 0 16px; + transition: color 0.5s; +} + +.vue-mastery-link .description span { + color: var(--vt-c-brand); +} + +.vue-mastery-link .logo-wrapper { + position: relative; + width: 48px; + height: 48px; + border-radius: 50%; + background-color: var(--vt-c-white); + display: flex; + justify-content: center; + align-items: center; +} + +.vue-mastery-link .logo-wrapper img { + width: 25px; + object-fit: contain; +} + +@media (max-width: 576px) { + .vue-mastery-link .banner { + width: 56px; + } + + .vue-mastery-link .description { + font-size: 12px; + line-height: 18px; + } + .vue-mastery-link .logo-wrapper { + position: relative; + width: 32px; + height: 32px; + } +} diff --git a/documents/docs/.vitepress/utils/index.ts b/documents/docs/.vitepress/utils/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..50124094f941c1e3ed92ecbdfa06153797a59e75 --- /dev/null +++ b/documents/docs/.vitepress/utils/index.ts @@ -0,0 +1,26 @@ +import fs from 'fs-extra'; +import path from 'path'; + +export function getMdFilesAsync(rootDir: string) { + const results: string[] = []; + + function traverse(currentDir) { + const entries = fs.readdirSync(currentDir, { withFileTypes: true }); + + entries.map(async (entry) => { + const entryPath = path.join(currentDir, entry.name); + + if (entry.isDirectory()) { + traverse(entryPath); // 递归子目录[3](@ref) + } else if (path.extname(entry.name).toLowerCase() === '.md') { + const relativePath = path.relative(rootDir, entryPath); + results.push(relativePath); + } + }) + } + + traverse(rootDir); + return results.map(item => { + return item.replace('.md', ''); + }); +} diff --git a/documents/docs/about/images/ben-hong.jpeg b/documents/docs/about/images/ben-hong.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..55891975ab662b5016db391b597103cbacd39e67 Binary files /dev/null and b/documents/docs/about/images/ben-hong.jpeg differ diff --git a/documents/docs/about/images/evan-you.jpeg b/documents/docs/about/images/evan-you.jpeg new file mode 100644 index 0000000000000000000000000000000000000000..388b88924ab9d00d0e9dff7226b9d8c45f2e2a76 Binary files /dev/null and b/documents/docs/about/images/evan-you.jpeg differ diff --git a/documents/docs/about/team.md b/documents/docs/about/team.md new file mode 100644 index 0000000000000000000000000000000000000000..2efca6e50321a85fd9afd3fbcd7b8a06b3befc7a --- /dev/null +++ b/documents/docs/about/team.md @@ -0,0 +1,11 @@ +--- +page: true +title: Py-xiaozhi团队 +layout: home +--- + + + + diff --git a/documents/docs/about/team/Member.ts b/documents/docs/about/team/Member.ts new file mode 100644 index 0000000000000000000000000000000000000000..865be87ef48db8887fa81fcec27c94901b66de85 --- /dev/null +++ b/documents/docs/about/team/Member.ts @@ -0,0 +1,26 @@ +export interface Member { + name: string + avatarPic?: string + title: string + company?: string + companyLink?: string + projects: Link[] + location: string | string[] + languages: string[] + website?: Link + socials: Socials + sponsor?: boolean | string + reposPersonal?: string[] +} + +export interface Link { + label: string + url: string +} + +export interface Socials { + github: string + twitter?: string + linkedin?: string + codepen?: string +} diff --git a/documents/docs/about/team/TeamHero.vue b/documents/docs/about/team/TeamHero.vue new file mode 100644 index 0000000000000000000000000000000000000000..974c61c9a7b91dfd90f9fc056dd4e48b6a82c2be --- /dev/null +++ b/documents/docs/about/team/TeamHero.vue @@ -0,0 +1,75 @@ + + + diff --git a/documents/docs/about/team/TeamList.vue b/documents/docs/about/team/TeamList.vue new file mode 100644 index 0000000000000000000000000000000000000000..19b4bc260137200a7fe9db592a1b89be5847f405 --- /dev/null +++ b/documents/docs/about/team/TeamList.vue @@ -0,0 +1,128 @@ + + + + + diff --git a/documents/docs/about/team/TeamMember.vue b/documents/docs/about/team/TeamMember.vue new file mode 100644 index 0000000000000000000000000000000000000000..24487d427aa81bf92d8be030e1d2e6577ffac970 --- /dev/null +++ b/documents/docs/about/team/TeamMember.vue @@ -0,0 +1,472 @@ + + + + + diff --git a/documents/docs/about/team/TeamPage.vue b/documents/docs/about/team/TeamPage.vue new file mode 100644 index 0000000000000000000000000000000000000000..58a76ab7e87769b7916cd8a3f6964c914e601292 --- /dev/null +++ b/documents/docs/about/team/TeamPage.vue @@ -0,0 +1,84 @@ + + + + + + + diff --git a/documents/docs/about/team/members-core.json b/documents/docs/about/team/members-core.json new file mode 100644 index 0000000000000000000000000000000000000000..757643401eabef1f30965bea6a4f9c99dea25a6f --- /dev/null +++ b/documents/docs/about/team/members-core.json @@ -0,0 +1,85 @@ +[ + { + "name": "Junsen Huang", + "title": "py-xiaozhi发起者 & 核心开发者", + "company": "南方电网人工智能有限公司 前端开发工程师", + "location": "中国", + "languages": ["中文"], + "projects": [ + { + "label": "py-xiaozhi", + "url": "https://github.com/huangjunsen0406/py-xiaozhi" + }, + { + "label": "UnifyPy", + "url": "https://github.com/huangjunsen0406/UnifyPy" + }, + { + "label": "xiaozhi-esp32-server", + "url": "https://github.com/xinnan-tech/xiaozhi-esp32-server" + } + ], + "socials": { + "github": "huangjunsen0406" + }, + "website": { + "label": "https://junsen.online", + "url": "https://junsen.online" + } + }, + { + "name": "ASLant", + "title": "核心开发者、PyQt5界面开发、嵌入式兼容", + "company": "山东青橙数字科技有限公司 嵌入式软件工程师", + "location": "中国", + "languages": ["中文"], + "projects": [ + ], + "socials": { + "github": "Y-ASLant" + }, + "website": { + "label": "aslant.top", + "url": "https://aslant.top/" + } + }, + { + "name": "fengzai6", + "title": "前期核心开发者", + "company": "中山华定科技有限公司 前端开发工程师", + "location": "中国", + "languages": ["中文"], + "projects": [ + ], + "socials": { + "github": "fengzai6" + } + }, + { + "name": "Cal And Nong", + "title": "文档贡献者", + "company": "腾讯 前端开发工程师", + "location": "中国", + "languages": ["中文"], + "projects": [ + ], + "socials": { + "github": "calandnong" + } + }, + { + "name": "vonweller", + "title": "视觉功能贡献者", + "projects": [ + { + "label": "视觉功能开发", + "url": "https://github.com/vonweller" + } + ], + "location": "中国", + "languages": ["中文"], + "socials": { + "github": "vonweller" + } + } +] diff --git a/documents/docs/about/team/members-partner.json b/documents/docs/about/team/members-partner.json new file mode 100644 index 0000000000000000000000000000000000000000..aa79a33f58f7a08fb166d9c82d6c026ae62786a9 --- /dev/null +++ b/documents/docs/about/team/members-partner.json @@ -0,0 +1,113 @@ +[ + { + "name": "Xiaoxia", + "title": "社区贡献者", + "projects": [ + { + "label": "小智Ai创始人", + "url": "https://github.com/78/xiaozhi-esp32" + } + ], + "location": "中国", + "languages": ["中文"], + "socials": { + "github": "78" + } + }, + { + "name": "zhh827", + "title": "社区贡献者", + "projects": [ + ], + "location": "中国", + "languages": ["中文"], + "socials": { + "github": "zhh827" + } + }, + { + "name": "四博智联-李洪刚", + "title": "社区贡献者", + "projects": [ + ], + "location": "中国", + "languages": ["中文"], + "socials": { + "github": "SmartArduino" + } + }, + { + "name": "HonestQiao", + "title": "社区贡献者", + "projects": [ + ], + "location": "中国", + "languages": ["中文"], + "socials": { + "github": "HonestQiao" + } + }, + { + "name": "孙卫公", + "title": "社区贡献者", + "avatarPic": "https://tuchuang.junsen.online/i/2025/04/27/i7ndx4.png", + "projects": [ + ], + "location": "中国", + "languages": ["中文"], + "socials": { + }, + "website": { + "label": "Bilibili 主页", + "url": "https://space.bilibili.com/416954647" + } + }, + { + "name": "isamu2025", + "title": "社区贡献者", + "projects": [ + ], + "location": "中国", + "languages": ["中文"], + "socials": { + "github": "isamu2025" + } + }, + { + "name": "Rain120", + "title": "社区贡献者", + "projects": [ + ], + "location": "中国", + "languages": ["中文"], + "socials": { + "github": "Rain120" + } + }, + { + "name": "kejily", + "title": "社区贡献者", + "projects": [ + ], + "location": "中国", + "languages": ["中文"], + "socials": { + "github": "kejily" + } + }, + { + "name": "电波bilibili君", + "title": "社区贡献者", + "avatarPic": "https://tuchuang.junsen.online/i/2025/04/27/f4g54b.png", + "projects": [ + ], + "location": "中国", + "languages": ["中文"], + "socials": { + }, + "website": { + "label": "Bilibili 主页", + "url": "https://space.bilibili.com/119751" + } + } +] diff --git a/documents/docs/architecture/components/ArchitectureFeatures.vue b/documents/docs/architecture/components/ArchitectureFeatures.vue new file mode 100644 index 0000000000000000000000000000000000000000..b490ca9ce3fe85fd1b714aa2d70e2fd94a451734 --- /dev/null +++ b/documents/docs/architecture/components/ArchitectureFeatures.vue @@ -0,0 +1,73 @@ + + + + + \ No newline at end of file diff --git a/documents/docs/architecture/components/CoreArchitecture.vue b/documents/docs/architecture/components/CoreArchitecture.vue new file mode 100644 index 0000000000000000000000000000000000000000..3389f2b1a768b829cd81b768ea00d40d3b235349 --- /dev/null +++ b/documents/docs/architecture/components/CoreArchitecture.vue @@ -0,0 +1,98 @@ + + + \ No newline at end of file diff --git a/documents/docs/architecture/components/DataFlow.vue b/documents/docs/architecture/components/DataFlow.vue new file mode 100644 index 0000000000000000000000000000000000000000..1a793d15133c4ccab00ea7db13d1b044c7964766 --- /dev/null +++ b/documents/docs/architecture/components/DataFlow.vue @@ -0,0 +1,169 @@ + + + + + \ No newline at end of file diff --git a/documents/docs/architecture/components/ModuleDetails.vue b/documents/docs/architecture/components/ModuleDetails.vue new file mode 100644 index 0000000000000000000000000000000000000000..d60e41486033fcdf56aa309eb2e186fc55cb2f3b --- /dev/null +++ b/documents/docs/architecture/components/ModuleDetails.vue @@ -0,0 +1,132 @@ + + + + + \ No newline at end of file diff --git a/documents/docs/architecture/components/StateManagement.vue b/documents/docs/architecture/components/StateManagement.vue new file mode 100644 index 0000000000000000000000000000000000000000..4550001793f26fbbb527c62c053446d9a7748e29 --- /dev/null +++ b/documents/docs/architecture/components/StateManagement.vue @@ -0,0 +1,85 @@ + + + \ No newline at end of file diff --git a/documents/docs/architecture/components/TechnologyStack.vue b/documents/docs/architecture/components/TechnologyStack.vue new file mode 100644 index 0000000000000000000000000000000000000000..6a12b42dd345cad8a741cf878441e98a682ed6e9 --- /dev/null +++ b/documents/docs/architecture/components/TechnologyStack.vue @@ -0,0 +1,101 @@ + + + + + \ No newline at end of file diff --git a/documents/docs/architecture/index.md b/documents/docs/architecture/index.md new file mode 100644 index 0000000000000000000000000000000000000000..f0db961cc9b0eddb249d6071541e18fe9a416750 --- /dev/null +++ b/documents/docs/architecture/index.md @@ -0,0 +1,46 @@ +--- +title: Py-Xiaozhi 项目架构 +description: 基于 Python 实现的小智语音客户端,采用模块化设计,支持多种通信协议和设备集成 +sidebar: false, +pageClass: architecture-page-class +--- + + +
+ +# Py-Xiaozhi 项目架构 + +

基于 Python 实现的小智语音客户端,采用模块化设计,支持多种通信协议和设备集成

+ +## 核心架构 + + +## 状态管理 + + +## 数据流 + + +## 模块详情 + + +## 技术栈 + + +## 架构特点 + +
+ + \ No newline at end of file diff --git a/documents/docs/contributing.md b/documents/docs/contributing.md new file mode 100644 index 0000000000000000000000000000000000000000..80f0dfc67ddaa82210bfd9f5f128652f7f5ccfe6 --- /dev/null +++ b/documents/docs/contributing.md @@ -0,0 +1,451 @@ +--- +title: 贡献指南 +description: 如何为 py-xiaozhi 项目贡献代码 +sidebar: false +outline: deep +--- + +
+ +# 贡献指南 + +
+

如何为 py-xiaozhi 项目贡献代码 🚀

+
+ +## 前言 + +感谢您对 py-xiaozhi 项目感兴趣!我们非常欢迎社区成员参与贡献,无论是修复错误、改进文档还是添加新功能。本指南将帮助您了解如何向项目提交贡献。 + +## 开发环境准备 + +### 基本要求 + +- Python 3.9 或更高版本 +- Git 版本控制系统 +- 基本的 Python 开发工具(推荐使用 Visual Studio Code) + +### 获取源代码 + +1. 首先,在 GitHub 上 Fork 本项目到您自己的账号 + - 访问 [py-xiaozhi 项目页面](https://github.com/huangjunsen0406/py-xiaozhi) + - 点击右上角的"Fork"按钮 + - 等待 Fork 完成,您将被重定向到您的仓库副本 + +2. 克隆您 fork 的仓库到本地: + +```bash +git clone https://github.com/YOUR_USERNAME/py-xiaozhi.git +cd py-xiaozhi +``` + +3. 添加上游仓库作为远程源: + +```bash +git remote add upstream https://github.com/huangjunsen0406/py-xiaozhi.git +``` + +你可以使用 `git remote -v` 命令确认远程仓库已正确配置: + +```bash +git remote -v +# 应显示: +# origin https://github.com/YOUR_USERNAME/py-xiaozhi.git (fetch) +# origin https://github.com/YOUR_USERNAME/py-xiaozhi.git (push) +# upstream https://github.com/huangjunsen0406/py-xiaozhi.git (fetch) +# upstream https://github.com/huangjunsen0406/py-xiaozhi.git (push) +``` + +### 安装开发依赖 +- 其他依赖需要查看指南下的相关文档 +```bash +# 创建并激活虚拟环境(推荐) +python -m venv venv +source venv/bin/activate # 在 Windows 上使用: venv\Scripts\activate + +# 安装项目依赖 +pip install -r requirements.txt +``` + +## 开发流程 + +### 与主仓库保持同步 + +在开始工作之前,确保您的本地仓库与主项目保持同步是非常重要的。以下是同步本地仓库的步骤: + +1. 切换到您的主分支(`main`): + +```bash +git checkout main +``` + +2. 拉取上游仓库的最新更改: + +```bash +git fetch upstream +``` + +3. 将上游主分支的更改合并到您的本地主分支: + +```bash +git merge upstream/main +``` + +4. 将更新后的本地主分支推送到您的 GitHub 仓库: + +```bash +git push origin main +``` + +### 创建分支 + +在开始任何工作之前,请确保从最新的上游主分支创建新的分支: + +```bash +# 获取最新的上游代码(如上节所述) +git fetch upstream +git checkout -b feature/your-feature-name upstream/main +``` + +为分支命名时,可以遵循以下约定: +- `feature/xxx`:新功能开发 +- `fix/xxx`:修复 bug +- `docs/xxx`:文档更新 +- `test/xxx`:测试相关工作 +- `refactor/xxx`:代码重构 + +### 编码规范 + +我们使用 [PEP 8](https://www.python.org/dev/peps/pep-0008/) 作为 Python 代码风格指南。在提交代码前,请确保您的代码符合以下要求: + +- 使用 4 个空格进行缩进 +- 行长度不超过 120 个字符 +- 使用有意义的变量和函数名称 +- 为公共 API 添加文档字符串 +- 使用类型提示(Type Hints) + +我们推荐使用静态代码分析工具来帮助您遵循编码规范: + +```bash +# 使用 flake8 检查代码风格 +flake8 . + +# 使用 mypy 进行类型检查 +mypy . +``` + +### 测试 + +在提交之前,请确保所有测试都能通过 + +## 提交变更 + +### 提交前的检查清单 + +在提交您的代码之前,请确保完成以下检查: + +1. 代码是否符合 PEP 8 规范 +2. 是否添加了必要的测试用例 +3. 所有测试是否通过 +4. 是否添加了适当的文档 +5. 是否解决了您计划解决的问题 +6. 是否与最新的上游代码保持同步 + +### 提交变更 + +在开发过程中,养成小批量、频繁提交的习惯。这样可以使您的更改更容易跟踪和理解: + +```bash +# 查看更改的文件 +git status + +# 暂存更改 +git add file1.py file2.py + +# 提交更改 +git commit -m "feat: add new feature X" +``` + +### 解决冲突 + +如果您在尝试合并上游更改时遇到冲突,请按照以下步骤解决: + +1. 首先了解冲突的位置: + +```bash +git status +``` + +2. 打开冲突文件,您会看到类似以下标记: + +``` +<<<<<<< HEAD +您的代码 +======= +上游代码 +>>>>>>> upstream/main +``` + +3. 修改文件以解决冲突,删除冲突标记 +4. 解决完所有冲突后,暂存并提交: + +```bash +git add . +git commit -m "fix: resolve merge conflicts" +``` + +### 提交规范 + +我们使用[约定式提交](https://www.conventionalcommits.org/zh-hans/)规范来格式化 Git 提交消息。提交消息应该遵循以下格式: + +``` +<类型>[可选 作用域]: <描述> + +[可选 正文] + +[可选 脚注] +``` + +常用的提交类型包括: +- `feat`:新功能 +- `fix`:错误修复 +- `docs`:文档更改 +- `style`:不影响代码含义的变更(如空格、格式化等) +- `refactor`:既不修复错误也不添加功能的代码重构 +- `perf`:提高性能的代码更改 +- `test`:添加或修正测试 +- `chore`:对构建过程或辅助工具和库的更改 + +例如: + +``` +feat(tts): 添加新的语音合成引擎支持 + +添加对百度语音合成API的支持,包括以下功能: +- 支持多种音色选择 +- 支持语速和音量调节 +- 支持中英文混合合成 + +修复 #123 +``` + +### 推送更改 + +完成代码更改后,将您的分支推送到您的 GitHub 仓库: + +```bash +git push origin feature/your-feature-name +``` + +如果您已经创建了 Pull Request,并且需要更新它,只需再次推送到同一分支即可: + +```bash +# 在进行更多更改后 +git add . +git commit -m "refactor: improve code based on feedback" +git push origin feature/your-feature-name +``` + +### 创建 Pull Request 前同步最新代码 + +在创建 Pull Request 前,建议再次与上游仓库同步,以避免潜在的冲突: + +```bash +# 获取上游最新代码 +git fetch upstream + +# 将上游最新代码变基到您的特性分支 +git rebase upstream/main + +# 如果出现冲突,解决冲突并继续变基 +git add . +git rebase --continue + +# 强制推送更新后的分支到您的仓库 +git push --force-with-lease origin feature/your-feature-name +``` + +注意:使用 `--force-with-lease` 比直接使用 `--force` 更安全,它可以防止覆盖他人推送的更改。 + +### 创建 Pull Request + +当您完成功能开发或问题修复后,请按照以下步骤创建 Pull Request: + +1. 将您的更改推送到 GitHub: + +```bash +git push origin feature/your-feature-name +``` + +2. 访问 GitHub 上您 fork 的仓库页面,点击 "Compare & pull request" 按钮 + +3. 填写 Pull Request 表单: + - 使用清晰的标题,遵循提交消息格式 + - 在描述中提供详细信息 + - 引用相关 issue(使用 `#issue编号` 格式) + - 如果这是一个进行中的工作,请添加 `[WIP]` 前缀到标题 + +4. 提交 Pull Request,等待项目维护者审核 + +### Pull Request 的生命周期 + +1. **创建**:提交您的 PR +2. **CI 检查**:自动化测试和代码风格检查 +3. **代码审核**:维护者会审核您的代码并提供反馈 +4. **修订**:根据反馈修改您的代码 +5. **批准**:一旦您的 PR 被批准 +6. **合并**:维护者会将您的 PR 合并到主分支 + +## 文档贡献 + +如果您想改进项目文档,请按照以下步骤操作: + +1. 按照上述步骤 Fork 项目并克隆到本地 + +2. 文档位于 `documents/docs` 目录下,使用 Markdown 格式 + +3. 安装文档开发依赖: + +```bash +cd documents +pnpm install +``` + +4. 启动本地文档服务器: + +```bash +pnpm docs:dev +``` + +5. 在浏览器中访问 `http://localhost:5173/py-xiaozhi/` 预览您的更改 + +6. 完成更改后,提交您的贡献并创建 Pull Request + +### 文档编写准则 + +- 使用清晰、简洁的语言 +- 提供实际示例 +- 对复杂概念进行详细解释 +- 包含适当的截图或图表(需要时) +- 避免技术术语过多,必要时提供解释 +- 保持文档结构一致 + +## 问题反馈 + +如果您发现了问题但暂时无法修复,请在 GitHub 上[创建 Issue](https://github.com/huangjunsen0406/py-xiaozhi/issues/new)。创建 Issue 时,请包含以下信息: + +- 问题的详细描述 +- 重现问题的步骤 +- 预期行为和实际行为 +- 您的操作系统和 Python 版本 +- 相关的日志输出或错误信息 + +## 代码审核 + +提交 Pull Request 后,项目维护者将会审核您的代码。在代码审核过程中: + +- 请耐心等待反馈 +- 及时响应评论和建议 +- 必要时进行修改并更新您的 Pull Request +- 保持礼貌和建设性的讨论 + +### 处理代码审核反馈 + +1. 认真阅读所有评论和建议 +2. 针对每个要点作出回应或更改 +3. 如果您不同意某个建议,礼貌地解释您的理由 +4. 修改完成后,在 PR 中留言通知审核者 + +## 成为项目维护者 + +如果您持续为项目做出有价值的贡献,您可能会被邀请成为项目的维护者。作为维护者,您将有权限审核和合并其他人的 Pull Request。 + +### 维护者的职责 + +- 审核 Pull Request +- 管理 issue +- 参与项目规划 +- 回答社区问题 +- 帮助引导新贡献者 + +## 行为准则 + +请尊重所有项目参与者,遵循以下行为准则: + +- 使用包容性语言 +- 尊重不同的观点和经验 +- 优雅地接受建设性批评 +- 关注社区最佳利益 +- 对其他社区成员表示同理心 + +## 常见问题解答 + +### 我应该从哪里开始贡献? + +1. 查看标记为 "good first issue" 的问题 +2. 修复文档中的错误或不清晰的部分 +3. 添加更多测试用例 +4. 解决您自己在使用过程中发现的问题 + +### 我提交的 PR 已经很久没有回应了,我该怎么办? + +在 PR 中留言,礼貌地询问是否需要进一步的改进或澄清。请理解维护者可能很忙,需要一些时间来审核您的贡献。 + +### 我可以贡献哪些类型的更改? + +- 错误修复 +- 新功能 +- 性能改进 +- 文档更新 +- 测试用例 +- 代码重构 + +## 致谢 + +再次感谢您为项目做出贡献!您的参与对我们非常重要,共同努力让 py-xiaozhi 变得更好! + +
+ + \ No newline at end of file diff --git a/documents/docs/ecosystem/index.md b/documents/docs/ecosystem/index.md new file mode 100644 index 0000000000000000000000000000000000000000..dd8d4d0a31ea01732b9bd25f1dd72ad85cfedb9e --- /dev/null +++ b/documents/docs/ecosystem/index.md @@ -0,0 +1,130 @@ +--- +title: 相关生态 +description: py-xiaozhi项目相关的生态系统和扩展项目 +outline: deep +--- + +
+ +# 相关生态 + +
+

py-xiaozhi项目生态系统 🌱

+

探索围绕py-xiaozhi构建的相关项目和扩展

+
+ +## 生态概览 + +本页面将收集和展示py-xiaozhi项目相关的生态系统项目,包括: + +- 官方扩展和插件 +- 社区贡献的项目 +- 兼容的硬件设备 +- 第三方集成方案 +- 示例项目和案例分析 + +## 即将推出 + +我们计划收集和整理以下内容: + +- 各种设备上的安装和运行指南 +- 与智能家居系统的集成方案 +- 定制语音指令和技能的开发教程 +- 基于py-xiaozhi构建的项目案例 +- 社区贡献的扩展功能 + +## 参与贡献 + +如果您有相关的项目或扩展想要分享,欢迎通过以下方式参与贡献: + +1. 在GitHub上提交Pull Request,添加您的项目 +2. 在Issues中建议您希望看到的集成或扩展 +3. 分享您使用py-xiaozhi的经验和案例 + +
+ + \ No newline at end of file diff --git a/documents/docs/ecosystem/projects/intelliconnect/images/logo.png b/documents/docs/ecosystem/projects/intelliconnect/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..580252ae4630fe91b490f1c5d72a82fa75bc6afa Binary files /dev/null and b/documents/docs/ecosystem/projects/intelliconnect/images/logo.png differ diff --git a/documents/docs/ecosystem/projects/intelliconnect/index.md b/documents/docs/ecosystem/projects/intelliconnect/index.md new file mode 100644 index 0000000000000000000000000000000000000000..7af7e71a1a26b955b4fea2461ed61e7c4a44bc3d --- /dev/null +++ b/documents/docs/ecosystem/projects/intelliconnect/index.md @@ -0,0 +1,237 @@ +--- +title: IntelliConnect +description: 基于SpringBoot的智能物联网平台,集成Agent智能体技术的IoT解决方案 +--- + +# IntelliConnect + +
+ +
+ 跨平台 + Java/Spring + v0.1 +
+
+ +
+
+██╗ ███╗   ██╗ ████████╗ ███████╗ ██╗      ██╗      ██╗    ██████╗  ██████╗  ███╗   ██╗ ███╗   ██╗ ███████╗  ██████╗ ████████╗
+██║ ████╗  ██║ ╚══██╔══╝ ██╔════╝ ██║      ██║      ██║   ██╔════╝ ██╔═══██╗ ████╗  ██║ ████╗  ██║ ██╔════╝ ██╔════╝ ╚══██╔══╝
+██║ ██╔██╗ ██║    ██║    █████╗   ██║      ██║      ██║   ██║      ██║   ██║ ██╔██╗ ██║ ██╔██╗ ██║ █████╗   ██║         ██║   
+██║ ██║╚██╗██║    ██║    ██╔══╝   ██║      ██║      ██║   ██║      ██║   ██║ ██║╚██╗██║ ██║╚██╗██║ ██╔══╝   ██║         ██║   
+██║ ██║ ╚████║    ██║    ███████╗ ███████╗ ███████╗ ██║   ╚██████╗ ╚██████╔╝ ██║ ╚████║ ██║ ╚████║ ███████╗ ╚██████╗    ██║   
+╚═╝ ╚═╝  ╚═══╝    ╚═╝    ╚══════╝ ╚══════╝ ╚══════╝ ╚═╝    ╚═════╝  ╚═════╝  ╚═╝  ╚═══╝ ╚═╝  ╚═══╝ ╚══════╝  ╚═════╝    ╚═╝   
+
+

Built by RSLLY

+
+ +
+ License + Release + CWL Project +
+ +## 概述 + +* 本项目基于springboot2.7开发,使用spring security作为安全框架 +* 配备物模型(属性,功能和事件模块)和完善的监控模块 +* 支持多种大模型和先进的Agent智能体技术提供出色的AI智能,可以快速搭建智能物联网应用(首个基于Agent智能体设计的物联网平台) +* 支持快速构建智能语音应用,支持语音识别和语音合成 +* 支持多种iot协议,使用emqx exhook作为mqtt通讯,可扩展性强 +* 支持OTA空中升级技术 +* 支持微信小程序和微信服务号 +* 支持小智AI硬件 +* 使用常见的mysql和redis数据库,上手简单 +* 支持时序数据库influxdb + +## 安装运行 + +
+

推荐使用docker安装,docker-compose.yaml文件在docker目录下,执行 docker-compose up 可初始化mysql,redis,emqx和influxdb环境,安装详情请看官方文档。

+
+ +* 安装mysql和redis数据库,高性能运行推荐安装时序数据库influxdb +* 安装EMQX集群,并配置好exhook,本项目使用exhook作为mqtt消息的处理器 +* 安装java17环境 +* 修改配置文件application.yaml(设置ddl-auto为update模式) +* java -jar IntelliConnect-1.8-SNAPSHOT.jar + +```bash +# 克隆仓库 +git clone https://github.com/ruanrongman/IntelliConnect +cd intelliconnect/docker + +# 启动所需环境(MySQL, Redis, EMQX, InfluxDB) +docker-compose up -d +``` + +## 项目特色 + +* 极简主义,层次分明,符合mvc分层结构 +* 完善的物模型抽象,使得iot开发者可以专注于业务本身 +* AI能力丰富,支持Agent智能体技术,快速开发AI智能应用 + +## 小智 ESP-32 后端服务(xiaozhi-esp32-server) + +
+

本项目能够为开源智能硬件项目 xiaozhi-esp32 提供后端服务。根据 小智通信协议 使用 Java 实现。

+

适合希望本地部署的用户,不同于单纯语音交互,本项目重点在于提供更强大的物联网和智能体能力。

+
+ +## 项目文档和视频演示 + +* 项目文档和视频演示地址:[https://ruanrongman.github.io/IntelliConnect/](https://ruanrongman.github.io/IntelliConnect/) +* 技术博客地址:[https://wordpress.rslly.top](https://wordpress.rslly.top) +* 社区地址:[https://github.com/cwliot](https://github.com/cwliot) +* 创万联社区公众号:微信直接搜索创万联 + +## 相关项目和社区 + +* **创万联(cwl)**: 专注于物联网和人工智能技术的开源社区。 +* **Promptulate**: [https://github.com/Undertone0809/promptulate](https://github.com/Undertone0809/promptulate) - A LLM application and Agent development framework. +* **Rymcu**: [https://github.com/rymcu](https://github.com/rymcu) - 为数百万人服务的开源嵌入式知识学习交流平台 + +## 致谢 + +* 感谢项目[xiaozhi-esp32](https://github.com/78/xiaozhi-esp32)提供强大的硬件语音交互。 +* 感谢项目[Concentus: Opus for Everyone](https://github.com/lostromb/concentus)提供opus解码和编码。 +* 感谢项目[TalkX](https://github.com/big-mouth-cn/talkx)提供了opus解码和编码的参考。 +* 感谢项目[py-xiaozhi](https://github.com/huangjunsen0406/py-xiaozhi)方便项目进行小智开发调试。 + +## 贡献 + +本人正在尝试一些更加完善的抽象模式,支持更多的物联网协议和数据存储形式,如果你有更好的建议,欢迎一起讨论交流。 + + \ No newline at end of file diff --git a/documents/docs/ecosystem/projects/open-xiaoai/images/logo.png b/documents/docs/ecosystem/projects/open-xiaoai/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b19bd4f49a2b05941a59891e7d0f6bca27fb9e2b --- /dev/null +++ b/documents/docs/ecosystem/projects/open-xiaoai/images/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b612b8a703a0c7b2111c89a9ca77b340873463d89da20b6c57714280d52c7369 +size 1108065 diff --git a/documents/docs/ecosystem/projects/open-xiaoai/index.md b/documents/docs/ecosystem/projects/open-xiaoai/index.md new file mode 100644 index 0000000000000000000000000000000000000000..c6ddc84f3ae0f0212e15918b692b2fe2f4742061 --- /dev/null +++ b/documents/docs/ecosystem/projects/open-xiaoai/index.md @@ -0,0 +1,459 @@ +--- +title: open-xiaoai +description: 让小爱音箱「听见你的声音」,解锁无限可能的开源项目 +--- + +# open-xiaoai + +
+ +
+ 跨平台 + Rust/Python/Node.js + 实验性 +
+
+ +
+ Open-XiaoAI 项目封面 +
+ +## 项目简介 + +Open-XiaoAI 是一个让小爱音箱"听见你的声音"的开源项目,将小爱音箱与小智AI生态系统无缝集成。该项目直接接管小爱音箱的"耳朵"和"嘴巴",通过多模态大模型和AI Agent技术,将小爱音箱的潜力完全释放,解锁无限可能。 + +2017年,当全球首款千万级销量的智能音箱诞生时,我们以为触摸到了未来。但很快发现,这些设备被困在「指令-响应」的牢笼里: + +- 它听得见分贝,却听不懂情感 +- 它能执行命令,却不会主动思考 +- 它有千万用户,却只有一套思维 + +我们曾幻想中的"贾维斯"级人工智能,在现实场景中沦为"闹钟+音乐播放器"。 + +**真正的智能不应被预设的代码逻辑所束缚,而应像生命体般在交互中进化。** + +在上一个 [MiGPT](https://github.com/idootop/mi-gpt) 项目的基础上,Open-XiaoAI再次进化,为小智生态系统提供了与小爱音箱交互的新方式。 + +## 核心功能 + +
+
+
🎤
+

语音输入接管

+

直接捕获小爱音箱的麦克风输入,绕过原有语音识别限制

+
+ +
+
🔊
+

声音输出控制

+

完全接管小爱音箱的扬声器,可以播放自定义音频和TTS内容

+
+ +
+
🧠
+

AI模型整合

+

支持接入小智AI、ChatGPT等多种大模型,实现自然对话体验

+
+ +
+
🌐
+

跨平台支持

+

Client端使用Rust开发,Server端支持Python和Node.js实现

+
+ +
+
🛠️
+

可扩展架构

+

模块化设计,方便开发者添加自定义功能和集成其他服务

+
+ +
+
🎮
+

开发者友好

+

详细的文档和教程,帮助开发者快速上手并定制自己的功能

+
+
+ +## 演示视频 + +
+ + + +
+ +## 快速开始 + +
+
⚠️
+
+ 重要提示 +

本教程仅适用于 小爱音箱 Pro(LX06)Xiaomi 智能音箱 Pro(OH2P) 这两款机型,其他型号的小爱音箱请勿直接使用!

+
+
+ +Open-XiaoAI项目由Client端和Server端两部分组成,您可以按照以下步骤快速开始: + +### 安装步骤 + +
+
+
1
+
+

小爱音箱固件更新

+

刷机更新小爱音箱补丁固件,开启并SSH连接到小爱音箱

+ 查看详细教程 +
+
+ +
+
2
+
+

客户端部署

+

在电脑上编译Client端补丁程序,然后复制到小爱音箱上运行

+ 查看详细教程 +
+
+ +
+
3
+
+

服务端部署

+

在电脑上运行Server端演示程序,体验小爱音箱的全新能力

+ +
+
+
+ +## 工作原理 + +Open-XiaoAI通过以下方式工作: + +1. **固件补丁**: 修改小爱音箱的固件,允许SSH访问和底层系统控制 +2. **音频流劫持**: 客户端程序直接捕获麦克风输入和控制扬声器输出 +3. **网络通信**: 客户端与服务端之间建立WebSocket连接进行实时通信 +4. **AI处理**: 服务端接收语音输入,交由AI模型处理后返回响应 +5. **自定义功能**: 开发者可以在服务端实现各种自定义功能和集成 + +## 相关项目 + +如果您不想刷机,或者不是小爱音箱Pro,以下项目可能对您有用: + +- [MiGPT](https://github.com/idootop/mi-gpt) - 将ChatGPT接入小爱音箱的原始项目 +- [MiGPT-Next](https://github.com/idootop/migpt-next) - MiGPT的下一代版本 +- [XiaoGPT](https://github.com/yihong0618/xiaogpt) - 另一个小爱音箱ChatGPT接入方案 +- [XiaoMusic](https://github.com/hanxi/xiaomusic) - 小爱音箱音乐播放增强 + +## 技术参考 + +如果您想了解更多技术细节,以下链接可能对您有帮助: + +- [xiaoai-patch](https://github.com/duhow/xiaoai-patch) - 小爱音箱固件补丁 +- [open-lx01](https://github.com/jialeicui/open-lx01) - 小爱音箱LX01开源项目 +- [小爱FM研究](https://javabin.cn/2021/xiaoai_fm.html) - 小爱音箱FM功能研究 +- [小米设备安全研究](https://github.com/yihong0618/gitblog/issues/258) - 小米IoT设备安全分析 +- [小爱音箱探索](https://xuanxuanblingbling.github.io/iot/2022/09/16/mi/) - 小爱音箱技术探索 + +## 免责声明 + +
+

适用范围

+

本项目为非盈利开源项目,仅限于技术原理研究、安全漏洞验证及非营利性个人使用。严禁用于商业服务、网络攻击、数据窃取、系统破坏等违反《网络安全法》及使用者所在地司法管辖区的法律规定的场景。

+ +

非官方声明

+

本项目由第三方开发者独立开发,与小米集团及其关联方(下称"权利方")无任何隶属/合作关系,未获其官方授权/认可或技术支持。项目中涉及的商标、固件、云服务的所有权利归属小米集团。若权利方主张权益,使用者应立即主动停止使用并删除本项目。

+ +

继续使用本项目,即表示您已完整阅读并同意用户协议,否则请立即终止使用并彻底删除本项目。

+
+ +## 许可证 + +本项目使用 [MIT](https://github.com/idootop/open-xiaoai/blob/main/LICENSE) 许可证 © 2024-PRESENT Del Wang + + \ No newline at end of file diff --git "a/documents/docs/ecosystem/projects/xiaozhi-android-client/images/\347\225\214\351\235\2421.jpg" "b/documents/docs/ecosystem/projects/xiaozhi-android-client/images/\347\225\214\351\235\2421.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..5308218a0dda1c2c909dfce8c04be0823234200b Binary files /dev/null and "b/documents/docs/ecosystem/projects/xiaozhi-android-client/images/\347\225\214\351\235\2421.jpg" differ diff --git "a/documents/docs/ecosystem/projects/xiaozhi-android-client/images/\347\225\214\351\235\2422.jpg" "b/documents/docs/ecosystem/projects/xiaozhi-android-client/images/\347\225\214\351\235\2422.jpg" new file mode 100644 index 0000000000000000000000000000000000000000..5f506a9fc53c6c5905180ca7b94d55bbb5ba248c --- /dev/null +++ "b/documents/docs/ecosystem/projects/xiaozhi-android-client/images/\347\225\214\351\235\2422.jpg" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:07f4a063c4e2551625ac604a4b716f92c29603475bac7514be7eaf38ed9919aa +size 149908 diff --git a/documents/docs/ecosystem/projects/xiaozhi-android-client/index.md b/documents/docs/ecosystem/projects/xiaozhi-android-client/index.md new file mode 100644 index 0000000000000000000000000000000000000000..a75aa41d53c7803eca220000b7ad6026b7686c65 --- /dev/null +++ b/documents/docs/ecosystem/projects/xiaozhi-android-client/index.md @@ -0,0 +1,500 @@ +--- +title: 小智手机端 +description: 基于Flutter的跨平台小智客户端,支持iOS、Android、Web等多平台 +--- + +# 小智手机客户端 + +
+ +
+ 多平台 + Flutter/Dart + 活跃开发中 +
+
+ +## 项目简介 + +小智手机客户端是基于Flutter框架开发的跨平台应用,为小智AI生态系统提供了移动端接入能力。通过一套代码,实现了在iOS、Android、Web、Windows、macOS和Linux等多个平台的部署,让用户随时随地都能与小智AI进行实时语音交互和文字对话。 + +
+
+ 应用展示 + +
+
+

最新版本客户端已全面升级,支持iOS与Android平台,并可自行打包为Web、PC版本。通过精心设计的UI和流畅的交互体验,为用户提供随时随地与小智AI交流的能力。

+
+
+ +## 核心功能 + +
+
+
📱
+

跨平台支持

+

使用Flutter开发,一套代码支持iOS、Android、Web、Windows、macOS和Linux等多平台

+
+ +
+
🤖
+

多AI模型集成

+

支持小智AI服务、Dify、OpenAI等多种AI服务,可随时切换不同模型

+
+ +
+
💬
+

丰富交互方式

+

支持实时语音对话、文字消息、图片消息,以及通话中手动打断功能

+
+ +
+
🔊
+

语音优化技术

+

实现安卓设备AEC+NS回音消除,提升语音交互质量

+
+ +
+
🎨
+

精美界面设计

+

轻度拟物化设计、流畅动画效果、自适应UI布局

+
+ +
+
⚙️
+

灵活配置选项

+

支持多种AI服务配置管理,可添加多个小智到聊天列表

+
+
+ +## 功能亮点 + +### 实时语音交互 + +
+
+ 实时语音交互 +
+
+

流畅的语音对话体验

+
    +
  • 实时语音识别和响应
  • +
  • 支持持续对话模式
  • +
  • 语音交互过程中支持手动打断
  • +
  • 按住说话快捷模式
  • +
  • 语音会话历史记录
  • +
+
+
+ +### 多AI服务支持 + +
+
+

灵活切换不同AI服务

+
    +
  • 集成小智WebSocket实时语音对话
  • +
  • 支持Dify平台接入
  • +
  • 支持OpenAI图文消息和流式输出
  • +
  • 支持官方小智服务一键设备注册
  • +
  • 可同时添加多个AI服务到对话列表
  • +
+
+
+ 多AI服务支持 +
+
+ +## 系统要求 + +- **Flutter**: ^3.7.0 +- **Dart**: ^3.7.0 +- **iOS**: 12.0+ +- **Android**: API 21+ (Android 5.0+) +- **Web**: 现代浏览器 + +## 安装与使用 + +### 安装方法 + +1. 克隆项目仓库: +```bash +git clone https://github.com/TOM88812/xiaozhi-android-client.git +``` + +2. 安装依赖: +```bash +flutter pub get +``` + +3. 运行应用: +```bash +flutter run +``` + +### 构建发布版本 + +```bash +# Android应用 +flutter build apk --release + +# iOS应用 +flutter build ios --release + +# Web应用 +flutter build web --release +``` + +> **注意**: iOS编译完成后,需要在设置-APP中打开网络权限 + +## 配置说明 + +应用支持灵活的服务配置管理,包括: + +### 小智服务配置 +- 支持配置多个小智服务地址 +- WebSocket URL设置 +- Token认证 +- 自定义MAC地址 + +### Dify API配置 +- 支持配置多个Dify服务 +- API密钥管理 +- 服务器URL配置 + +### OpenAI配置 +- API密钥设置 +- 模型选择 +- 参数调整 + +## 开发计划 + +
+
+
+
+

已实现功能

+
    +
  • 支持多种AI服务提供商
  • +
  • 支持OTA自动注册设备
  • +
  • 增强语音识别准确性
  • +
  • 实现文字和语音混合会话
  • +
  • 支持OpenAI接口图文交互
  • +
+
+
+ +
+
+
+

正在开发

+
    +
  • 深色/浅色主题适配
  • +
  • iOS平台回音消除实现
  • +
  • 本地ASR语音识别支持
  • +
  • 本地唤醒词功能
  • +
+
+
+ +
+
+
+

计划实现

+
    +
  • 支持IoT映射手机操作
  • +
  • 本地TTS实现
  • +
  • 支持MCP_Client
  • +
  • OpenAI接口联网搜索功能
  • +
+
+
+
+ +## 项目贡献 + +欢迎为小智手机客户端贡献代码或提交问题反馈: + +- 目前iOS端回音消除尚未实现,欢迎有经验的开发者PR +- 提交Bug、功能请求或改进建议 +- 分享您使用小智手机客户端的经验和案例 + +## 相关链接 + +- [项目GitHub仓库](https://github.com/TOM88812/xiaozhi-android-client) +- [演示视频](https://www.bilibili.com/video/BV1fgXvYqE61) +- [问题反馈](https://github.com/TOM88812/xiaozhi-android-client/issues) + + \ No newline at end of file diff --git a/documents/docs/ecosystem/projects/xiaozhi-esp32-server/images/logo.png b/documents/docs/ecosystem/projects/xiaozhi-esp32-server/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b37afb3909bc37a3814297801b6a84ad7cdb5123 Binary files /dev/null and b/documents/docs/ecosystem/projects/xiaozhi-esp32-server/images/logo.png differ diff --git a/documents/docs/ecosystem/projects/xiaozhi-esp32-server/index.md b/documents/docs/ecosystem/projects/xiaozhi-esp32-server/index.md new file mode 100644 index 0000000000000000000000000000000000000000..6ffdcf43170f1583cc03316724bd133de2c99992 --- /dev/null +++ b/documents/docs/ecosystem/projects/xiaozhi-esp32-server/index.md @@ -0,0 +1,475 @@ +--- +title: xiaozhi-esp32-server +description: 基于ESP32的小智开源服务端,轻量级且高效的语音交互服务 +--- + +# xiaozhi-esp32-server + +
+ +
+ ESP32 + Python + 活跃开发中 +
+
+ +
+

xiaozhi-esp32-server是为开源智能硬件项目xiaozhi-esp32提供的后端服务,根据小智通信协议使用Python实现,帮助您快速搭建小智服务器。

+
+ +## 适用人群 + +本项目需要配合ESP32硬件设备使用。如果您已经购买了ESP32相关硬件,且成功对接过虾哥部署的后端服务,并希望独立搭建自己的`xiaozhi-esp32`后端服务,那么本项目非常适合您。 + +
+

⚠️ 重要提示

+
    +
  1. 本项目为开源软件,与对接的任何第三方API服务商(包括但不限于语音识别、大模型、语音合成等平台)均不存在商业合作关系,不为其服务质量及资金安全提供任何形式的担保。建议使用者优先选择持有相关业务牌照的服务商,并仔细阅读其服务协议及隐私政策。本软件不托管任何账户密钥、不参与资金流转、不承担充值资金损失风险。
  2. +
  3. 本项目成立时间较短,还未通过网络安全测评,请勿在生产环境中使用。如果您在公网环境中部署学习本项目,请务必在配置文件config.yaml中开启防护。
  4. +
+
+ +## 核心特性 + +
+
+
🔄
+

通信协议

+

基于xiaozhi-esp32协议,通过WebSocket实现数据交互

+
+ +
+
💬
+

对话交互

+

支持唤醒对话、手动对话及实时打断,长时间无对话时自动休眠

+
+ +
+
🧠
+

意图识别

+

支持使用LLM意图识别、function call函数调用,减少硬编码意图判断

+
+ +
+
🌐
+

多语言识别

+

支持国语、粤语、英语、日语、韩语(默认使用FunASR)

+
+ +
+
🤖
+

LLM模块

+

支持灵活切换LLM模块,默认使用ChatGLMLLM,也可选用阿里百炼、DeepSeek、Ollama等

+
+ +
+
🔊
+

TTS模块

+

支持EdgeTTS(默认)、火山引擎豆包TTS等多种TTS接口,满足语音合成需求

+
+ +
+
📝
+

记忆功能

+

支持超长记忆、本地总结记忆、无记忆三种模式,满足不同场景需求

+
+ +
+
🏠
+

IOT功能

+

支持管理注册设备IOT功能,支持基于对话上下文语境下的智能物联网控制

+
+ +
+
🖥️
+

智控台

+

提供Web管理界面,支持智能体管理、用户管理、系统配置等功能

+
+
+ +## 部署方式 + +本项目提供两种部署方式,请根据您的具体需求选择: + +
+ + + + + + + + + + + + + + + + + + + + +
部署方式特点适用场景
最简化安装智能对话、IOT功能,数据存储在配置文件低配置环境,无需数据库
全模块安装智能对话、IOT、OTA、智控台,数据存储在数据库完整功能体验
+
+ +详细部署文档请参考: +- [Docker部署文档](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/Deployment.md) +- [源码部署文档](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/Deployment_all.md) + +## 支持平台列表 + +xiaozhi-esp32-server支持丰富的第三方平台和组件: + +### LLM 语言模型 + +
+

接口调用

+

支持平台:阿里百炼、火山引擎豆包、深度求索、智谱ChatGLM、Gemini、Ollama、Dify、Fastgpt、Coze

+

免费平台:智谱ChatGLM、Gemini

+

实际上,任何支持openai接口调用的LLM均可接入使用

+
+ +### TTS 语音合成 + +
+

接口调用

+

支持平台:EdgeTTS、火山引擎豆包TTS、腾讯云、阿里云TTS、CosyVoiceSiliconflow、TTS302AI、CozeCnTTS、GizwitsTTS、ACGNTTS、OpenAITTS

+

免费平台:EdgeTTS、CosyVoiceSiliconflow(部分)

+ +

本地服务

+

支持平台:FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、MinimaxTTS

+

免费平台:FishSpeech、GPT_SOVITS_V2、GPT_SOVITS_V3、MinimaxTTS

+
+ +### ASR 语音识别 + +
+

接口调用

+

支持平台:DoubaoASR

+ +

本地服务

+

支持平台:FunASR、SherpaASR

+

免费平台:FunASR、SherpaASR

+
+ +### 更多组件 + +- **VAD语音活动检测**:支持SileroVAD(本地免费使用) +- **记忆存储**:支持mem0ai(1000次/月额度)、mem_local_short(本地总结,免费) +- **意图识别**:支持intent_llm(通过大模型识别意图)、function_call(通过大模型函数调用完成意图) + +## 参与贡献 + +xiaozhi-esp32-server是一个活跃的开源项目,欢迎贡献代码或提交问题反馈: + +- [GitHub仓库](https://github.com/xinnan-tech/xiaozhi-esp32-server) +- [问题反馈](https://github.com/xinnan-tech/xiaozhi-esp32-server/issues) +- [致开发者的公开信](https://github.com/xinnan-tech/xiaozhi-esp32-server/blob/main/docs/contributor_open_letter.md) + + \ No newline at end of file diff --git a/documents/docs/ecosystem/projects/xiaozhi-unity/images/logo.png b/documents/docs/ecosystem/projects/xiaozhi-unity/images/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..29fc10d82a3106699db4b700ad91290c7507ad00 --- /dev/null +++ b/documents/docs/ecosystem/projects/xiaozhi-unity/images/logo.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:4dc8725108886c16402d57bd4222bf4b9d6832378cf2b57a868098620ad4431f +size 562328 diff --git "a/documents/docs/ecosystem/projects/xiaozhi-unity/images/\347\225\214\351\235\2421.png" "b/documents/docs/ecosystem/projects/xiaozhi-unity/images/\347\225\214\351\235\2421.png" new file mode 100644 index 0000000000000000000000000000000000000000..7389f7580031a77a5ae01bfac8d8c86904d0a895 --- /dev/null +++ "b/documents/docs/ecosystem/projects/xiaozhi-unity/images/\347\225\214\351\235\2421.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:e84e2ea2ab85d6f0f77d3631f5f426ea93a80a8a20293314d4386cc8d7b24765 +size 2147551 diff --git "a/documents/docs/ecosystem/projects/xiaozhi-unity/images/\347\225\214\351\235\2422.png" "b/documents/docs/ecosystem/projects/xiaozhi-unity/images/\347\225\214\351\235\2422.png" new file mode 100644 index 0000000000000000000000000000000000000000..2e34bdf9cb668926035f008af2d77a9ac57f210f --- /dev/null +++ "b/documents/docs/ecosystem/projects/xiaozhi-unity/images/\347\225\214\351\235\2422.png" @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:308d7fb47007ce8fb2c212e77e393894c7f92ea7ae4a44596514810755ddafbc +size 2431306 diff --git a/documents/docs/ecosystem/projects/xiaozhi-unity/index.md b/documents/docs/ecosystem/projects/xiaozhi-unity/index.md new file mode 100644 index 0000000000000000000000000000000000000000..08f494a6c674646bb7c82a7e0711b487d20f579e --- /dev/null +++ b/documents/docs/ecosystem/projects/xiaozhi-unity/index.md @@ -0,0 +1,504 @@ +--- +title: XiaoZhiAI_server32_Unity +description: 基于Unity的小智AI视觉交互服务,实现语音与Live2D多模态人机交互体验 +--- + +# XiaoZhiAI_server32_Unity + +
+ +
+ 跨平台 + C#/Unity + 活跃开发中 +
+
+ +## 项目简介 + +XiaoZhiAI_server32_Unity是一个基于Unity开发的AI应用程序,专注于提供高质量的语音交互和网络服务功能。本项目利用Unity的跨平台特性,支持多种设备和操作系统,包括PC、Android、iOS、WebGL和微信小程序,为用户提供流畅的AI语音与Live2D交互体验。 + +## 技术架构 + +XiaoZhiAI_server32_Unity基于以下技术栈构建: + +- **开发引擎**:Unity 2020.3或更高版本 +- **目标平台**:PC、Android、iOS、WebGL、微信小程序 +- **核心功能模块**: + - **语音交互系统**:实时语音识别、自然语言处理、语音合成 + - **Live2D交互**:服务端返回LLM表情交互Live2D + - **Mqtt硬件交互**:服务端functioncall处理IoT返回 + +- **依赖包**: + - OPUS解码SDK + - WebSocket网络通信库 + - YooAsset资源管理框架2.3.7版本 + - YuikFrameWork (YOO分支) + - Hycrl热更新框架 + + +## 核心功能 + +### 语音交互能力 + +
+
+
🎤
+

实时语音识别

+

支持多种语言的实时语音转文字,准确率高达95%以上

+
+ +
+
🧠
+

自然语言理解

+

基于深度学习的语义分析,精准理解用户意图

+
+ +
+
🔊
+

语音合成

+

自然流畅的语音输出,支持多种音色和语速调节

+
+ +
+
🤖
+

Live2D表情交互

+

根据LLM返回结果实现实时表情变化和情感表达

+
+ +
+
📱
+

IoT与Mqtt对接

+

通过functioncall实现智能家居设备控制和状态反馈

+
+ +
+
🔄
+

热更新支持

+

基于Hycrl框架的热更新能力,无需重装即可升级

+
+
+ +## 环境要求 + +### 开发环境 +- Unity版本:2020.3或更高 +- 操作系统:Windows 10/11(开发环境) + +### 运行环境 +- **PC平台**: + - 操作系统:Windows 10/11、macOS 10.14+ + - 处理器:Intel i5或同等性能 + - 内存:8GB以上 + - 显卡:支持DirectX 11 + +- **移动平台**: + - Android 6.0+ + - iOS 11.0+ + +- **Web平台**: + - 支持WebGL 2.0的现代浏览器 + +- **硬件要求**: + - 麦克风:支持16kHz采样率的高质量麦克风(语音交互) + - 网络:稳定的网络连接,建议5Mbps以上带宽 + +## 项目结构 + +``` +XiaoZhiAI_server32_Unity/ +├── Assets/ # Unity资源文件 +│ ├── Scenes/ # 场景文件 +│ ├── Scripts/ # 脚本文件 +│ │ ├── VoiceInteraction/ # 语音交互相关脚本 +│ │ ├── Networking/ # 网络通信相关脚本 +│ │ └── ... +│ ├── Prefabs/ # 预制体 +│ ├── Plugins/ # 第三方插件 +│ │ ├── VoiceSDK/ # 语音识别SDK +│ │ └── NetworkLibs/ # 网络库 +│ └── ... +├── Packages/ # 项目依赖包 +├── ProjectSettings/ # Unity项目设置 +└── README.md # 项目说明文档 +``` + +## 安装指南 + +### 开发者安装 + +1. 克隆仓库到本地: + ```bash + git clone https://gitee.com/vw112266/XiaoZhiAI_server32_Unity.git + ``` + +2. 安装依赖包: + - 手动引入YooAsset资源管理框架(v2.3.7):https://github.com/tuyoogame/YooAsset + - 手动引入YuikFrameWork-YOO分支:https://gitee.com/NikaidoShinku/YukiFrameWork + +3. 使用Unity Hub打开项目,并确保Unity版本兼容 + +### 用户安装 + +1. 从发布页下载对应平台的安装包 +2. 按照向导完成安装 +3. 启动应用并完成初始配置 + +## 功能特性展示 + +### Live2D交互 + +
+
+

表情丰富的Live2D模型

+
    +
  • 根据对话内容实时改变表情
  • +
  • 支持多种情感状态表达
  • +
  • 精准的口型同步
  • +
  • 自然的眨眼和头部动作
  • +
  • 可定制的角色形象
  • +
+
+
+ 演示界面 +
+
+ +### IoT智能控制 + +
+
+

家居设备智能控制

+
    +
  • 通过语音控制智能家居设备
  • +
  • 基于functioncall的智能意图识别
  • +
  • 支持多种MQTT协议设备
  • +
  • 设备状态实时反馈
  • +
  • 场景联动与自动化
  • +
+
+
+ 演示界面 +
+
+ +## 开发计划 + +
+
+
+
+

已完成功能

+
    +
  • 基础语音交互系统
  • +
  • Live2D模型集成
  • +
  • WebSocket网络通信
  • +
  • 基础MQTT支持
  • +
+
+
+ +
+
+
+

开发中功能

+
    +
  • 更多Live2D模型支持
  • +
  • 表情系统优化
  • +
  • 移动平台性能优化
  • +
  • 更多IoT设备支持
  • +
+
+
+ +
+
+
+

计划功能

+
    +
  • 微信小程序集成
  • +
  • AR互动体验
  • +
  • 多角色场景支持
  • +
  • 用户自定义模型系统
  • +
+
+
+
+ +## 贡献指南 + +我们欢迎社区开发者参与XiaoZhiAI_server32_Unity项目的开发: + +- 提交bug报告和功能建议 +- 贡献代码改进和新功能 +- 创建和分享Live2D模型 +- 优化性能和用户体验 +- 完善文档和教程 + +请参考我们的贡献指南,了解如何参与项目开发。 + +## 相关链接 + +- [项目仓库](https://gitee.com/vw112266/XiaoZhiAI_server32_Unity) + + \ No newline at end of file diff --git "a/documents/docs/guide/00_\346\226\207\346\241\243\347\233\256\345\275\225.md" "b/documents/docs/guide/00_\346\226\207\346\241\243\347\233\256\345\275\225.md" new file mode 100644 index 0000000000000000000000000000000000000000..2697573e56026476ad859d1a2fd3d0f84d3d5b6e --- /dev/null +++ "b/documents/docs/guide/00_\346\226\207\346\241\243\347\233\256\345\275\225.md" @@ -0,0 +1,47 @@ +# py-xiaozhi文档目录 + +本目录包含了 py-xiaozhi 项目 的全部功能文档,按功能模块进行划分,便于查阅和使用。 + +项目默认启用了以下模块:音乐、灯光、音量、相机 IoT 控制。 +其他模块可根据需求自行扩展与启用。 +视觉识别功能 需配置 智普大模型的 API Key 才可使用。 + +## 基础文档 + +- [01_系统依赖安装](01_系统依赖安装) - 各平台的系统依赖和Python环境配置 +- [02_配置说明](02_配置说明.md) - 配置文件结构、配置项说明和修改指南 +- [03_语音交互模式说明](03_语音交互模式说明) - 项目概述、基本使用说明和运行模式 +- [04_语音唤醒](04_语音唤醒.md) - 语音唤醒功能的配置和使用说明 +- [05_IoT功能说明](05_IoT功能说明.md) - 物联网功能架构、设备控制和扩展方法 +- [06_音量控制功能](06_音量控制功能.md) - 系统音量控制功能的使用和配置 +- [07_视觉识别功能](07_视觉识别功能.md) - 摄像头控制和视觉分析功能 +- [08_设备激活流程](08_设备激活流程) - 设备激活和注册流程说明 +- [09_打包教程](09_打包教程.md) - 使用UnifyPy打包小智客户端的详细教程 +- [TTS功能说明](TTS功能说明.md) - 文本转语音功能说明 + +## 其他文档 + +- [异常汇总](异常汇总.md) - 常见问题和解决方案 + +## 旧版文档 + +为了便于参考,我们保留了旧版文档: + +- [旧版使用文档](old_docs/使用文档.md) - 较早版本的使用说明文档 + +## 参与贡献 + +如果您想参与项目开发或提供反馈,请查看以下资源: + +- [贡献指南](/contributing) - 如何为项目贡献代码,包括开发流程、代码规范和PR提交流程 +- [团队成员](/about/team) - 感谢为项目做出贡献的团队成员 +- [赞助支持](/sponsors/) - 如何赞助项目发展 + +## 相关生态 + +- [相关生态](/ecosystem/) - py-xiaozhi项目的相关生态系统和扩展项目 + + +## 版本信息 + +文档最后更新时间:2025年4月 \ No newline at end of file diff --git "a/documents/docs/guide/01_\347\263\273\347\273\237\344\276\235\350\265\226\345\256\211\350\243\205.md" "b/documents/docs/guide/01_\347\263\273\347\273\237\344\276\235\350\265\226\345\256\211\350\243\205.md" new file mode 100644 index 0000000000000000000000000000000000000000..363d9408f865d2fede49f379811f4a908c949f87 --- /dev/null +++ "b/documents/docs/guide/01_\347\263\273\347\273\237\344\276\235\350\265\226\345\256\211\350\243\205.md" @@ -0,0 +1,225 @@ +# 系统依赖安装 +⚠️务必按照教程安装顺序进行软件、工具的安装 + +⚠️推荐使用conda环境进行安装,PyQt5、OpenCV可以直接使用conda预编译好的版本。pip在arm64 4GB及其以下设备上PyQt5、OpenCV极其容易编译失败无法安装 + + +## 系统依赖安装 +### Windows +1. **安装 FFmpeg** + ```bash + # 方法一:使用 Scoop 安装(推荐) + scoop install ffmpeg + + # 方法二:手动安装 + # 1. 访问 https://github.com/BtbN/FFmpeg-Builds/releases 下载 + # 2. 解压并将 bin 目录添加到系统 PATH + ``` + +2. **Opus 音频编解码库** + - 项目默认会自动引入 opus.dll,无需手动安装 + - 如遇问题,可将 `/libs/windows/opus.dll` 复制到以下位置之一: + - 应用程序目录 + - `C:\Windows\System32` + +### Linux (Debian/Ubuntu) +```bash +# 安装系统依赖 +sudo apt-get update +# 必装 +sudo apt-get install python3-pyaudio portaudio19-dev ffmpeg libopus0 libopus-dev build-essential python3-venv + +# 安装音量控制依赖(以下三选一) +# 1. PulseAudio 工具(推荐) +sudo apt-get install pulseaudio-utils + +# 2. 或者 ALSA 工具 +sudo apt-get install alsa-utils + +# 3. 如果需要使用 alsamixer 方式,还需要安装 expect +sudo apt-get install alsa-utils expect +``` + +### macOS +```bash +# 使用 Homebrew 安装系统依赖 +brew install portaudio opus python-tk ffmpeg gfortran +brew upgrade tcl-tk +``` + +## Python 依赖安装 + +--- + +### 方式一:使用 Miniconda(推荐) + +### 1. 下载 Miniconda 安装包( +根据你的系统架构或操作系统选择下载命令: + +| 系统 / 架构 | 下载指令 | +|:-----------|:---------| +| **Linux - x86_64**(PC/服务器常用) | ```bash wget -O Miniconda3-latest-Linux-x86_64.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-x86_64.sh ``` | +| **Linux - aarch64**(ARM64,比如树莓派、部分服务器) | ```bash wget -O Miniconda3-latest-Linux-aarch64.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-aarch64.sh ``` | +| **Linux - ppc64le**(IBM Power服务器) | ```bash wget -O Miniconda3-latest-Linux-ppc64le.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-Linux-ppc64le.sh ``` | +| **Windows - x86_64**(普通Windows PC) | [点击下载](https://repo.anaconda.com/miniconda/Miniconda3-latest-Windows-x86_64.exe) | +| **Windows - arm64**(ARM Windows设备,如Surface Pro X) | [点击下载](https://repo.anaconda.com/miniconda/Miniconda3-latest-Windows-arm64.exe) | +| **macOS - x86_64**(Intel芯片Mac) | ```bash wget -O Miniconda3-latest-MacOSX-x86_64.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-x86_64.sh ``` | +| **macOS - arm64**(Apple Silicon芯片,如M1/M2/M3) | ```bash wget -O Miniconda3-latest-MacOSX-arm64.sh https://repo.anaconda.com/miniconda/Miniconda3-latest-MacOSX-arm64.sh ``` | + +--- + +### 2. 给安装脚本添加执行权限(仅Linux/macOS) + +```bash +chmod +x Miniconda3-latest-*.sh +``` + +### 3. 运行安装程序(仅Linux/macOS) + +```bash +./Miniconda3-latest-*.sh +``` +⚡ *注意:Linux/macOS下不需要用`sudo`,用普通用户安装即可。* + +--- + +### 4. 安装过程中 (windows请按照网络搜索到的教程来) + +1. 出现许可协议 → 按 `Enter` 键慢慢翻,或按 `q` 直接跳过。 +2. 输入 `yes` 接受协议。 +3. 选择安装路径,默认是 `$HOME/miniconda3` → 直接按 `Enter` 确认。 +4. 是否初始化 Miniconda → 输入 `yes`(推荐)。 + +--- + +### 5. 配置环境变量(如果未自动配置) + +Linux/macOS 编辑 `.bashrc`: + +```bash +nano ~/.bashrc +``` + +在文件末尾添加: + +```bash +export PATH="$HOME/miniconda3/bin:$PATH" +``` + +保存并退出: +- 按 `Ctrl + X` +- 按 `Y` +- 按 `Enter` + +让配置立即生效: + +```bash +source ~/.bashrc +``` + +--- + +### 6. 检查 conda 安装成功 + +```bash +conda --version +``` +如果看到版本号,比如 `conda 24.1.2`,就代表安装成功! + +--- + +### 7. 初始化conda(可选但推荐) + +```bash +conda init +bash +``` + +然后打开新终端,看到 `(base)`,说明环境正常激活。 + +--- + +### 8. (推荐)关闭开机自动激活 base 环境 + +```bash +conda config --set auto_activate_base false +``` + +这样以后打开终端是干净的,需要的时候再手动 `conda activate base`。 + +### 一键自动更换当前网络下最快pip软件源 + +* **为了更稳定更快速的安装所需依赖包,建议安装** + +[工具地址|chsrc 全平台通用换源工具|GitHub仓库](https://github.com/RubyMetric/chsrc) + +```bash +# 个人服务器,加速下载,支持x86_64和arm64架构 +wget -O- aslant.top/chsrc.sh|sudo bash + +# 更换pip软件源 +chsrc set pip +``` + +## 安装项目依赖 + +### 1. 创建 Conda 环境 +```bash +conda create -n py-xiaozhi python=3.10 -y +``` + +### 2. 激活环境 +```bash +conda activate py-xiaozhi +``` + +### 3. 安装 Python 依赖 +```bash +# Windows/Linux +pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple +# macOS +pip install -r requirements_mac.txt -i https://mirrors.aliyun.com/pypi/simple +``` + +* 安装其它依赖(**因为pip安装这两个可能会启动不了。需要在conda单独安装**) +```bash +# 依然在创建的px-xiaozhi虚拟环境中 +# PyQt5 +conda install pyqt=5.15.10 -y + +# OpenCV +conda install opencv=4.10.0 -y +``` + + +--- + +### 方式二:使用 venv +```bash +# 1. 创建虚拟环境 +python -m venv .venv + +# 2. 激活虚拟环境 +# Windows +.venv\Scripts\activate +# Linux/macOS +source .venv/bin/activate + +# 3. 安装依赖 +# Windows/Linux +pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple +# macOS +pip install -r requirements_mac.txt -i https://mirrors.aliyun.com/pypi/simple + +# 由于requirements 去除了这两个默认的需要单独安装 +pip install PyQt5==5.15.9 opencv-python==4.11.0.86 -i https://mirrors.aliyun.com/pypi/simple +``` + +## 注意事项 +1. 建议使用 Python 3.9.13+ 版本,推荐 3.10 最大版本3.12(不行就降级) +2. Windows 用户无需手动安装 opus.dll,项目会自动处理 +3. 使用 Conda 环境时必须安装 ffmpeg 和 Opus +4. 使用 Conda 环境时请勿和esp32-server共用同一个Conda环境,因为服务端websocket依赖版本高于本项目 +5. 建议使用国内镜像源安装依赖,可以提高下载速度 +6. macOS 用户需使用专门的 requirements_mac.txt +7. 确保系统依赖安装完成后再安装 Python 依赖 \ No newline at end of file diff --git "a/documents/docs/guide/02_\351\205\215\347\275\256\350\257\264\346\230\216.md" "b/documents/docs/guide/02_\351\205\215\347\275\256\350\257\264\346\230\216.md" new file mode 100644 index 0000000000000000000000000000000000000000..afa678cdad597c096e0b9e2bb12670e63eec823b --- /dev/null +++ "b/documents/docs/guide/02_\351\205\215\347\275\256\350\257\264\346\230\216.md" @@ -0,0 +1,102 @@ +# 配置说明 + +## 项目基础配置 + +### 配置文件说明 +项目使用两种配置方式:初始配置模板和运行时配置文件。 + +1. **初始配置模板** + - 位置:`/src/utils/config_manager.py` + - 作用:提供默认配置模板,首次运行时会自动生成配置文件 + - 使用场景:首次运行或需要重置配置时修改此文件 + +2. **运行时配置文件** + - 位置:`/config/config.json` + - 作用:存储实际运行时的配置信息 + - 使用场景:日常使用时修改此文件 + +### 配置项说明 +- 需要什么加什么配置通过config_manager去获取就行了,参考websocket或iot\things\temperature_sensor.py +- 例如获取 "MQTT_INFO"的"endpoint" , 通过这样 `config.get_config("SYSTEM_OPTIONS.NETWORK.MQTT_INFO.endpoint")`就能拿到**endpoint** + +```json +{ + "SYSTEM_OPTIONS": { + "CLIENT_ID": "", // 自动生成的客户端ID + "DEVICE_ID": "", // 设备MAC地址 + "NETWORK": { + "OTA_VERSION_URL": "https://api.tenclass.net/xiaozhi/ota/", // OTA更新地址 + "WEBSOCKET_URL": "ws://192.168.31.232:8000/xiaozhi/v1/", // WebSocket服务器地址 + "WEBSOCKET_ACCESS_TOKEN": "test-token", // 访问令牌 + "MQTT_INFO": { + "endpoint": "", // MQTT服务器地址 + "client_id": "", // MQTT客户端ID + "username": "", // MQTT用户名 + "password": "", // MQTT密码 + "publish_topic": "", // 发布主题 + "subscribe_topic": "" // 订阅主题 + } + } + }, + "WAKE_WORD_OPTIONS": { + "USE_WAKE_WORD": false, // 是否启用语音唤醒 + "MODEL_PATH": "models/vosk-model-small-cn-0.22", // 唤醒模型路径 + "WAKE_WORDS": [ // 唤醒词列表 + "小智", + "小美" + ] + }, + "TEMPERATURE_SENSOR_MQTT_INFO": { + "endpoint": "你的Mqtt连接地址", // MQTT服务器地址 + "port": 1883, // MQTT服务器端口 + "username": "admin", // MQTT用户名 + "password": "123456", // MQTT密码 + "publish_topic": "sensors/temperature/command", // 发布主题 + "subscribe_topic": "sensors/temperature/device_001/state" // 订阅主题 + }, + "CAMERA": { + "camera_index": 0, // 摄像头索引 + "frame_width": 640, // 画面宽度 + "frame_height": 480, // 画面高度 + "fps": 30, // 帧率 + "Loacl_VL_url": "https://open.bigmodel.cn/api/paas/v4/", // 智普API地址 + "VLapi_key": "你自己的key", // 智普视觉大模型API密钥 + "models": "glm-4v-plus" // 使用的视觉模型 + } +} +``` + +### 配置修改指南 + +1. **首次使用配置** + - 直接运行程序,系统会自动生成默认配置文件 + - 如需修改默认值,可编辑 `config_manager.py` 中的 `DEFAULT_CONFIG` + +2. **更换服务器配置** + - 打开 `/config/config.json` + - 修改 `SYSTEM_OPTIONS.NETWORK.WEBSOCKET_URL` 为新的服务器地址 + - 示例: + ```json + "SYSTEM_OPTIONS": { + "NETWORK": { + "WEBSOCKET_URL": "ws://你的服务器地址:端口号/" + } + } + ``` + +3. **启用语音唤醒** + - 修改 `WAKE_WORD_OPTIONS.USE_WAKE_WORD` 为 `true` + - 可在 `WAKE_WORD_OPTIONS.WAKE_WORDS` 数组中添加或修改唤醒词 + +4. **配置摄像头与视觉识别** + - 修改 `CAMERA` 部分的相关配置 + - 将 `VLapi_key` 设置为你从智普AI平台获取的API密钥 + - 可以根据需要调整分辨率和帧率 + +### 注意事项 +- 修改配置文件后需要重启程序才能生效 +- WebSocket URL 必须以 `ws://` 或 `wss://` 开头 +- 首次运行时会自动生成 CLIENT_ID,建议不要手动修改 +- DEVICE_ID 默认使用设备MAC地址,可按需修改 +- 配置文件使用 UTF-8 编码,请使用支持 UTF-8 的编辑器修改 +- 请妥善保管API密钥等敏感信息 \ No newline at end of file diff --git "a/documents/docs/guide/03_\350\257\255\351\237\263\344\272\244\344\272\222\346\250\241\345\274\217\350\257\264\346\230\216.md" "b/documents/docs/guide/03_\350\257\255\351\237\263\344\272\244\344\272\222\346\250\241\345\274\217\350\257\264\346\230\216.md" new file mode 100644 index 0000000000000000000000000000000000000000..d76621de3ff7618b154baf5c429d4481610c01ef --- /dev/null +++ "b/documents/docs/guide/03_\350\257\255\351\237\263\344\272\244\344\272\222\346\250\241\345\274\217\350\257\264\346\230\216.md" @@ -0,0 +1,119 @@ +# 语音交互模式说明 + +![Image](https://github.com/user-attachments/assets/df8bd5d2-a8e6-4203-8084-46789fc8e9ad) + +## 项目概述 + +py-xiaozhi是一个智能语音交互助手,支持多种操作模式和功能,包括语音对话、物联网设备控制、视觉识别等功能。本文档主要介绍语音交互的基本使用方法。 + +## 语音交互模式 + +语音交互支持两种模式,您可以根据实际需求选择合适的交互方式: + +### 1. 长按对话模式 + +- **操作方法**:按住说话按钮,松手发送 +- **适用场景**:短句交流,精确控制对话开始和结束时间 +- **优点**:避免误触发,控制精确 +- **快捷键**:F2(GUI模式下) + +### 2. 自动对话模式 + +- **操作方法**:点击开始对话,系统自动检测语音并发送 +- **适用场景**:长句交流,无需手动控制 +- **优点**:解放双手,自然交流 +- **界面提示**:显示"聆听中"表示系统正在接收您的语音 +- **快捷键**:F2(CLI模式下)或界面按钮 + +### 模式切换 + +- 在GUI界面右下角显示当前模式 +- 点击按钮可以切换模式 +- 可以通过配置文件设置默认模式 + +## 对话控制 + +### 打断功能 + +当系统正在语音回复时,您可以随时打断: +- **GUI模式**:使用F3键或界面上的打断按钮 +- **CLI模式**:使用F3键 + +### 状态流转 + +语音交互系统有以下几种状态: + +``` + +----------------+ + | | + v | ++------+ 唤醒词/按钮 +------------+ | +------------+ +| IDLE | -----------> | CONNECTING | --+-> | LISTENING | ++------+ +------------+ +------------+ + ^ | + | | 语音识别完成 + | +------------+ v + +--------- | SPEAKING | <-----------------+ + 完成播放 +------------+ +``` + +- **IDLE**:空闲状态,等待唤醒词或按钮触发 +- **CONNECTING**:正在连接服务器 +- **LISTENING**:正在聆听用户语音 +- **SPEAKING**:系统正在语音回复 + +## 语音命令 + +系统支持多种语音命令,以下是常用命令示例: + +### 基础交互 +- "你好"/"你是谁" - 基础打招呼和身份询问 +- "谢谢"/"再见" - 礼貌用语 + +### 物联网控制 +- "打开/关闭客厅的灯" - 控制灯光 +- "播放 菊花台 通过iot音乐播放器播放" - 开始播放音乐 + +### 视觉识别 +- "打开摄像头" - 开启摄像头 +- "识别画面" - 分析当前画面 +- "识别到了什么" - ai播放识别的内容 +- "关闭摄像头" - 关闭摄像头 + +## 运行模式 + +### GUI 模式运行(默认) +```bash +python main.py +``` + +### CLI模式运行 +```bash +python main.py --mode cli +``` + +### 构建打包 + +使用PyInstaller打包为可执行文件: + +```bash +# 各平台通用命令 +python scripts/build.py +``` + +## 最佳实践 + +1. **清晰发音**:确保在安静环境中清晰发音 +2. **适当停顿**:句子间适当停顿有助于系统识别 +3. **使用唤醒词**:开启唤醒词功能,可以避免误触发 +4. **查看反馈**:注意界面状态提示,了解系统当前状态 +5. **简洁命令**:使用简洁明了的命令获得更好的识别效果 + +## 获取帮助 + +如果遇到问题: + +1. 优先查看 docs/异常汇总.md 文档 +2. 通过 GitHub Issues 提交问题 +3. 通过 AI 助手寻求帮助 +4. 联系作者(主页有微信)(请自备 Todesk 链接并说明来意,作者工作日晚上处理) \ No newline at end of file diff --git "a/documents/docs/guide/04_\350\257\255\351\237\263\345\224\244\351\206\222.md" "b/documents/docs/guide/04_\350\257\255\351\237\263\345\224\244\351\206\222.md" new file mode 100644 index 0000000000000000000000000000000000000000..f2e123d2ca42cc8fab343f39737e2de23cb8f6f7 --- /dev/null +++ "b/documents/docs/guide/04_\350\257\255\351\237\263\345\224\244\351\206\222.md" @@ -0,0 +1,47 @@ +# 语音唤醒功能 + +## 唤醒词模型 + +使用语音唤醒功能需要下载和配置唤醒词模型: + +- [唤醒词模型下载](https://alphacephei.com/vosk/models) +- 下载完成后解压放至根目录/models +- 默认读取vosk-model-small-cn-0.22小模型 +- ![Image](./images/唤醒词.png) + +## 启用语音唤醒 + +1. 打开配置文件 `/config/config.json` +2. 修改 `WAKE_WORD_OPTIONS.USE_WAKE_WORD` 设置为 `true` +3. 可以在 `WAKE_WORD_OPTIONS.WAKE_WORDS` 数组中自定义唤醒词 +4. 确保 `WAKE_WORD_OPTIONS.MODEL_PATH` 设置正确,指向您下载的模型 + +示例配置: +```json +{ + "WAKE_WORD_OPTIONS": { + "USE_WAKE_WORD": true, + "MODEL_PATH": "models/vosk-model-small-cn-0.22", + "WAKE_WORDS": [ + "小智", + "你好小智", + "嘿小智" + ] + } +} +``` + +## 使用方法 + +1. 启动程序后,系统会加载唤醒词模型并自动进入唤醒词监听状态 +2. 说出您设置的唤醒词(如"小智"),系统会自动从IDLE状态切换到LISTENING状态 +3. 此时可以继续说出您的指令 +4. 如果您未说出任何指令,系统会在一段时间后自动回到唤醒词监听状态 + +## 注意事项 + +1. 唤醒词模型加载需要一定时间,请耐心等待 +2. 唤醒词识别准确度取决于模型质量和环境噪音 +3. 可以尝试不同大小的模型,小模型速度快但准确度较低 +4. 考虑使用独特的唤醒词以避免误触发 +5. 使用唤醒词功能会略微增加系统资源占用 \ No newline at end of file diff --git "a/documents/docs/guide/05_IoT\345\212\237\350\203\275\350\257\264\346\230\216.md" "b/documents/docs/guide/05_IoT\345\212\237\350\203\275\350\257\264\346\230\216.md" new file mode 100644 index 0000000000000000000000000000000000000000..78d58a7f15d198264767cc1d0420ca6cde0a6ae0 --- /dev/null +++ "b/documents/docs/guide/05_IoT\345\212\237\350\203\275\350\257\264\346\230\216.md" @@ -0,0 +1,825 @@ +# IoT功能说明 + +## 概述 + +py-xiaozhi项目中的IoT(物联网)模块提供了一个灵活、可扩展的设备控制框架,支持通过语音命令控制多种虚拟和物理设备。本文档详细介绍IoT模块的架构、使用方法以及如何扩展自定义设备。 +如需执行完立马同步状态和播报结果请参考相机模块和温湿度模块 + +## 核心架构 + +IoT模块采用分层设计,由以下主要组件构成: + +``` +├── iot # IoT设备相关模块 +│ ├── things # 具体设备实现目录 +│ │ ├── lamp.py # 灯设备实现 +│ │ ├── speaker.py # 音量控制实现 +│ │ ├── music_player.py # 音乐播放器实现 +│ │ ├── CameraVL/ # 摄像头与视觉识别集成设备 +│ │ ├── temperature_sensor.py# 温度传感器示例实现 +│ │ └── query_bridge_rag.py # RAG检索桥接设备 +│ ├── thing.py # IoT设备基类和工具类定义 +│ │ ├── Thing # IoT设备抽象基类 +│ │ ├── Property # 设备属性类 +│ │ ├── Parameter # 设备方法参数类 +│ │ └── Method # 设备方法类 +│ └── thing_manager.py # IoT设备管理器 +│ └── ThingManager # 单例模式实现的设备管理器 +``` + +### 核心类说明 + +1. **Thing(设备基类)**: + - 所有IoT设备的抽象基类 + - 提供属性和方法的注册机制 + - 提供状态和描述的JSON序列化 + +2. **Property(属性类)**: + - 定义设备的可变状态(如开/关、亮度等) + - 支持布尔、数字和字符串三种基本类型 + - 使用getter回调实时获取设备状态 + +3. **Method(方法类)**: + - 定义设备可执行的操作(如打开、关闭等) + - 支持带参数的方法调用 + - 通过callback处理具体操作实现 + +4. **Parameter(参数类)**: + - 定义方法的参数规范 + - 包含名称、描述、类型和是否必需等信息 + +5. **ThingManager(设备管理器)**: + - 集中管理所有IoT设备实例 + - 处理设备注册和命令分发 + - 提供设备描述和状态查询接口 + +## 命令处理流程 + +以下是语音命令被处理并执行IoT设备控制的完整流程: + +``` + +-------------------+ + | 用户语音指令 | + +-------------------+ + | + v + +-------------------+ + | 语音识别 | + | (STT) | + +-------------------+ + | + v + +-------------------+ + | 语义理解 | + | (LLM) | + +-------------------+ + | + v + +-------------------+ + | 物联网命令生成 | + +-------------------+ + | + v ++------------------------------+ | +------------------------------+ +| WebSocket服务端处理 | | | Application._handle_iot_message() +| <--------+-------> | ++------------------------------+ +------------------------------+ + | + v + +------------------------------+ + | ThingManager.invoke() | + +------------------------------+ + | + +-------------------------+----------+------------+ + | | | + v v v + +---------------+-------+ +------------+---------+ +---------+----------+ + | Lamp | | Speaker | | MusicPlayer | + | (控制灯设备) | | (控制系统音量) | | (音乐播放器) | + +---------------+-------+ +------------+---------+ +---------+----------+ + | | | + v v v + +---------------+-------+ +------------+---------+ +---------+----------+ + | 执行设备相关操作 | | 执行设备相关操作 | | 执行设备相关操作 | + +---------------+-------+ +------------+---------+ +---------+----------+ + | | | + +-------------------------+-----------------------+ + | + v + +-----------------------------+ + | 更新设备状态 | + | Application._update_iot_states() + +-----------------------------+ + | + v + +-----------------------------+ + | 发送状态更新到服务器 | + | send_iot_states() | + +-----------------------------+ + | + v + +-----------------------------+ + | 语音或界面反馈结果 | + +-----------------------------+ +``` + +## 内置设备说明 + +### 1. 灯设备 (Lamp) + +虚拟灯设备,用于演示基本的IoT控制功能。 + +**属性**: +- `power`:灯的开关状态(布尔值) + +**方法**: +- `TurnOn`:打开灯 +- `TurnOff`:关闭灯 + +**语音命令示例**: +- "打开灯" +- "关闭灯" + +### 2. 系统音量控制 (Speaker) + +控制系统音量的设备,可调整应用程序的音量大小。 + +**属性**: +- `volume`:当前音量值(0-100) + +**方法**: +- `SetVolume`:设置音量级别 + +**语音命令示例**: +- "把音量调到50%" +- "音量调小一点" +- "音量调大" + +### 3. 音乐播放器 (MusicPlayer) + +功能丰富的在线音乐播放器,支持歌曲搜索、播放控制和歌词显示。 + +**属性**: +- `current_song`:当前播放的歌曲 +- `playing`:播放状态 +- `total_duration`:歌曲总时长 +- `current_position`:当前播放位置 +- `progress`:播放进度 + +**方法**: +- `Play`:播放指定歌曲 +- `Pause`:暂停播放 +- `GetDuration`:获取播放信息 + +**语音命令示例**: +- "播放音乐周杰伦的稻香,通过iot音乐播放器播放" +- "暂停播放" +- "播放下一首" + +### 4. 摄像头与视觉识别 (CameraVL) + +集成摄像头控制和视觉识别功能,可以捕获画面并进行智能分析。 + +**功能**: +- 摄像头开启/关闭 +- 画面智能识别 +- 视觉内容分析 + +**语音命令示例**: +- "打开摄像头" +- "识别画面" +- "关闭摄像头" + +## 扩展自定义设备 + +要添加新的IoT设备,需要遵循以下步骤: + +### 1. 创建设备类 + +在`src/iot/things/`目录下创建新的Python文件,定义设备类: + +```python +from src.iot.thing import Thing, Parameter, ValueType + +class MyCustomDevice(Thing): + """ + 自定义IoT设备实现示例 + + 此类演示了如何创建一个符合项目IoT架构的自定义设备, + 包括属性定义、方法注册以及实际功能实现 + """ + + def __init__(self): + # 调用父类初始化方法,设置设备名称和描述 + # 第一个参数是设备ID(全局唯一),第二个参数是对设备的描述文本 + super().__init__("MyCustomDevice", "自定义设备描述") + + # 设备状态变量定义 + self.status = False # 定义设备的开关状态,初始为关闭(False) + self.parameter_value = 0 # 定义设备的参数值,初始为0 + self.last_update_time = 0 # 记录最后一次状态更新的时间戳 + + # 设备初始化日志 + print("[IoT设备] 自定义设备初始化完成") + + # ========================= + # 注册设备属性(状态值) + # ========================= + + # 注册status属性,使其可被查询 + # 参数1: 属性名称 - 在JSON中显示的键名 + # 参数2: 属性描述 - 对此属性的解释说明 + # 参数3: getter回调函数 - 用于实时获取属性值的lambda函数 + self.add_property("status", "设备开关状态(True为开启,False为关闭)", + lambda: self.status) + + # 注册parameter_value属性 + self.add_property("parameter_value", "设备参数值(0-100)", + lambda: self.parameter_value) + + # 注册last_update_time属性 + self.add_property("last_update_time", "最后一次状态更新时间", + lambda: self.last_update_time) + + # ========================= + # 注册设备方法(可执行的操作) + # ========================= + + # 注册TurnOn方法,用于打开设备 + # 参数1: 方法名称 - 用于API调用的标识符 + # 参数2: 方法描述 - 对此方法功能的说明 + # 参数3: 参数列表 - 空列表表示无参数 + # 参数4: 回调函数 - 执行实际功能的lambda函数,调用内部的_turn_on方法 + self.add_method( + "TurnOn", # 方法名称 + "打开设备", # 方法描述 + [], # 无参数 + lambda params: self._turn_on() # 回调函数,调用内部的_turn_on方法 + ) + + # 注册TurnOff方法,用于关闭设备 + self.add_method( + "TurnOff", + "关闭设备", + [], + lambda params: self._turn_off() + ) + + # 注册SetParameter方法,用于设置参数值 + # 此方法需要一个参数value + self.add_method( + "SetParameter", + "设置设备参数值(范围0-100)", + # 定义方法所需参数: + [ + # 创建参数对象: + # 参数1: 参数名称 - API中的参数键名 + # 参数2: 参数描述 - 对此参数的说明 + # 参数3: 参数类型 - 值类型(NUMBER表示数字类型) + # 参数4: 是否必需 - True表示此参数必须提供 + Parameter("value", "参数值(0-100之间的数字)", ValueType.NUMBER, True) + ], + # 回调函数 - 从params字典中提取参数值并传递给_set_parameter方法 + lambda params: self._set_parameter(params["value"].get_value()) + ) + + # 注册GetStatus方法,用于获取设备状态信息 + self.add_method( + "GetStatus", + "获取设备完整状态信息", + [], # 无参数 + lambda params: self._get_status() + ) + + # ========================= + # 内部方法实现(实际功能) + # ========================= + + def _turn_on(self): + """ + 打开设备的内部实现方法 + + 返回: + dict: 包含操作状态和消息的字典 + """ + self.status = True # 修改设备状态为开启 + self.last_update_time = int(time.time()) # 更新状态变更时间 + + # 这里可以添加实际的硬件控制代码,如GPIO操作、串口通信等 + print(f"[IoT设备] 自定义设备已打开") + + # 返回操作结果,包含状态和消息 + return { + "status": "success", # 操作状态: success或error + "message": "设备已打开" # 操作结果消息 + } + + def _turn_off(self): + """ + 关闭设备的内部实现方法 + + 返回: + dict: 包含操作状态和消息的字典 + """ + self.status = False # 修改设备状态为关闭 + self.last_update_time = int(time.time()) # 更新状态变更时间 + + # 这里可以添加实际的硬件控制代码 + print(f"[IoT设备] 自定义设备已关闭") + + # 返回操作结果 + return { + "status": "success", + "message": "设备已关闭" + } + + def _set_parameter(self, value): + """ + 设置设备参数值的内部实现方法 + + 参数: + value (float): 要设置的参数值 + + 返回: + dict: 包含操作状态和消息的字典 + + 异常: + ValueError: 如果参数值超出有效范围 + """ + # 参数值验证 + if not isinstance(value, (int, float)): + return {"status": "error", "message": "参数必须是数字"} + + if not 0 <= value <= 100: + return {"status": "error", "message": "参数值必须在0-100之间"} + + # 设置参数值 + self.parameter_value = value + self.last_update_time = int(time.time()) # 更新状态变更时间 + + # 这里可以添加实际的参数设置代码 + print(f"[IoT设备] 自定义设备参数已设置为: {value}") + + # 返回操作结果 + return { + "status": "success", + "message": f"参数已设置为 {value}", + "value": value + } + + def _get_status(self): + """ + 获取设备完整状态的内部实现方法 + + 返回: + dict: 包含设备所有状态信息的字典 + """ + # 返回设备的完整状态信息 + return { + "status": "success", + "device_status": { + "is_on": self.status, + "parameter": self.parameter_value, + "last_update": self.last_update_time + } + } + +### 2. 注册设备 + +在程序启动时注册设备到ThingManager: + +```python +# 在Application._initialize_iot_devices方法中 +from src.iot.thing_manager import ThingManager +from src.iot.things.my_custom_device import MyCustomDevice +from src.utils.logging_config import get_logger + +# 获取日志记录器实例 +logger = get_logger(__name__) + +def _initialize_iot_devices(self): + """ + 初始化并注册所有IoT设备 + 此方法在应用程序启动时被调用 + """ + # 记录日志:开始初始化IoT设备 + logger.info("开始初始化IoT设备...") + + # 获取设备管理器单例实例 + # ThingManager使用单例模式,确保全局只有一个管理器实例 + thing_manager = ThingManager.get_instance() + + # 创建自定义设备实例 + my_device = MyCustomDevice() + + # 将设备实例添加到设备管理器 + # 一旦添加,设备将可以通过API和语音命令访问 + thing_manager.add_thing(my_device) + + # 记录成功添加设备的日志 + logger.info(f"已添加自定义设备: {my_device.name}") + + # 可以在这里继续添加其他设备... + + # 记录设备初始化完成的日志 + logger.info(f"IoT设备初始化完成,共注册了 {len(thing_manager.things)} 个设备") +``` + +### 3. 设备通信(可选) + +如果设备需要与实体硬件通信,可以通过各种协议实现: + +- MQTT:用于与标准物联网设备通信 +- HTTP:用于REST API调用 +- 串口/GPIO:用于直接硬件控制 + +## 使用示例 + +### 基本设备控制 + +1. 启动应用程序 +2. 使用语音指令"打开灯" +3. 系统识别指令并执行lamp.py中的TurnOn方法 +4. 灯设备状态更新,反馈给用户"灯已打开" + +### 音乐播放控制 + +1. 使用指令"播放音乐周杰伦的稻香,通过iot音乐播放器播放" +2. 系统解析指令并调用MusicPlayer的Play方法 +3. 播放器搜索歌曲,开始播放,并显示歌词 +4. 可以继续使用"暂停播放"等命令控制播放 + +## 注意事项 + +1. 设备属性更新后,会自动通过WebSocket推送状态到服务端和UI界面 +2. 设备方法的实现应该考虑异步操作,避免阻塞主线程 +3. 参数类型和格式应严格遵循ValueType中定义的类型 +4. 新增设备时应确保设备ID全局唯一 +5. 所有设备方法应该实现适当的错误处理和反馈机制 + +## 高级主题:Home Assistant集成 + +### 通过MQTT控制Home Assistant + +Home Assistant是一个流行的开源家庭自动化平台,支持通过MQTT协议控制各种智能设备。以下是如何创建一个与Home Assistant集成的设备示例: + +```python +import paho.mqtt.client as mqtt +import json +import time +from src.iot.thing import Thing, Parameter, ValueType +from src.utils.logging_config import get_logger +from src.utils.config_manager import ConfigManager + +logger = get_logger(__name__) + +class HomeAssistantLight(Thing): + """ + 通过MQTT协议控制Home Assistant中的灯设备 + + 支持开关、亮度调节和颜色调整功能 + """ + + def __init__(self, entity_id, friendly_name=None): + """ + 初始化Home Assistant灯设备 + + 参数: + entity_id: Home Assistant中的实体ID,例如 'light.living_room' + friendly_name: 显示名称,如不提供则使用entity_id + """ + self.entity_id = entity_id + name = friendly_name or entity_id.replace(".", "_") + super().__init__(name, f"Home Assistant灯设备: {friendly_name or entity_id}") + + # 设备状态 + self.state = "off" + self.brightness = 255 # 亮度值 0-255 + self.rgb_color = [255, 255, 255] # RGB颜色 + + # MQTT客户端配置 + config = ConfigManager.get_instance() + self.mqtt_config = { + "host": config.get_config("HOME_ASSISTANT.MQTT.host", "localhost"), + "port": config.get_config("HOME_ASSISTANT.MQTT.port", 1883), + "username": config.get_config("HOME_ASSISTANT.MQTT.username", ""), + "password": config.get_config("HOME_ASSISTANT.MQTT.password", ""), + "command_topic": f"homeassistant/light/{self.entity_id}/set", + "state_topic": f"homeassistant/light/{self.entity_id}/state" + } + + # 创建MQTT客户端 + self._setup_mqtt_client() + + # 注册属性 + self.add_property("state", "灯的状态 (on/off)", lambda: self.state) + self.add_property("brightness", "灯的亮度 (0-255)", lambda: self.brightness) + self.add_property("rgb_color", "灯的RGB颜色", lambda: self.rgb_color) + + # 注册方法 + self.add_method( + "TurnOn", + "打开灯", + [], + lambda params: self._turn_on() + ) + + self.add_method( + "TurnOff", + "关闭灯", + [], + lambda params: self._turn_off() + ) + + self.add_method( + "SetBrightness", + "设置灯的亮度", + [Parameter("brightness", "亮度值 (0-100)", ValueType.NUMBER, True)], + lambda params: self._set_brightness(params["brightness"].get_value()) + ) + + self.add_method( + "SetColor", + "设置灯的颜色", + [ + Parameter("red", "红色分量 (0-255)", ValueType.NUMBER, True), + Parameter("green", "绿色分量 (0-255)", ValueType.NUMBER, True), + Parameter("blue", "蓝色分量 (0-255)", ValueType.NUMBER, True) + ], + lambda params: self._set_color( + params["red"].get_value(), + params["green"].get_value(), + params["blue"].get_value() + ) + ) + + # 刷新设备状态 + self._request_state() + + def _setup_mqtt_client(self): + """设置MQTT客户端""" + self.mqtt_client = mqtt.Client() + + # 设置认证 + if self.mqtt_config["username"] and self.mqtt_config["password"]: + self.mqtt_client.username_pw_set( + self.mqtt_config["username"], + self.mqtt_config["password"] + ) + + # 设置回调 + self.mqtt_client.on_connect = self._on_connect + self.mqtt_client.on_message = self._on_message + self.mqtt_client.on_disconnect = self._on_disconnect + + # 连接MQTT服务器 + try: + self.mqtt_client.connect( + self.mqtt_config["host"], + self.mqtt_config["port"], + 60 + ) + self.mqtt_client.loop_start() + logger.info(f"MQTT客户端已连接到 {self.mqtt_config['host']}:{self.mqtt_config['port']}") + except Exception as e: + logger.error(f"MQTT连接失败: {e}") + + def _on_connect(self, client, userdata, flags, rc): + """连接回调""" + if rc == 0: + logger.info(f"已成功连接到MQTT服务器,订阅主题: {self.mqtt_config['state_topic']}") + # 订阅状态主题 + client.subscribe(self.mqtt_config["state_topic"]) + # 请求当前状态 + self._request_state() + else: + logger.error(f"连接MQTT服务器失败,返回码: {rc}") + + def _on_disconnect(self, client, userdata, rc): + """断开连接回调""" + logger.warning(f"与MQTT服务器断开连接,返回码: {rc}") + if rc != 0: + logger.info("尝试重新连接...") + time.sleep(5) + try: + client.reconnect() + except Exception as e: + logger.error(f"重连失败: {e}") + + def _on_message(self, client, userdata, msg): + """消息回调""" + try: + payload = json.loads(msg.payload.decode()) + logger.debug(f"收到MQTT消息: {payload}") + + # 更新设备状态 + if "state" in payload: + self.state = payload["state"] + if "brightness" in payload: + self.brightness = payload["brightness"] + if "rgb_color" in payload and isinstance(payload["rgb_color"], list): + self.rgb_color = payload["rgb_color"] + + logger.info(f"设备 {self.entity_id} 状态已更新: state={self.state}, brightness={self.brightness}") + except Exception as e: + logger.error(f"处理MQTT消息时出错: {e}") + + def _request_state(self): + """请求设备当前状态""" + try: + self.mqtt_client.publish( + f"homeassistant/light/{self.entity_id}/get", + "" + ) + logger.debug(f"已请求设备 {self.entity_id} 的状态") + except Exception as e: + logger.error(f"请求设备状态失败: {e}") + + def _turn_on(self): + """打开灯""" + try: + payload = { + "state": "on" + } + self.mqtt_client.publish( + self.mqtt_config["command_topic"], + json.dumps(payload) + ) + self.state = "on" + logger.info(f"发送命令: 打开灯 {self.entity_id}") + return {"status": "success", "message": f"已发送打开命令到 {self.entity_id}"} + except Exception as e: + logger.error(f"发送打开命令失败: {e}") + return {"status": "error", "message": f"发送命令失败: {e}"} + + def _turn_off(self): + """关闭灯""" + try: + payload = { + "state": "off" + } + self.mqtt_client.publish( + self.mqtt_config["command_topic"], + json.dumps(payload) + ) + self.state = "off" + logger.info(f"发送命令: 关闭灯 {self.entity_id}") + return {"status": "success", "message": f"已发送关闭命令到 {self.entity_id}"} + except Exception as e: + logger.error(f"发送关闭命令失败: {e}") + return {"status": "error", "message": f"发送命令失败: {e}"} + + def _set_brightness(self, brightness_percent): + """ + 设置灯的亮度 + + 参数: + brightness_percent: 亮度百分比 (0-100) + """ + try: + # 验证输入 + if not 0 <= brightness_percent <= 100: + return {"status": "error", "message": "亮度必须在0-100之间"} + + # 将百分比转换为Home Assistant使用的0-255范围 + brightness = int(brightness_percent * 255 / 100) + + payload = { + "state": "on", + "brightness": brightness + } + + self.mqtt_client.publish( + self.mqtt_config["command_topic"], + json.dumps(payload) + ) + + self.state = "on" + self.brightness = brightness + + logger.info(f"发送命令: 设置灯 {self.entity_id} 亮度为 {brightness_percent}%") + return { + "status": "success", + "message": f"已将 {self.entity_id} 亮度设置为 {brightness_percent}%" + } + except Exception as e: + logger.error(f"设置亮度失败: {e}") + return {"status": "error", "message": f"设置亮度失败: {e}"} + + def _set_color(self, red, green, blue): + """ + 设置灯的颜色 + + 参数: + red: 红色分量 (0-255) + green: 绿色分量 (0-255) + blue: 蓝色分量 (0-255) + """ + try: + # 验证输入 + for value, color in [(red, "红"), (green, "绿"), (blue, "蓝")]: + if not 0 <= value <= 255: + return {"status": "error", "message": f"{color}色值必须在0-255之间"} + + payload = { + "state": "on", + "rgb_color": [red, green, blue] + } + + self.mqtt_client.publish( + self.mqtt_config["command_topic"], + json.dumps(payload) + ) + + self.state = "on" + self.rgb_color = [red, green, blue] + + logger.info(f"发送命令: 设置灯 {self.entity_id} 颜色为 RGB({red},{green},{blue})") + return { + "status": "success", + "message": f"已将 {self.entity_id} 颜色设置为 RGB({red},{green},{blue})" + } + except Exception as e: + logger.error(f"设置颜色失败: {e}") + return {"status": "error", "message": f"设置颜色失败: {e}"} +``` + +### 配置和使用Home Assistant设备 + +1. **配置文件设置** + +在`config/config.json`中添加Home Assistant MQTT配置: + +```json +{ + "HOME_ASSISTANT": { + "MQTT": { + "host": "你的Home Assistant IP地址", + "port": 1883, + "username": "mqtt用户名", + "password": "mqtt密码" + }, + "DEVICES": [ + { + "entity_id": "light.living_room", + "friendly_name": "客厅灯" + }, + { + "entity_id": "light.bedroom", + "friendly_name": "卧室灯" + } + ] + } +} +``` + +2. **注册Home Assistant设备** + +在`Application._initialize_iot_devices`方法中添加: + +```python +# 添加Home Assistant设备 +from src.iot.things.ha_light import HomeAssistantLight + +# 从配置中读取Home Assistant设备列表 +ha_devices = self.config.get_config("HOME_ASSISTANT.DEVICES", []) +for device in ha_devices: + entity_id = device.get("entity_id") + friendly_name = device.get("friendly_name") + if entity_id: + thing_manager.add_thing(HomeAssistantLight(entity_id, friendly_name)) + logger.info(f"已添加Home Assistant设备: {friendly_name or entity_id}") +``` + +3. **语音命令示例** + +- "打开客厅灯" +- "把卧室灯调暗一点" +- "将客厅灯设置为蓝色" +- "关闭所有灯" + +### Home Assistant设备使用说明 + +1. **先决条件** + - 已安装并配置好Home Assistant + - Home Assistant已启用MQTT集成 + - 已在Home Assistant中配置好智能灯设备 + +2. **注意事项** + - 需要确保MQTT服务器允许外部连接 + - Home Assistant中的实体ID要与配置文件中一致 + - MQTT主题格式可能需要根据你的Home Assistant配置进行调整 + +### 通信协议限制 + +当前IoT协议(1.0版本)存在以下限制: + +1. **单向控制流**:大模型只能下发指令,无法立即获取指令执行结果 +2. **状态更新延迟**:设备状态变更需要等到下一轮对话时,通过读取property属性值才能获知 +3. **异步反馈**:如果需要操作结果反馈,必须通过设备属性的方式间接实现 + +### 最佳实践 + +1. **使用有意义的属性名称**:属性名称应清晰表达其含义,便于大模型理解和使用 + +2. **不产生歧义的方法描述**:为每个方法提供明确的自然语言描述,帮助大模型更准确地理解和调用 \ No newline at end of file diff --git "a/documents/docs/guide/06_\351\237\263\351\207\217\346\216\247\345\210\266\345\212\237\350\203\275.md" "b/documents/docs/guide/06_\351\237\263\351\207\217\346\216\247\345\210\266\345\212\237\350\203\275.md" new file mode 100644 index 0000000000000000000000000000000000000000..406e195a4b49859ef805de678005a37015755707 --- /dev/null +++ "b/documents/docs/guide/06_\351\237\263\351\207\217\346\216\247\345\210\266\345\212\237\350\203\275.md" @@ -0,0 +1,224 @@ +# 音量控制功能 + +## 功能概述 + +本应用支持调整系统音量,根据不同操作系统需要安装不同的依赖。应用程序会在启动时自动检查这些依赖是否已安装。如果缺少依赖,将会显示相应的安装指令。 + +## 平台支持 + +系统针对不同操作系统提供了不同的音量控制实现: + +1. **Windows**: 使用 pycaw 和 comtypes 控制系统音量 +2. **macOS**: 使用 applescript 控制系统音量 +3. **Linux**: 根据系统环境使用 pactl (PulseAudio)、amixer (ALSA) 或 alsamixer 控制音量 + +## 依赖安装 + +### Windows +```bash +pip install pycaw comtypes +``` + +### macOS +macOS系统需要安装applescript模块: +```bash +pip install applescript +``` + +### Linux +根据您的音频系统安装以下依赖之一: + +```bash +# PulseAudio 工具(推荐) +sudo apt-get install pulseaudio-utils + +# 或者 ALSA 工具 +sudo apt-get install alsa-utils + +# 如果需要使用 alsamixer 方式,还需要安装 expect +sudo apt-get install alsa-utils expect +``` + +## 使用方法 + +### GUI模式 +- 使用界面上的音量滑块直接调节音量 +- 滑块会在移动后300毫秒更新系统音量(防抖设计) +- 可通过语音命令控制音量,如"调高音量"、"把音量调到50%"等 + +### CLI模式 +- 使用 `v <音量值>` 命令调节音量,例如 `v 50` 将音量设置为50% +- 支持的命令: + - `v <数值>` 设置为指定音量值(0-100) + +### 语音控制 +通过IoT功能,可以使用语音命令控制音量: +- "把音量调到50%" +- "音量调小一点" +- "音量调大" +- "设置音量为80" + +## 架构设计 + +音量控制功能采用分层设计,包括: + +1. **VolumeController类** - 底层实现,负责跨平台音量操作 +2. **BaseDisplay.update_volume** - 中间层,应用程序与底层控制器的桥接 +3. **Speaker IoT设备** - 高级抽象,提供语音命令接口 + +## 内部实现 + +### 1. VolumeController类 + +VolumeController类是一个跨平台的音量控制实现,支持Windows、macOS和Linux系统: + +```python +# src/utils/volume_controller.py +class VolumeController: + """跨平台音量控制器""" + + def __init__(self): + self.system = platform.system() + # 根据不同操作系统初始化控制器 + if self.system == "Windows": + self._init_windows() + elif self.system == "Darwin": # macOS + self._init_macos() + elif self.system == "Linux": + self._init_linux() + + def get_volume(self): + """获取当前音量 (0-100)""" + # 根据不同平台实现获取音量 + + def set_volume(self, volume): + """设置音量 (0-100)""" + # 根据不同平台实现设置音量 +``` + +### 2. BaseDisplay音量控制 + +BaseDisplay类提供音量控制接口,由CLI和GUI显示类继承: + +```python +# src/display/base_display.py +class BaseDisplay(ABC): + def __init__(self): + self.current_volume = 70 # 默认音量值 + self.volume_controller = None + + # 初始化音量控制器 + try: + from src.utils.volume_controller import VolumeController + if VolumeController.check_dependencies(): + self.volume_controller = VolumeController() + self.current_volume = self.volume_controller.get_volume() + except Exception as e: + # 错误处理... + + def get_current_volume(self): + """获取当前音量""" + if self.volume_controller: + try: + self.current_volume = self.volume_controller.get_volume() + except Exception: + pass + return self.current_volume + + def update_volume(self, volume: int): + """更新系统音量""" + volume = max(0, min(100, volume)) + self.current_volume = volume + + if self.volume_controller: + try: + self.volume_controller.set_volume(volume) + except Exception: + # 错误处理... + pass +``` + +### 3. Speaker IoT设备 + +Speaker类是一个IoT设备,允许通过语音命令控制音量: + +```python +# src/iot/things/speaker.py +from src.application import Application +from src.iot.thing import Thing, Parameter, ValueType + +class Speaker(Thing): + def __init__(self): + super().__init__("Speaker", "当前 AI 机器人的扬声器") + + # 获取当前显示实例的音量作为初始值 + try: + app = Application.get_instance() + self.volume = app.display.current_volume + except Exception: + # 如果获取失败,使用默认值 + self.volume = 100 # 默认音量 + + # 定义音量属性 + self.add_property("volume", "当前音量值", lambda: self.volume) + + # 定义设置音量方法 + self.add_method( + "SetVolume", + "设置音量", + [Parameter("volume", "0到100之间的整数", ValueType.NUMBER, True)], + lambda params: self._set_volume(params["volume"].get_value()) + ) + + def _set_volume(self, volume): + """设置音量的具体实现""" + if 0 <= volume <= 100: + self.volume = volume + try: + app = Application.get_instance() + app.display.update_volume(volume) + return {"success": True, "message": f"音量已设置为: {volume}"} + except Exception as e: + return {"success": False, "message": f"设置音量失败: {e}"} + else: + raise ValueError("音量必须在0-100之间") +``` + +### 4. 在Application中注册 + +音量控制设备在应用程序启动时被注册: + +```python +# src/application.py (部分代码) +def _initialize_iot_devices(self): + """初始化物联网设备""" + from src.iot.thing_manager import ThingManager + from src.iot.things.speaker import Speaker + + # 获取物联网设备管理器实例 + thing_manager = ThingManager.get_instance() + + # 添加音量控制设备 + thing_manager.add_thing(Speaker()) +``` + +## 常见问题 + +1. **无法调节音量** + - 检查是否安装了对应操作系统的音量控制依赖 + - Windows用户确保安装了pycaw和comtypes + - macOS用户确保安装了applescript模块 + - Linux用户确保安装了对应的音频控制工具(pactl或amixer) + +2. **调节音量命令无响应** + - 确保IoT模块正常运行 + - 检查系统音频设备是否正常工作 + - 尝试重启应用 + +3. **音量调节不准确** + - 可能是由于不同音频接口导致的精度问题 + - 尝试使用较大幅度的调节命令 + +4. **GUI滑块与实际音量不同步** + - 在某些情况下,系统音量可能被其他应用程序更改 + - 重新启动应用程序将重新获取当前系统音量 \ No newline at end of file diff --git "a/documents/docs/guide/07_\350\247\206\350\247\211\350\257\206\345\210\253\345\212\237\350\203\275.md" "b/documents/docs/guide/07_\350\247\206\350\247\211\350\257\206\345\210\253\345\212\237\350\203\275.md" new file mode 100644 index 0000000000000000000000000000000000000000..1ea0e19d202b12c6c157c448d348ad8d09341163 --- /dev/null +++ "b/documents/docs/guide/07_\350\247\206\350\247\211\350\257\206\345\210\253\345\212\237\350\203\275.md" @@ -0,0 +1,97 @@ +# 视觉识别功能 + +## 功能概述 + +py-xiaozhi提供了摄像头控制和视觉识别功能,支持通过语音命令打开/关闭摄像头,以及对摄像头捕获的画面进行智能识别分析。 + +## 配置说明 + +视觉识别功能需要在配置文件中进行相关设置: + +```json +"CAMERA": { + "camera_index": 0, // 摄像头索引,0通常是电脑内置摄像头 + "frame_width": 640, // 画面宽度 + "frame_height": 480, // 画面高度 + "fps": 30, // 帧率 + "Loacl_VL_url": "https://open.bigmodel.cn/api/paas/v4/", // 智普API地址 + "VLapi_key": "你的key", // 智普视觉大模型API密钥 + "models": "glm-4v-plus" // 使用的视觉模型 +} +``` + +## 智普视觉大模型配置 + +1. 访问 [智普AI开放平台](https://open.bigmodel.cn/) +2. 注册账号并创建API密钥 +3. 将获取的API密钥配置到`config.json`的`CAMERA.VLapi_key`字段 +4. 可以选择使用的模型,默认为`glm-4v-plus` + +## 使用方法 + +### 语音命令控制 + +系统支持以下语音命令控制摄像头和视觉识别功能: + +- **打开摄像头**:激活系统摄像头,开始捕获视频流 +- **关闭摄像头**:停止摄像头捕获 +- **识别画面**:对当前摄像头画面进行智能视觉分析,识别画面中的内容 +- **分析图像**:对当前画面进行详细视觉分析并提供描述 +- **看到了什么**:询问当前摄像头看到的内容 + +### GUI 界面控制 + +在图形界面模式下,可以通过界面上的相关按钮控制摄像头功能。 + +## 内部实现 + +视觉识别功能通过IoT模块中的CameraVL设备类实现,主要由Camera和VL两个组件组成: + +1. **Camera 组件**:负责摄像头的基本控制,如开启、关闭、获取视频帧等 +2. **VL(Vision Language)组件**:负责对图像进行智能分析,调用智普视觉大模型API + +实现结构: + +``` +CameraVL # 摄像头与视觉识别集成设备 +├── Camera.py # 摄像头控制模块 +└── VL.py # 视觉语言分析模块 +``` + +## 工作流程 + +1. 用户通过语音命令打开摄像头 +2. 系统激活摄像头并在界面显示视频流 +3. 用户请求识别当前画面 +4. 系统截取当前帧,并将图像发送给智普视觉大模型 +5. 获取视觉分析结果并通过语音或文字反馈给用户 + +## 隐私说明 + +视觉识别功能会使用您的摄像头并处理画面内容,请注意: + +1. 摄像头捕获的画面仅用于本地分析或发送至智普API进行分析 +2. 非语音命令控制时,摄像头处于关闭状态 +3. 可以在配置中修改摄像头设置或完全禁用此功能 + +## 常见问题 + +1. **摄像头无法打开** + - 确认您的设备有可用摄像头 + - 检查摄像头是否被其他应用占用 + - 确认已授予应用使用摄像头的权限 + +2. **视觉识别功能无响应** + - 检查智普API密钥是否正确配置 + - 确认网络连接正常 + - 检查是否超出API调用次数限制 + +3. **识别结果不准确** + - 尝试改善摄像头光线条件 + - 确保目标对象在画面中清晰可见 + - 可能需要升级智普视觉模型版本 + +4. **摄像头画面卡顿** + - 尝试在配置中降低分辨率或帧率 + - 关闭其他占用系统资源的应用 + - 更新摄像头驱动 \ No newline at end of file diff --git "a/documents/docs/guide/08_\350\256\276\345\244\207\346\277\200\346\264\273\346\265\201\347\250\213.md" "b/documents/docs/guide/08_\350\256\276\345\244\207\346\277\200\346\264\273\346\265\201\347\250\213.md" new file mode 100644 index 0000000000000000000000000000000000000000..eeae4e6b30968d8b6edd5c6fc6db2fedc673fa9a --- /dev/null +++ "b/documents/docs/guide/08_\350\256\276\345\244\207\346\277\200\346\264\273\346\265\201\347\250\213.md" @@ -0,0 +1,324 @@ +# 设备激活流程 v2 + +## 概述 + +当前流程是虾哥设备认证v2版本 + +## 激活流程 + +每个设备都有一个唯一的序列号(Serial Number)和HMAC密钥(HMAC Key),用于身份验证和安全通信。新设备首次使用时需要通过以下流程进行激活: + +1. 客户端启动时,向服务器发送设备信息,包括序列号、MAC地址和客户端ID +2. 服务器检查设备是否已激活: + - 如果已激活,客户端正常工作 + - 如果未激活,服务器返回包含验证码和Challenge的激活请求 +3. 客户端显示验证码,提示用户前往xiaozhi.me网站输入验证码 +4. 客户端使用HMAC密钥对Challenge进行签名,并发送给服务器验证 +5. 客户端通过轮询方式等待服务器确认验证结果: + - 如果验证成功,设备激活完成 + - 如果验证失败或超时,设备激活失败 + +### 小智 ESP32 设备激活流程图 + +``` +┌────────────────────┐ +│ 设备启动 │ +└──────────┬─────────┘ + ↓ +┌────────────────────┐ +│ 初始化各组件 │ +│ 连接WiFi/网络 │ +└──────────┬─────────┘ + ↓ +┌────────────────────┐ +│ 调用CheckVersion │ +│ 访问OTA服务器 │──→ POST /xiaozhi/ota/ +└──────────┬─────────┘ + ↓ +┌────────────────────┐ +│ 解析服务器响应 │ +└──────────┬─────────┘ + ↓ + ┌─────┴─────┐ + ↓ ↓ +┌─────────┐ ┌─────────┐ +│是否有新版本│ │是否需要激活│ +└─────┬───┘ └─────┬───┘ + │ │ +┌─────▼───┐ └───┬─── 否 ──┐ +│升级固件 │ ↓ ↓ +└─────────┘ ┌─────────────┐ ┌─────────────┐ + │是否有激活码 │ │初始化协议连接│ + └──────┬──────┘ │MQTT/WebSocket│ + │ └─────────────┘ + ┌────▼───┐ + │ 是 │ + └────┬───┘ + ↓ + ┌──────────────────┐ + │显示激活码给用户 │ + │播放语音提示 │ + └────────┬─────────┘ + ↓ + ┌──────────────────┐ + │ 开始激活流程 │ + └────────┬─────────┘ + ↓ +┌────────────────────────────────┐ +│ 检查设备序列号 │ +└───────────────┬────────────────┘ + ↓ + ┌──────┴───────┐ + ↓ ↓ + ┌─────────┐ ┌─────────┐ + │ 有序列号 │ │ 无序列号 │ + └─────┬────┘ └────┬────┘ + │ │ +┌─────────▼────────┐ │ +│构造激活载荷JSON: │ │ +│- serial_number │ │ +│- challenge │ │ +│- hmac签名 │ │ +└─────────┬─────────┘ │ + │ │ + └──────┬───────┘ + ↓ + ┌───────────────────────┐ + │发送POST请求到激活端点 │──→ POST /xiaozhi/ota/activate + └────────────┬──────────┘ + ↓ + ┌───────────┴───────────┐ + ↓ ↓ ↓ +┌─────────┐ ┌─────────┐ ┌─────────┐ +│状态码200 │ │状态码202 │ │其他状态码│ +│激活成功 │ │超时重试 │ │激活失败 │ +└────┬────┘ └────┬────┘ └────┬────┘ + │ │ │ + │ ┌────▼─────┐ │ + │ │延迟后重试 │ │ + │ │最多10次 │ │ + │ └────┬─────┘ │ + │ │ │ + └───────────┼───────────┘ + ↓ + ┌──────────────────┐ + │设置激活状态标志位 │ + └────────┬─────────┘ + ↓ + ┌──────────────────┐ + │ 继续正常运行 │ + │ 连接MQTT/WS协议 │ + └──────────────────┘ +``` + +### 激活数据交互详细流程 + +``` +┌────────────┐ ┌────────────┐ ┌────────────┐ +│ │ │ │ │ │ +│ 设备客户端 │ │ 服务器 │ │ 用户浏览器 │ +│ │ │ │ │ │ +└─────┬──────┘ └─────┬──────┘ └─────┬──────┘ + │ │ │ + │ 请求设备状态 (MAC, ClientID, SN) │ │ + │ ────────────────────────────────> │ │ + │ │ │ + │ 返回激活请求 (验证码, Challenge) │ │ + │ <──────────────────────────────── │ │ + │ │ │ + │ 显示验证码 │ │ + │ ┌─────────────┐ │ │ + │ │请前往网站输入 │ │ │ + │ │验证码: 123456│ │ │ + │ └─────────────┘ │ │ + │ │ │ + │ │ 用户访问xiaozhi.me │ + │ │ <─────────────────────────────────│ + │ │ │ + │ │ 输入验证码 123456 │ + │ │ <─────────────────────────────────│ + │ │ │ + │ 计算HMAC签名 │ │ + │ ┌─────────────┐ │ │ + │ │ HMAC(密钥, │ │ │ + │ │ Challenge) │ │ │ + │ └─────────────┘ │ │ + │ │ │ + │ 发送激活请求 (SN, Challenge, 签名) │ │ + │ ────────────────────────────────> │ │ + │ │ ┌───────────────┐ │ + │ │ │ 等待用户输入验证码 │ │ + │ │ │ 超时返回202 │ │ + │ │ └───────────────┘ │ + │ │ │ + │ 轮询等待 (HTTP Long Polling) │ │ + │ ────────────────────────────────> │ │ + │ HTTP 202 (Pending) │ │ + │ <──────────────────────────────── │ │ + │ │ │ + │ 继续轮询... │ │ + │ ────────────────────────────────> │ │ + │ │ │ + │ │ 验证码验证成功 │ + │ │───────────────────────────────────│ + │ │ │ + │ 激活成功 (HTTP 200) │ │ + │ <──────────────────────────────── │ │ + │ │ │ + │ ┌─────────────┐ │ │ + │ │设备激活成功! │ │ │ + │ └─────────────┘ │ │ + │ │ │ +``` + +## 设备与服务器通信内容详解 + +### 1. 设备信息请求 (POST /xiaozhi/ota/) + +**请求头**: +``` +Activation-Version: 2 // 表示支持序列号激活 +Device-Id: AA:BB:CC:DD:EE:FF // MAC地址 +Client-Id: xxxx-xxxx-xxxx-xxxx // 设备UUID +User-Agent: BOARD_NAME/1.0.0 // 开发板名称和固件版本 +Content-Type: application/json +``` + +**请求体** (POST时): +```json +{ + "version": 2, + "flash_size": 16777216, + "psram_size": 8388608, + "minimum_free_heap_size": 7265024, + "mac_address": "你的mac地址", + "uuid": "你的client_id", + "chip_model_name": "esp32s3", + "chip_info": { + "model": 9, + "cores": 2, + "revision": 0, + "features": 20 + }, + "application": { + "name": "xiaozhi", + "version": "1.6.0", + "compile_time": "2025-04-16T12:00:00Z", + "idf_version": "v5.3.2" + }, + "partition_table": [ + { + "label": "nvs", + "type": 1, + "subtype": 2, + "address": 36864, + "size": 24576 + }, + { + "label": "otadata", + "type": 1, + "subtype": 0, + "address": 61440, + "size": 8192 + }, + { + "label": "app0", + "type": 0, + "subtype": 0, + "address": 65536, + "size": 1966080 + }, + { + "label": "app1", + "type": 0, + "subtype": 0, + "address": 2031616, + "size": 1966080 + }, + { + "label": "spiffs", + "type": 1, + "subtype": 130, + "address": 3997696, + "size": 1966080 + } + ], + "ota": { + "label": "app0" + }, + "board": { + "type": "lc-esp32-s3", + "name": "立创ESP32-S3开发板", + "features": ["wifi", "ble", "psram", "octal_flash"], + "ip": "你的ip地址", + "mac": "你的mac地址" + } +} +``` + +### 2. 服务器响应 + +**响应体**: +```json +{ + "firmware": { + "version": "1.0.1", + "url": "" + }, + "activation": { + "message": "请访问xiaozhi.me输入激活码", + "code": "123456", + "challenge": "randomstring123456", + "timeout_ms": 30000 + }, + "mqtt": { + "endpoint": "mqtt.xiaozhi.me", + "client_id": "device123", + "username": "user123", + "password": "pass123", + "publish_topic": "" + }, + "websocket": { + "url": "wss://api.tenclass.net/xiaozhi/v1/", + "token": "test-token" + } +} +``` + +### 3. 设备激活请求 (POST /xiaozhi/ota/activate) + +**请求体**: +```json +{ + "Payload": { + "algorithm": "hmac-sha256", + "serial_number": "SN-5CD8467B47FB4920", + "challenge": "dac852d6-4ac4-4650-ba1a-c2a5bf00a766", + "hmac": "ada4775e3ed93cf9c0eb9ed00444138554ba416af41283a0e5603c77681a8022" + } +} +``` + +### 4. 激活响应 + +- **成功**: 状态码 200 +- **等待用户输入验证码**: 状态码 202 +- **失败**: 状态码 4xx (如401表示未授权,400表示请求错误) + +**响应体** (失败时): + +```json +{ + "error": "错误原因描述" +} +``` + + +## 安全机制 + +设备激活流程v2版本采用以下安全机制: + +1. **设备唯一标识**:每个设备有一个唯一的序列号(Serial Number) +2. **HMAC签名验证**:使用HMAC-SHA256算法对Challenge进行签名,确保设备身份的真实性 +3. **验证码验证**:通过要求用户在网页端输入验证码,防止自动化的激活攻击 +4. **轮询等待机制**:使用HTTP Long Polling等待服务器验证结果,适应各种网络环境 \ No newline at end of file diff --git "a/documents/docs/guide/09_\346\211\223\345\214\205\346\225\231\347\250\213.md" "b/documents/docs/guide/09_\346\211\223\345\214\205\346\225\231\347\250\213.md" new file mode 100644 index 0000000000000000000000000000000000000000..0b96b143a0c11dc5f509e9643d6beff30f5b9512 --- /dev/null +++ "b/documents/docs/guide/09_\346\211\223\345\214\205\346\225\231\347\250\213.md" @@ -0,0 +1,325 @@ +# 项目打包教程 + +## 使用UnifyPy打包小智客户端 + +UnifyPy是一个强大的自动化解决方案,能将Python项目打包成跨平台的独立可执行文件和安装程序。小智客户端已配置了相应的打包配置文件,本教程将指导您如何使用UnifyPy进行打包。 + +## 准备工作 + +### 1. 安装依赖 + +首先,确保您已安装项目的所有依赖: + +```bash +# Windows +pip install -r requirements.txt + +# macOS +pip install -r requirements_mac.txt + +# Linux +pip install -r requirements.txt +``` + +### 2. 克隆UnifyPy仓库 + +```bash +git clone https://github.com/huangjunsen0406/UnifyPy.git +cd UnifyPy +pip install -r requirements.txt +``` + +### 3. 安装平台特定工具 + +#### Windows平台 +- 安装[Inno Setup](https://jrsoftware.org/isdl.php)(用于创建安装程序) +- 安装后,在build.json中配置Inno Setup路径,或设置环境变量INNO_SETUP_PATH + +#### macOS平台 +- 安装create-dmg(用于创建DMG镜像): + ```bash + brew install create-dmg + ``` + +#### Linux平台 +- 安装相应的打包工具: + ```bash + # Debian/Ubuntu + sudo apt-get install dpkg-dev + + # Fedora/CentOS + sudo dnf install rpm-build + + # AppImage + # 请参考AppImageKit的安装说明 + ``` + +## 打包配置详解 + +小智客户端已经提供了预配置的`build.json`文件,以下是各配置项的详细说明: + +### 基本配置 + +```json +{ + "name": "xiaozhi", // 应用程序名称,将用于可执行文件和安装程序名称 + "version": "1.0.0", // 应用程序版本号 + "publisher": "Junsen", // 发布者名称 + "entry": "main.py", // 程序入口文件 + "icon": "assets/xiaozhi_icon.ico", // 应用图标路径 + "hooks": "hooks", // PyInstaller钩子目录 + "onefile": false, // 是否生成单文件模式的可执行文件 + + // PyInstaller通用参数,适用于所有平台 + "additional_pyinstaller_args": "--add-data assets;assets --add-data libs;libs --add-data src;src --add-data models;models --hidden-import=PyQt5", + + // Inno Setup路径(Windows平台需要) + "inno_setup_path": "E:\\application\\Inno Setup 6\\ISCC.exe", + + // 其他配置... +} +``` + +> **注意**:JSON文件不支持注释,上述代码中的注释仅用于说明,实际配置文件中不应包含注释。 + +### 平台特定配置 + +#### Windows平台配置 + +```json +"windows": { + "format": "exe", // 输出格式 + "additional_pyinstaller_args": "--add-data assets;assets --add-data libs;libs --add-data src;src --add-data models;models --hidden-import=PyQt5 --noconsole", + "desktop_entry": true, // 是否创建桌面快捷方式 + "installer_options": { + "languages": ["ChineseSimplified", "English"], // 安装程序支持的语言 + "license_file": "LICENSE", // 许可证文件 + "readme_file": "README.md", // 自述文件 + "create_desktop_icon": true, // 是否创建桌面图标 + "allow_run_after_install": true // 安装后是否允许立即运行 + } +} +``` + +#### Linux平台配置 + +```json +"linux": { + "format": "deb", // 输出格式,可选值:deb, rpm, appimage + "desktop_entry": true, // 是否创建桌面快捷方式 + "categories": "Utility;Development;", // 应用程序类别 + "description": "小智Ai客户端", // 应用描述 + "requires": "libc6,libgtk-3-0,libx11-6,libopenblas-dev", // 依赖项 + "additional_pyinstaller_args": "--add-data assets:assets --add-data libs:libs --add-data src:src --add-data models:models --hidden-import=PyQt5" +} +``` + +#### macOS平台配置 + +```json +"macos": { + "format": "app", // 输出格式,可选值:app, dmg + "additional_pyinstaller_args": "--add-data assets:assets --add-data libs:libs --add-data src:src --add-data models:models --hidden-import=PyQt5 --windowed", + "app_bundle_name": "XiaoZhi.app", // 应用包名称 + "bundle_identifier": "com.junsen.xiaozhi", // 应用标识符 + "sign_bundle": false, // 是否签名应用包 + "create_dmg": true, // 是否创建DMG镜像 + "installer_options": { + "license_file": "LICENSE", // 许可证文件 + "readme_file": "README.md" // 自述文件 + } +} +``` + +### 其他重要配置项 + +```json +"build_installer": true // 是否构建安装程序,设为false只生成可执行文件 +``` + +### 自定义安装程序模板 + +当打包Windows安装程序时,UnifyPy使用`setup.iss.template`文件作为Inno Setup的脚本模板。需要注意的是,模板中的AppId需要更换为自己的唯一标识符: + +``` +[Setup] +; 应用程序信息 +AppId={{05DBB87C-AE34-4F2F-AEC5-3CD2AFE9DC90}} ; 需要替换为你自己生成的GUID +``` + +> **重要**:请勿直接使用示例中的AppId,这可能导致与其他应用程序冲突。您可以使用在线GUID生成工具(如[Online GUID Generator](https://www.guidgenerator.com/))来生成自己的唯一标识符。 + +## 执行打包 + +### 基本打包命令 + +```bash +# 导航到UnifyPy目录 +cd 到当前py-xiaozhi项目目录下 + +# 执行打包命令 +# /home/junsen/桌面/UnifyPy/main.py 是 UnifPy的项目路径 +# . 是表示当前py-xiaozhi目录下 +# --config build.json 表示用 py-xiaozhi根目录的build.json +python /home/junsen/桌面/UnifyPy/main.py . --config build.json +``` + +### 针对不同平台的打包命令 + +#### Windows平台 +```bash +python C:\Users\Junsen\Desktop\Workspace\UnifyPy\main.py . --config build.json +``` + +#### macOS平台 +```bash +python /Users/junsen/Desktop/UnifyPy/main.py . --config build.json +``` + +#### Linux平台 + +- Linux打包有点特殊需要自行编译numpy或者你安装MKL相关依赖才行,下面是自行编译openblas版本的numpy流程 + +### **1. 更新系统并安装依赖** + +首先,更新系统并安装编译 `NumPy` 所需的所有依赖项。我们需要构建工具、线性代数库(OpenBLAS、LAPACK 等)以及 OpenSSL 等开发库。 + +```bash +sudo apt update +sudo apt install autoconf automake libtool cmake libssl-dev libopenblas-dev liblapack-dev libatlas-base-dev +``` + +### **2. 卸载现有的 `NumPy`** + +如果你已经安装了通过 `pip` 或 `conda` 安装的 `NumPy`,需要先卸载它: + +```bash +# 卸载 pip 安装的 NumPy +pip uninstall numpy -y + +# 卸载 conda 安装的 NumPy(如果使用了 conda) +conda remove numpy -y +``` + +### **3. 配置环境变量** + +在编译过程中,确保使用 OpenBLAS 和 LAPACK 库。你可以通过以下命令设置相关的环境变量: + +```bash +export BLAS=openblas +export LAPACK=openblas +export NPY_NUM_BUILD_JOBS=$(nproc) # 使用所有 CPU 核心加速编译 +``` + +### **4. 编译并安装 `NumPy`** + +接下来,使用 `pip` 安装 `NumPy`,并指定 `--no-binary :all:`,强制从源代码编译: + +```bash +pip install numpy==1.26.4 --no-binary :all: +``` + +### **5. 运行项目** + +安装完成后,可以运行你的项目,确保一切正常: + +```bash +python /home/junsen/桌面/UnifyPy/main.py . --config build.json +``` + +## 打包输出 + +成功打包后,将在项目根目录下的`dist`文件夹中找到打包的应用程序: + +- **Windows**: + - 可执行文件(.exe)位于`dist/xiaozhi`目录 + - 安装程序位于`dist/installer`目录,命名为`xiaozhi-1.0.0-setup.exe` + +- **macOS**: + - 应用程序包(.app)位于`dist/xiaozhi`目录 + - 磁盘镜像(.dmg)位于`dist/installer`目录,命名为`xiaozhi-1.0.0.dmg` + +- **Linux**: + - 可执行文件位于`dist/xiaozhi`目录 + - 安装包(DEB/RPM/AppImage)位于`dist/installer`目录 + +## 高级配置选项 + +### PyInstaller参数 + +在`additional_pyinstaller_args`字段中,您可以添加任何PyInstaller支持的参数。以下是一些常用参数: + +- `--noconsole`: 不显示控制台窗口(仅适用于图形界面程序) +- `--windowed`: 等同于`--noconsole` +- `--hidden-import=MODULE`: 添加隐式导入的模块 +- `--add-data SRC;DEST`: 添加数据文件(Windows平台使用分号分隔) +- `--add-data SRC:DEST`: 添加数据文件(macOS/Linux平台使用冒号分隔) +- `--icon=FILE.ico`: 设置应用程序图标 + +### 处理特殊依赖 + +某些Python库可能需要特殊处理才能正确打包,可以通过以下方式解决: + +1. **使用钩子文件**:在`hooks`目录中创建自定义钩子,处理特殊导入情况 +2. **添加隐式导入**:使用`--hidden-import`参数显式包含隐式导入的模块 +3. **添加数据文件**:使用`--add-data`参数包含程序运行所需的数据文件 + +## 常见问题及解决方案 + +### Windows平台常见问题 + +1. **找不到Inno Setup** + + 解决方案:确保已安装Inno Setup,并在build.json中正确配置路径,或设置环境变量INNO_SETUP_PATH + +2. **缺少隐式导入的模块** + + 解决方案:在`additional_pyinstaller_args`中添加`--hidden-import=模块名称` + +3. **打包后无法找到资源文件** + + 解决方案:确保使用相对路径访问资源文件,并使用`--add-data`参数正确包含资源文件 + +### macOS平台常见问题 + +1. **创建DMG出错** + + 解决方案:确保已安装create-dmg工具,并有正确的权限 + +2. **签名问题** + + 解决方案:如果需要签名,在配置文件中启用`sign_bundle`并提供有效的开发者身份 + +3. **动态库加载问题** + + 解决方案:确保代码中正确处理库路径,特别是使用`sys._MEIPASS`路径 + +### Linux平台常见问题 + +1. **缺少依赖库** + + 解决方案:在Linux配置中的`requires`字段中添加所需的系统依赖 + +2. **打包工具不可用** + + 解决方案:确保已安装相应格式的打包工具(dpkg-deb, rpmbuild, appimagetool) + +3. **权限问题** + + 解决方案:确保脚本有正确的执行权限,对某些资源目录可能需要特别处理 + +## 自定义打包配置 + +如果需要自定义打包过程,可以修改`build.json`文件中的以下部分: + +1. **基本信息**:修改应用名称、版本号、发布者等 +2. **图标**:更换应用程序图标 +3. **打包模式**:设置`onefile`为`true`可生成单个可执行文件 +4. **平台特定配置**:针对不同平台设置特定的打包参数 + +## 打包前的最佳实践 + +1. **清理项目**:移除临时文件、缓存和不必要的大型文件 +2. **测试依赖**:确保所有依赖都正确安装并可以导入 +3. **确认文件路径**:检查代码中的文件路径是否使用相对路径或资源路径 +4. **验证配置**:确保build.json中的配置与您的环境一致 \ No newline at end of file diff --git "a/documents/docs/guide/TTS\345\212\237\350\203\275\350\257\264\346\230\216.md" "b/documents/docs/guide/TTS\345\212\237\350\203\275\350\257\264\346\230\216.md" new file mode 100644 index 0000000000000000000000000000000000000000..06ad2a0edf2b04ea4c55f4c7b57331a062279353 --- /dev/null +++ "b/documents/docs/guide/TTS\345\212\237\350\203\275\350\257\264\346\230\216.md" @@ -0,0 +1,60 @@ +# 小智AI本地TTS功能说明 + +## 功能简介 + +小智AI现已支持本地文字转语音(TTS)功能,允许用户在命令行界面下直接朗读指定文本。该功能基于Pyttsx3,无需联网即可使用,提供高质量的中文语音合成。 + +## 技术实现 + +- 使用Pyttsx3引擎生成语音 +- 支持命令行下快速触发 +- 语音数据会发送到本地音频设备播放 +- 可实现纯本地的文本朗读功能 + +## 使用方法 + +### 命令行模式下使用 + +在命令行模式下,使用以下命令触发TTS功能: + +``` +你想要发送的文本 +``` + +例如: +``` +你好,小智 +``` + +执行后,系统会将输入的文本转换为语音并播放。 + +### 使用技巧 + +1. **长文本朗读**:支持较长文本的朗读,文本中可以包含标点符号 +2. **音量控制**:可以通过`v 数字`命令调整音量,例如:`v 80` +3. **中断朗读**:如需中断当前朗读,可以使用`x`命令 +4. **并发控制**:当前朗读未完成时,新的TTS请求会自动排队 + +## 依赖说明 + +本功能依赖以下Python库: +- edge-tts:Microsoft Edge TTS引擎的Python接口 +- soundfile:音频文件处理 +- pydub:音频转换和处理 +- numpy:数据处理 + +## 常见问题 + +1. **无法播放声音**: + - 检查系统音频设备是否正常工作 + - 确保音量设置适当(使用`v 80`等命令调整) + - 验证系统是否已安装必要的音频驱动 + +2. **TTS生成速度慢**: + - 首次使用可能需要下载语音模型,会稍慢 + - 确保网络连接正常(Edge TTS需要网络连接) + - 较长文本处理需要更多时间 + +3. **声音质量问题**: + - 默认使用Microsoft Edge TTS中的"zh-CN-XiaoxiaoNeural"女声 + - 如需其他声音,可修改源码中的`voice`参数 \ No newline at end of file diff --git "a/documents/docs/guide/images/\345\224\244\351\206\222\350\257\215.png" "b/documents/docs/guide/images/\345\224\244\351\206\222\350\257\215.png" new file mode 100644 index 0000000000000000000000000000000000000000..fb2339ddbd6ffd256a27bd785ed6ecd84db1967d Binary files /dev/null and "b/documents/docs/guide/images/\345\224\244\351\206\222\350\257\215.png" differ diff --git "a/documents/docs/guide/images/\345\267\262\346\263\250\345\206\214\350\256\276\345\244\207.png" "b/documents/docs/guide/images/\345\267\262\346\263\250\345\206\214\350\256\276\345\244\207.png" new file mode 100644 index 0000000000000000000000000000000000000000..fd813b56bcf876f255b1938aee3ca3cbe4e26217 Binary files /dev/null and "b/documents/docs/guide/images/\345\267\262\346\263\250\345\206\214\350\256\276\345\244\207.png" differ diff --git "a/documents/docs/guide/old_docs/\344\275\277\347\224\250\346\226\207\346\241\243.md" "b/documents/docs/guide/old_docs/\344\275\277\347\224\250\346\226\207\346\241\243.md" new file mode 100644 index 0000000000000000000000000000000000000000..b7655a6da63d3e9fc2ed8fd0043416c04b89a8b6 --- /dev/null +++ "b/documents/docs/guide/old_docs/\344\275\277\347\224\250\346\226\207\346\241\243.md" @@ -0,0 +1,421 @@ +--- +title: 旧版使用文档 +description: py-xiaozhi项目的旧版使用文档,提供早期版本的使用指南 +outline: deep +--- + +# py-xiaozhi使用文档(请认真阅读使用文档) + +![Image](https://github.com/user-attachments/assets/df8bd5d2-a8e6-4203-8084-46789fc8e9ad) +## 使用介绍 +- 语音模式分为两种长按对话和自动对话,右下角按钮显示的是当前模式 +- 长按对话:按住说话松手发送 +- 自动对话:点击开始对话即可,当界面显示聆听中就表示到你说话了,说完会自行发送 +- gui模式: + - F2 键:长按说话 + - F3 键:打断对话 +- cli模式 + - F2 键:按一次开始自动对话 + - F3 键:打断对话 + +## 配置说明 + +### 项目基础配置 + +#### 配置文件说明 +项目使用两种配置方式:初始配置模板和运行时配置文件。 + +1. **初始配置模板** + - 位置:`/src/utils/config_manager.py` + - 作用:提供默认配置模板,首次运行时会自动生成配置文件 + - 使用场景:首次运行或需要重置配置时修改此文件 + +2. **运行时配置文件** + - 位置:`/config/config.json` + - 作用:存储实际运行时的配置信息 + - 使用场景:日常使用时修改此文件 + +#### 配置项说明 +- 需要什么加什么配置通过config_manager去获取就行了,参考websocket或iot\things\temperature_sensor.py +- 例如获取 "MQTT_INFO"的"endpoint" , 通过这样 `config.get_config("MQTT_INFO.endpoint")`就能拿到**endpoint** +```json +{ + "CLIENT_ID": "自动生成的客户端ID", + "DEVICE_ID": "设备MAC地址", + "NETWORK": { + "OTA_VERSION_URL": "OTA更新地址", + "WEBSOCKET_URL": "WebSocket服务器地址", + "WEBSOCKET_ACCESS_TOKEN": "访问令牌" + }, + "MQTT_INFO": { + "endpoint": "MQTT服务器地址", + "client_id": "MQTT客户端ID", + "username": "MQTT用户名", + "password": "MQTT密码", + "publish_topic": "发布主题", + "subscribe_topic": "订阅主题" + }, + "USE_WAKE_WORD": false, // 是否启用语音唤醒 + "WAKE_WORDS": [ // 唤醒词列表 + "小智", + "你好小明" + ], + "WAKE_WORD_MODEL_PATH": "./models/vosk-model-small-cn-0.22", // 唤醒模型路径 + "TEMPERATURE_SENSOR_MQTT_INFO": { + "endpoint": "你的Mqtt地址", + "port": 1883, + "username": "admin", + "password": "dtwin@123", + "publish_topic": "sensors/temperature/command", + "subscribe_topic": "sensors/temperature/device_001/state" + }, + "CAMERA": { // 视觉配置 + "camera_index": 0, + "frame_width": 640, + "frame_height": 480, + "fps": 30, + "Loacl_VL_url": "https://open.bigmodel.cn/api/paas/v4/", // 智普的申请地址 https://open.bigmodel.cn/ + "VLapi_key": "你的key" + } + // ...可以添加任意配置 +} +``` + +#### 配置修改指南 + +1. **首次使用配置** + - 直接运行程序,系统会自动生成默认配置文件 + - 如需修改默认值,可编辑 `config_manager.py` 中的 `DEFAULT_CONFIG` + +2. **更换服务器配置** + - 打开 `/config/config.json` + - 修改 `NETWORK.WEBSOCKET_URL` 为新的服务器地址 + - 示例: + ```json + "NETWORK": { + "WEBSOCKET_URL": "ws://你的服务器地址:端口号/" + } + ``` + +3. **启用语音唤醒** + - 修改 `USE_WAKE_WORD` 为 `true` + - 可在 `WAKE_WORDS` 数组中添加或修改唤醒词 + +#### 注意事项 +- 修改配置文件后需要重启程序才能生效 +- WebSocket URL 必须以 `ws://` 或 `wss://` 开头 +- 首次运行时会自动生成 CLIENT_ID,建议不要手动修改 +- DEVICE_ID 默认使用设备MAC地址,可按需修改 +- 配置文件使用 UTF-8 编码,请使用支持 UTF-8 的编辑器修改 + +## 启动说明 +### 系统依赖安装 +#### Windows +1. **安装 FFmpeg** + ```bash + # 方法一:使用 Scoop 安装(推荐) + scoop install ffmpeg + + # 方法二:手动安装 + # 1. 访问 https://github.com/BtbN/FFmpeg-Builds/releases 下载 + # 2. 解压并将 bin 目录添加到系统 PATH + ``` + +2. **Opus 音频编解码库** + - 项目默认会自动引入 opus.dll,无需手动安装 + - 如遇问题,可将 `/libs/windows/opus.dll` 复制到以下位置之一: + - 应用程序目录 + - `C:\Windows\System32` + +#### Linux (Debian/Ubuntu) +```bash +# 安装系统依赖 +sudo apt-get update +sudo apt-get install python3-pyaudio portaudio19-dev ffmpeg libopus0 libopus-dev + +# 安装音量控制依赖(以下三选一) +# 1. PulseAudio 工具(推荐) +sudo apt-get install pulseaudio-utils + +# 2. 或者 ALSA 工具 +sudo apt-get install alsa-utils + +# 3. 如果需要使用 alsamixer 方式,还需要安装 expect +sudo apt-get install alsa-utils expect + + +sudo apt install build-essential python3-dev +``` + +#### macOS +```bash +# 使用 Homebrew 安装系统依赖 +brew install portaudio opus python-tk ffmpeg gfortran +brew upgrade tcl-tk +``` + +### Python 依赖安装 + +#### 方式一:使用 venv(推荐) +```bash +# 1. 创建虚拟环境 +python -m venv .venv + +# 2. 激活虚拟环境 +# Windows +.venv\Scripts\activate +# Linux/macOS +source .venv/bin/activate + +# 3. 安装依赖 +# Windows/Linux +pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple +# macOS +pip install -r requirements_mac.txt -i https://mirrors.aliyun.com/pypi/simple +``` + +#### 方式二:使用 Conda +```bash +# 1. 创建 Conda 环境 +conda create -n py-xiaozhi python=3.12 + +# 2. 激活环境 +conda activate py-xiaozhi + +# 3. 安装 Conda 特定依赖 +conda install conda-forge::libopus +conda install conda-forge::ffmpeg + +# 4. 安装 Python 依赖 +# Windows/Linux +pip install -r requirements.txt -i https://mirrors.aliyun.com/pypi/simple +# macOS +pip install -r requirements_mac.txt -i https://mirrors.aliyun.com/pypi/simple +``` + +### 唤醒词模型 + +- [唤醒词模型下载](https://alphacephei.com/vosk/models) +- 下载完成后解压放至根目录/models +- 默认读取vosk-model-small-cn-0.22小模型 +- ![Image](../images/唤醒词.png) + +### IoT功能说明 + +#### IoT模块结构 + +``` +├── iot # IoT设备相关模块 +│ ├── things # 具体设备实现目录 +│ │ ├── lamp.py # 智能灯控制实现 +│ │ │ └── Lamp # 灯设备类,提供开关、调节亮度、改变颜色等功能 +│ │ ├── music_player.py # 音乐播放器实现 +│ │ │ └── MusicPlayer # 音乐播放器类,提供播放、暂停、切换歌曲等功能 +│ │ └── speaker.py # 音量控制实现 +│ │ └── Speaker # 扬声器类,提供音量调节、静音等功能 +│ ├── thing.py # IoT设备基类定义 +│ │ ├── Thing # 所有IoT设备的抽象基类 +│ │ ├── Property # 设备属性类,定义设备的可变状态 +│ │ ├── Action # 设备动作类,定义设备可执行的操作 +│ │ └── Event # 设备事件类,定义设备可触发的事件 +│ └── thing_manager.py # IoT设备管理器(统一管理各类设备) +│ └── ThingManager # 单例模式实现的设备管理器,负责设备注册、查找和命令分发 +``` + +#### Iot 状态流转 +```text + +----------------+ + | 用户语音 | + | 指令 | + +-------+-------+ + | + v + +-------+-------+ + | 语音识别 | + | (STT) | + +-------+-------+ + | + v + +-------+-------+ + | LLM处理指令 | + | | + +-------+-------+ + | + v + +-------+-------+ + | 生成物联网命令 | + | | + +-------+-------+ + | + v + +---------------+---------------+ + | Application接收IoT消息 | + | _handle_iot_message() | + +---------------+---------------+ + | + v + +---------------+---------------+ + | ThingManager.invoke() | + +---------------+---------------+ + | + +------------------+------------------+------------------+ + | | | | + v v v v ++----------+-------+ +-------+--------+ +------+---------+ +----+-----------+ +| Lamp | | Speaker | | MusicPlayer | | CameraVL | +| (控制灯设备) | | (控制音量设备) | | (播放音乐设备) | | (摄像头与视觉) | ++----------+-------+ +-------+--------+ +------+---------+ +----+-----------+ + | | | | + | | | | + | | | | + | | | v + | | | +------+---------+ + | | | | Camera.py | + | | | | (摄像头控制) | + | | | +------+---------+ + | | | | + | | | v + | | | +------+---------+ + | | | | VL.py | + | | | | (视觉识别处理) | + | | | +------+---------+ + | | | | + +------------------+------------------+------------------+ + | + v + +---------------+---------------+ + | 执行设备操作 | + +---------------+---------------+ + | + v + +---------------+---------------+ + | 更新设备状态 | + | _update_iot_states() | + +---------------+---------------+ + | + v + +---------------+---------------+ + | 发送状态更新到服务器 | + | send_iot_states(states) | + +---------------+---------------+ + | + v + +---------------+---------------+ + | 服务器更新设备状态 | + +---------------+---------------+ + | + v + +---------------+---------------+ + | 返回执行结果给用户 | + | (语音或界面反馈) | + +-------------------------------+ +``` + +#### IoT设备管理 +- IoT模块采用灵活的多协议通信架构: + - MQTT协议:用于与标准物联网设备通信,如智能灯、空调等 + - HTTP协议:用于与Web服务交互,如获取在线音乐、调用多模态AI模型等 + - 可扩展支持其他协议:如WebSocket、TCP等 +- 支持自动发现和管理IoT设备 +- 可通过语音命令控制IoT设备,例如: + - "查看当前物联网设备" + - "打开客厅的灯" + - "关闭空调" + - "设置温度为26度" + - "打开摄像头" + - "关闭摄像头" + - "识别画面" + +#### 添加新的IoT设备 +1. 在`src/iot/things`目录下创建新的设备类 +2. 继承`Thing`基类并实现必要方法 +3. 在`thing_manager.py`中注册新设备 + +### 注意事项 +1. 确保相应的服务器配置正确且可访问: + - MQTT服务器配置(用于物联网设备) + - API接口地址(用于HTTP服务) +2. 不同协议的设备/服务需实现对应的连接和通信逻辑 +3. 建议为每个新增设备/服务添加基本的错误处理和重连机制 +4. 可以通过扩展Thing基类来支持新的通信协议 +5. 在添加新设备时,建议先进行通信测试,确保连接稳定 + +#### 在线音乐配置 +- 接入在线音源了,无需自行配置默认可用 +### 运行模式说明 +#### GUI 模式运行(默认) +```bash +python main.py +``` + + +#### CLI模式运行 +```bash +python main.py --mode cli +``` + +#### 构建打包 + +使用PyInstaller打包为可执行文件: + +```bash +# Windows +python scripts/build.py + +# macOS +python scripts/build.py + +# Linux +python scripts/build.py +``` + +### 注意事项 +1. 建议使用 Python 3.9.13+ 版本,推荐 3.12 +2. Windows 用户无需手动安装 opus.dll,项目会自动处理 +3. 使用 Conda 环境时必须安装 ffmpeg 和 Opus +4. 使用 Conda 环境时请勿和esp32-server共用同一个Conda环境,因为服务端websocket依赖版本高于本项目 +5. 建议使用国内镜像源安装依赖,可以提高下载速度 +6. macOS 用户需使用专门的 requirements_mac.txt +7. 确保系统依赖安装完成后再安装 Python 依赖 +8. 如若使用xiaozhi-esp32-server作为服务端该项目只能自动对话才有反应 +9. esp32-server视频部署教程 [新版!小智ai服务端本地部署完整教程,支持DeepSeek接入](https://www.bilibili.com/video/BV1GvQWYZEd2/?share_source=copy_web&vd_source=86370b0cff2da3ab6e3d26eb1cab13d3) +10. 音量控制功能需要安装特定依赖,程序会在启动时自动检查并提示缺少的依赖 + +### 音量控制功能说明 + +本应用支持调整系统音量,根据不同操作系统需要安装不同的依赖: + +1. **Windows**: 使用 pycaw 和 comtypes 控制系统音量 +2. **macOS**: 使用 applescript 控制系统音量 +3. **Linux**: 根据系统环境使用 pactl (PulseAudio)、wpctl (PipeWire)、amixer (ALSA) 或 alsamixer 控制音量 + +应用程序会在启动时自动检查这些依赖是否已安装。如果缺少依赖,将会显示相应的安装指令。 + +#### 音量控制使用方法 + +- **GUI模式**: 使用界面上的音量滑块调节音量 +- **CLI模式**: 使用 `v <音量值>` 命令调节音量,例如 `v 50` 将音量设置为50% + +### 状态流转图 + +``` + +----------------+ + | | + v | ++------+ 唤醒词/按钮 +------------+ | +------------+ +| IDLE | -----------> | CONNECTING | --+-> | LISTENING | ++------+ +------------+ +------------+ + ^ | + | | 语音识别完成 + | +------------+ v + +--------- | SPEAKING | <-----------------+ + 完成播放 +------------+ +``` + +## 获取帮助 +如果遇到问题: + +1. 优先查看 docs/异常汇总.md 文档 +2. 通过 GitHub Issues 提交问题 +3. 通过 AI 助手寻求帮助 +4. 联系作者(主页有微信)(请自备 Todesk 链接并说明来意,作者工作日晚上处理) \ No newline at end of file diff --git "a/documents/docs/guide/\345\274\202\345\270\270\346\261\207\346\200\273.md" "b/documents/docs/guide/\345\274\202\345\270\270\346\261\207\346\200\273.md" new file mode 100644 index 0000000000000000000000000000000000000000..1a34ee182ab209a20404100a90dfac1e9053e451 --- /dev/null +++ "b/documents/docs/guide/\345\274\202\345\270\270\346\261\207\346\200\273.md" @@ -0,0 +1,331 @@ +# 错误问题汇总 + +## 1. `Could not find Opus library. Make sure it is installed.` + +### **错误描述** + +``` +(.venv) C:\Users\Junsen\Desktop\learning\xiaozhi-python>python xiaozhi-python.py +Traceback (most recent call last): + File "C:\Users\Junsen\Desktop\learning\xiaozhi-python\xiaozhi-python.py", line 5, in + import opuslib + File "C:\Users\Junsen\Desktop\learning\xiaozhi-python\.venv\lib\site-packages\opuslib\__init__.py", line 19, in + from .exceptions import OpusError # NOQA + File "C:\Users\Junsen\Desktop\learning\xiaozhi-python\.venv\lib\site-packages\opuslib\exceptions.py", line 10, in + import opuslib.api.info + File "C:\Users\Junsen\Desktop\learning\xiaozhi-python\.venv\lib\site-packages\opuslib\api\__init__.py", line 20, in + raise Exception( +Exception: Could not find Opus library. Make sure it is installed. +``` + +### **解决方案** + +1. **Windows** + + - 下载并安装 Opus 库。 + - 确保 `opuslib` 相关库正确安装。 + +2. **Linux/macOS** + + - 运行以下命令安装 `libopus`: + ```sh + sudo apt-get install libopus-dev # Ubuntu/Debian + brew install opus # macOS + ``` + +3. **Python 代码安装** + + ```sh + pip install opuslib + ``` + +--- + +## 2. `externally-managed-environment` (macOS) + +### **错误描述** + +``` +(.venv) huangjunsen@huangjunsendeMac-mini py-xiaozhi % pip install -r requirements_mac.txt -i https://pypi.tuna.tsinghua.edu.cn/simple + +error: externally-managed-environment + +× This environment is externally managed +╰─> To install Python packages system-wide, try brew install + xyz, where xyz is the package you are trying to + install. + + If you wish to install a Python library that isn't in Homebrew, + use a virtual environment: + + python3 -m venv path/to/venv + source path/to/venv/bin/activate + python3 -m pip install xyz + + If you wish to install a Python application that isn't in Homebrew, + it may be easiest to use 'pipx install xyz', which will manage a + virtual environment for you. You can install pipx with + + brew install pipx + + You may restore the old behavior of pip by passing + the '--break-system-packages' flag to pip, or by adding + 'break-system-packages = true' to your pip.conf file. The latter + will permanently disable this error. + + If you disable this error, we STRONGLY recommend that you additionally + pass the '--user' flag to pip, or set 'user = true' in your pip.conf + file. Failure to do this can result in a broken Homebrew installation. + + Read more about this behavior here: + +note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages. +hint: See PEP 668 for the detailed specification. +``` + +### **解决方案** + +1. **使用虚拟环境安装** + ```sh + python3 -m venv my_env + source my_env/bin/activate + pip install -r requirements.txt + ``` +2. **使用 **``** 进行全局安装** + ```sh + brew install pipx + pipx install package_name + ``` +3. **强制安装(不推荐)** + ```sh + pip install package_name --break-system-packages + ``` + +--- + +## 3. `WebSocket连接失败: BaseEventLoop.create_connection() got an unexpected keyword argument 'extra_headers'` + +### **错误描述** + +```python +# 建立WebSocket连接 +self.websocket = await websockets.connect( + self.WEBSOCKET_URL, + extra_headers=headers # 高版本这里改为 additional_headers=headers +) +``` + +### **解决方案** + +- **新版本 **``: `extra_headers` 改为 `additional_headers`。 +- **旧版本 **``: `additional_headers` 改为 `extra_headers`。 + +--- + +## 4. `没有找到默认的输入/输出音频设备` + +### **错误描述** + +``` +AudioCodec - ERROR - 初始化音频设备失败: [Errno -9996] Invalid input device (no default output device) +AudioCodec - WARNING - 无法初始化音频设备: [Errno -9996] Invalid input device (no default output device) +``` + +### **解决方案** + +1. **Windows**: + + - 在 **声音设置** 中启用麦克风和扬声器。 + +2. **Linux/macOS**: + + ```sh + pactl list sources | grep "Name" + ``` + +3. **检查可用音频设备**: + + ```python + import pyaudio + p = pyaudio.PyAudio() + for i in range(p.get_device_count()): + print(f"设备 {i}: {p.get_device_info_by_index(i)['name']}") + ``` + +4. **手动指定音频设备**: + + ```python + stream = p.open(format=pyaudio.paInt16, channels=1, rate=16000, input=True, input_device_index=0) + ``` + +--- + + +## **5. `ModuleNotFoundError: No module named '_tkinter'` mac m4以下常见 ** + +### **错误描述** +``` +(.venv) apple@appledeMac-mini py-xiaozhi % python main.py + +Traceback (most recent call last): + File "/Users/apple/Desktop/py-xiaozhi/main.py", line 5, in + from src.application import Application + File "/Users/apple/Desktop/py-xiaozhi/src/application.py", line 23, in + from src.display import gui_display, cli_display + File "/Users/apple/Desktop/py-xiaozhi/src/display/gui_display.py", line 2, in + import tkinter as tk + File "/opt/homebrew/Cellar/python@3.12/3.12.9/Frameworks/Python.framework/Versions/3.12/lib/python3.12/tkinter/__init__.py", line 38, in + import _tkinter # If this fails your Python may not be configured for Tk + ^^^^^^^^^^^^^^^ +ModuleNotFoundError: No module named '_tkinter' +``` + +### **解决方案** + +1. **安装 `tcl-tk`** + ```sh + brew upgrade tcl-tk # 一般第一步就可以了 + ``` + +2. **检查 Homebrew 的 `tcl-tk` 路径** + ```sh + brew info tcl-tk + ``` + +3. **重新安装 Python,并链接 `tcl-tk`** + ```sh + brew install python-tk + ``` + +4. **手动指定 `Tcl/Tk` 路径(如有必要)** + ```sh + export PATH="/opt/homebrew/opt/tcl-tk/bin:$PATH" + export LDFLAGS="-L/opt/homebrew/opt/tcl-tk/lib" + export CPPFLAGS="-I/opt/homebrew/opt/tcl-tk/include" + ``` + +5. **重新创建虚拟环境** + ```sh + python3 -m venv .venv + source .venv/bin/activate + pip install -r requirements.txt + ``` + +--- + +## 6. `导入 opuslib 失败: No module named 'pyaudioop'或'_cffi_backend'` + +### **错误描述** + +``` +找到opus库文件: D:\xiaozhi\PC\py-xiaozhi-main\libs\windows\opus.dll +已添加DLL搜索路径: D:\xiaozhi\PC\py-xiaozhi-main\libs\windows +已成功加载 opus.dll: D:\xiaozhi\PC\py-xiaozhi-main\libs\windows\opus.dll +导入 opuslib 失败: No module named 'pyaudioop' +确保 opus 动态库已正确安装或位于正确的位置 +``` + +或 + +``` +找到opus库文件: D:\xiaozhi\PC\py-xiaozhi-main\libs\windows\opus.dll +已添加DLL搜索路径: D:\xiaozhi\PC\py-xiaozhi-main\libs\windows +已成功加载 opus.dll: D:\xiaozhi\PC\py-xiaozhi-main\libs\windows\opus.dll +导入 opuslib 失败: No module named '_cffi_backend' +请确保 opus 动态库已正确安装或位于正确的位置 +``` + +### **解决方案** + +1. **Python版本兼容性问题** + - 这个错误通常与Python版本有关,尤其是Python 3.13版本 + - 建议使用Python 3.9-3.12版本 + +2. **重新安装cffi** + ```sh + pip uninstall cffi + pip install cffi + ``` + +3. **opus.dll放置** + - 确保已将opus.dll放在正确位置(项目根目录和System32目录) + ```sh + # 检查是否已复制到这些位置 + C:\Windows\System32\opus.dll + 项目根目录\opus.dll + 项目根目录\libs\windows\opus.dll + ``` + +4. **安装pyaudioop支持库** + - 对于'pyaudioop'错误,尝试降级Python版本或安装相关依赖 + ```sh + pip install pyaudio + ``` + +--- + + +## 8. `error: subprocess-exited-with-error`(安装 `numpy` 失败) + +### **错误描述** +``` +Collecting numpy==2.0.2 (from -r requirements.txt (line 8)) + Using cached https://mirrors.aliyun.com/pypi/packages/a9/75/10dd1f8116a8b796cb2c737b674e02d02e80454bda953fa7e65d8c12b016/numpy-2.0.2.tar.gz (18.9 MB) + Installing build dependencies ... done + Getting requirements to build wheel ... done + Installing backend dependencies ... done + Preparing metadata (pyproject.toml) ... error + error: subprocess-exited-with-error + + × Preparing metadata (pyproject.toml) did not run successfully. + │ exit code: 1 + ╰─> [21 lines of output] + ... + WARNING: Failed to activate VS environment: Could not parse vswhere.exe output + ERROR: Unknown compiler(s): [['icl'], ['cl'], ['cc'], ['gcc'], ['clang'], ['clang-cl'], ['pgcc']] + The following exception(s) were encountered: + Running `icl ""` gave "[WinError 2] 系统找不到指定的文件。" + Running `cl /?` gave "[WinError 2] 系统找不到指定的文件。" + Running `cc --version` gave "[WinError 2] 系统找不到指定的文件。" + Running `gcc --version` gave "[WinError 2] 系统找不到指定的文件。" + Running `clang --version` gave "[WinError 2] 系统找不到指定的文件。" + Running `clang-cl /?` gave "[WinError 2] 系统找不到指定的文件。" + Running `pgcc --version` gave "[WinError 2] 系统找不到指定的文件。" + + note: This error originates from a subprocess, and is likely not a problem with pip. +error: metadata-generation-failed + +× Encountered error while generating package metadata. +╰─> See above for output. + +note: This is an issue with the package mentioned above, not pip. +hint: See above for details. +``` + +### **解决方案** +- 建议python版本在 3.9 - 3.12 + +1. **确保 `numpy` 版本兼容** + + `numpy==2.0.2` 可能存在构建问题,建议尝试安装较稳定的版本: + ```sh + pip install numpy==1.24.3 + ``` + + 如果你不需要特定版本,可以安装最新稳定版本: + ```sh + pip install numpy + ``` + +2. **安装编译工具** + + Windows用户可能需要安装Visual C++ Build Tools: + ```sh + # 安装Microsoft C++ Build Tools + # 下载并安装: https://visualstudio.microsoft.com/visual-cpp-build-tools/ + ``` + +3. **使用预编译的轮子** + ```sh + pip install --only-binary=numpy numpy + ``` \ No newline at end of file diff --git a/documents/docs/index.md b/documents/docs/index.md new file mode 100644 index 0000000000000000000000000000000000000000..cffda57cfb55da7a70bb6fe7703ded065561f59e --- /dev/null +++ b/documents/docs/index.md @@ -0,0 +1,166 @@ +--- +# https://vitepress.dev/reference/default-theme-home-page +layout: home + +hero: + name: "PY-XIAOZHI" + tagline: py-xiaozhi 是一个使用 Python 实现的小智语音客户端,旨在通过代码学习和在没有硬件条件下体验 AI 小智的语音功能。 + actions: + - theme: brand + text: 开始使用 + link: /guide/00_文档目录 + - theme: alt + text: 查看源码 + link: https://github.com/huangjunsen0406/py-xiaozhi + +features: + - title: AI语音交互 + details: 支持语音输入与识别,实现智能人机交互,提供自然流畅的对话体验。 + - title: 视觉多模态 + details: 支持图像识别和处理,提供多模态交互能力,理解图像内容。 + - title: IoT 设备集成 + details: 支持智能家居设备控制,实现更多物联网功能,打造智能家居生态。 + - title: 联网音乐播放 + details: 基于pygame实现的高性能音乐播放器,支持歌词显示和本地缓存,支持播放/暂停/停止、进度控制、歌词显示和本地缓存,提供更稳定的音乐播放体验。 + - title: 语音唤醒 + details: 支持唤醒词激活交互,免去手动操作的烦恼(默认关闭需要手动开启)。 + - title: 自动对话模式 + details: 实现连续对话体验,提升用户交互流畅度。 + - title: 图形化界面 + details: 提供直观易用的 GUI,支持小智表情与文本显示,增强视觉体验。 + - title: 命令行模式 + details: 支持 CLI 运行,适用于嵌入式设备或无 GUI 环境。 + - title: 跨平台支持 + details: 兼容 Windows 10+、macOS 10.15+ 和 Linux 系统,随时随地使用。 + - title: 音量控制 + details: 支持音量调节,适应不同环境需求,统一声音控制接口。 + - title: 会话管理 + details: 有效管理多轮对话,保持交互的连续性。 + - title: 加密音频传输 + details: 支持 WSS 协议,保障音频数据的安全性,防止信息泄露。 + - title: 自动验证码处理 + details: 首次使用时,程序自动复制验证码并打开浏览器,简化用户操作。 + - title: 自动获取 MAC 地址 + details: 避免 MAC 地址冲突,提高连接稳定性。 + - title: 代码模块化 + details: 拆分代码并封装为类,职责分明,便于二次开发。 + - title: 稳定性优化 + details: 修复多项问题,包括断线重连、跨平台兼容等。 +--- + +
+

感谢以下开发者对 py-xiaozhi 作出的贡献

+ +
+ + contributors + +
+ + + +
+ + + diff --git a/documents/docs/sponsors/SponsorsList.vue b/documents/docs/sponsors/SponsorsList.vue new file mode 100644 index 0000000000000000000000000000000000000000..5901b8d594cb73c7d9d0fd52dcac865105ad89b4 --- /dev/null +++ b/documents/docs/sponsors/SponsorsList.vue @@ -0,0 +1,130 @@ + + + + + \ No newline at end of file diff --git a/documents/docs/sponsors/data.js b/documents/docs/sponsors/data.js new file mode 100644 index 0000000000000000000000000000000000000000..0094a075e775b555555a506e1aead322be1614c7 --- /dev/null +++ b/documents/docs/sponsors/data.js @@ -0,0 +1,75 @@ +// 赞助者数据 +export default { + "sponsors": [ + { + "name": "ZhengDongHang", + "url": "https://github.com/ZhengDongHang", + "image": "https://avatars.githubusercontent.com/u/193732878?v=4" + }, + { + "name": "YANG-success-last", + "url": "https://github.com/YANG-success-last", + "image": "https://tuchuang.junsen.online/i/2025/03/28/2kzeks.jpg" + }, + { + "name": "李洪刚", + "url": "https://github.com/SmartArduino", + "image": "https://tuchuang.junsen.online/i/2025/03/28/2kfo2p.jpg" + }, + { + "name": "kejily", + "url": "https://github.com/kejily", + "image": "https://tuchuang.junsen.online/i/2025/03/28/2mpif8.jpg" + }, + { + "name": "thomas", + "url": "", + "image": "https://tuchuang.junsen.online/i/2025/03/28/2km1gm.jpg" + }, + { + "name": "吃饭叫我", + "url": "", + "image": "https://tuchuang.junsen.online/i/2025/03/28/2k0i12.jpg" + }, + { + "name": "留白", + "url": "", + "image": "https://tuchuang.junsen.online/i/2025/03/30/115fqut.jpg" + }, + { + "name": "*腾", + "url": "", + "image": "https://tuchuang.junsen.online/i/2025/04/03/v259c.png" + }, + { + "name": "张海峰", + "url": "", + "image": "https://tuchuang.junsen.online/i/2025/04/14/mcp32y.jpg" + }, + { + "name": "邹一达", + "url": "https://github.com/zyddsg159357", + "image": "https://tuchuang.junsen.online/i/2025/04/14/mcd4q2.jpg" + }, + { + "name": "折木", + "url": "", + "image": "https://tuchuang.junsen.online/i/2025/04/17/3qrbs4.jpg" + }, + { + "name": "arron", + "url": "https://github.com/kernelj", + "image": "https://tuchuang.junsen.online/i/2025/04/20/ppbiy2.jpg" + }, + { + "name": "Hpp 💦", + "url": "", + "image": "https://tuchuang.junsen.online/i/2025/04/27/5a1ood.jpg" + }, + { + "name": "985", + "url": "", + "image": "https://tuchuang.junsen.online/i/2025/04/27/59h2g1.jpg" + } + ] +} \ No newline at end of file diff --git a/documents/docs/sponsors/index.md b/documents/docs/sponsors/index.md new file mode 100644 index 0000000000000000000000000000000000000000..8c49902207e58ed45d0ccdb48222009ef4e0885b --- /dev/null +++ b/documents/docs/sponsors/index.md @@ -0,0 +1,138 @@ +--- +title: 赞助支持 +description: 感谢所有赞助者的支持 +sidebar: false +outline: deep +--- + + + +
+ +# 赞助支持 + +
+

感谢所有赞助者的支持 ❤️

+
+ +
+ + + + +## 成为赞助者 + +请通过以下方式进行赞助: +您的赞助将用于: +- 支持设备兼容性测试 +- 新功能开发和维护 + + +
+
+

微信支付

+
+ 微信收款码 +
+
+
+

支付宝支付

+
+ 支付宝收款码 +
+
+
+ +### 设备兼容性支持 + +您可以通过以下方式支持设备兼容性: +- 在赞助备注中说明您的设备型号,我会优先支持这些设备 +- 直接赞助/捐赠硬件设备,帮助我进行开发和适配测试 +- 提供设备的详细参数和使用场景,便于我更好地进行开发 + +::: tip 联系方式 +硬件赞助请通过GitHub主页的邮箱联系我,以便协商寄送方式和地址 +::: + +
+ +
+ + \ No newline at end of file diff --git a/documents/index.ts b/documents/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/documents/package.json b/documents/package.json new file mode 100644 index 0000000000000000000000000000000000000000..2a96e078d8d1ef15306e87c166952231e74ee510 --- /dev/null +++ b/documents/package.json @@ -0,0 +1,35 @@ +{ + "name": "documents", + "version": "1.0.0", + "main": "index.js", + "directories": { + "doc": "docs" + }, + "scripts": { + "docs:dev": "vitepress dev docs", + "docs:build": "vitepress build docs", + "docs:preview": "vitepress preview docs" + }, + "keywords": [], + "author": "", + "license": "MIT", + "description": "", + "devDependencies": { + "@types/fs-extra": "^11.0.4", + "fs-extra": "^11.3.0", + "sass-embedded": "^1.86.3", + "typescript": "^5.8.3", + "vitepress": "^1.6.3" + }, + "dependencies": { + "@heroicons/vue": "^2.2.0", + "@tailwindcss/vite": "^4.1.4", + "echarts": "^5.6.0", + "tailwindcss": "^4.1.4", + "@vue/repl": "^4.4.2", + "@vue/theme": "^2.3.0", + "dynamics.js": "^1.1.5", + "gsap": "^3.12.5", + "vue": "^3.5.13" + } +} diff --git a/documents/pnpm-lock.yaml b/documents/pnpm-lock.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7a9c90fad09b6b87aaedeb7af7487ee6ca6008bc --- /dev/null +++ b/documents/pnpm-lock.yaml @@ -0,0 +1,2383 @@ +lockfileVersion: '9.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + + .: + dependencies: + '@heroicons/vue': + specifier: ^2.2.0 + version: 2.2.0(vue@3.5.13(typescript@5.8.3)) + '@tailwindcss/vite': + specifier: ^4.1.4 + version: 4.1.4(vite@5.4.18(@types/node@22.14.1)(lightningcss@1.29.2)(sass-embedded@1.86.3)) + '@vue/repl': + specifier: ^4.4.2 + version: 4.5.1 + '@vue/theme': + specifier: ^2.3.0 + version: 2.3.0(@algolia/client-search@5.23.4)(search-insights@2.17.3)(vitepress@1.6.3(@algolia/client-search@5.23.4)(@types/node@22.14.1)(lightningcss@1.29.2)(postcss@8.5.3)(sass-embedded@1.86.3)(search-insights@2.17.3)(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3)) + dynamics.js: + specifier: ^1.1.5 + version: 1.1.5 + echarts: + specifier: ^5.6.0 + version: 5.6.0 + gsap: + specifier: ^3.12.5 + version: 3.12.7 + tailwindcss: + specifier: ^4.1.4 + version: 4.1.4 + vue: + specifier: ^3.5.13 + version: 3.5.13(typescript@5.8.3) + devDependencies: + '@types/fs-extra': + specifier: ^11.0.4 + version: 11.0.4 + fs-extra: + specifier: ^11.3.0 + version: 11.3.0 + sass-embedded: + specifier: ^1.86.3 + version: 1.86.3 + typescript: + specifier: ^5.8.3 + version: 5.8.3 + vitepress: + specifier: ^1.6.3 + version: 1.6.3(@algolia/client-search@5.23.4)(@types/node@22.14.1)(lightningcss@1.29.2)(postcss@8.5.3)(sass-embedded@1.86.3)(search-insights@2.17.3)(typescript@5.8.3) + +packages: + + '@algolia/autocomplete-core@1.17.7': + resolution: {integrity: sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q==} + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7': + resolution: {integrity: sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A==} + peerDependencies: + search-insights: '>= 1 < 3' + + '@algolia/autocomplete-preset-algolia@1.17.7': + resolution: {integrity: sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/autocomplete-shared@1.17.7': + resolution: {integrity: sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg==} + peerDependencies: + '@algolia/client-search': '>= 4.9.1 < 6' + algoliasearch: '>= 4.9.1 < 6' + + '@algolia/client-abtesting@5.23.4': + resolution: {integrity: sha512-WIMT2Kxy+FFWXWQxIU8QgbTioL+SGE24zhpj0kipG4uQbzXwONaWt7ffaYLjfge3gcGSgJVv+1VlahVckafluQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-analytics@5.23.4': + resolution: {integrity: sha512-4B9gChENsQA9kFmFlb+x3YhBz2Gx3vSsm81FHI1yJ3fn2zlxREHmfrjyqYoMunsU7BybT/o5Nb7ccCbm/vfseA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-common@5.23.4': + resolution: {integrity: sha512-bsj0lwU2ytiWLtl7sPunr+oLe+0YJql9FozJln5BnIiqfKOaseSDdV42060vUy+D4373f2XBI009K/rm2IXYMA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-insights@5.23.4': + resolution: {integrity: sha512-XSCtAYvJ/hnfDHfRVMbBH0dayR+2ofVZy3jf5qyifjguC6rwxDsSdQvXpT0QFVyG+h8UPGtDhMPoUIng4wIcZA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-personalization@5.23.4': + resolution: {integrity: sha512-l/0QvqgRFFOf7BnKSJ3myd1WbDr86ftVaa3PQwlsNh7IpIHmvVcT83Bi5zlORozVGMwaKfyPZo6O48PZELsOeA==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-query-suggestions@5.23.4': + resolution: {integrity: sha512-TB0htrDgVacVGtPDyENoM6VIeYqR+pMsDovW94dfi2JoaRxfqu/tYmLpvgWcOknP6wLbr8bA+G7t/NiGksNAwQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/client-search@5.23.4': + resolution: {integrity: sha512-uBGo6KwUP6z+u6HZWRui8UJClS7fgUIAiYd1prUqCbkzDiCngTOzxaJbEvrdkK0hGCQtnPDiuNhC5MhtVNN4Eg==} + engines: {node: '>= 14.0.0'} + + '@algolia/ingestion@1.23.4': + resolution: {integrity: sha512-Si6rFuGnSeEUPU9QchYvbknvEIyCRK7nkeaPVQdZpABU7m4V/tsiWdHmjVodtx3h20VZivJdHeQO9XbHxBOcCw==} + engines: {node: '>= 14.0.0'} + + '@algolia/monitoring@1.23.4': + resolution: {integrity: sha512-EXGoVVTshraqPJgr5cMd1fq7Jm71Ew6MpGCEaxI5PErBpJAmKdtjRIzs6JOGKHRaWLi+jdbJPYc2y8RN4qcx5Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/recommend@5.23.4': + resolution: {integrity: sha512-1t6glwKVCkjvBNlng2itTf8fwaLSqkL4JaMENgR3WTGR8mmW2akocUy/ZYSQcG4TcR7qu4zW2UMGAwLoWoflgQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-browser-xhr@5.23.4': + resolution: {integrity: sha512-UUuizcgc5+VSY8hqzDFVdJ3Wcto03lpbFRGPgW12pHTlUQHUTADtIpIhkLLOZRCjXmCVhtr97Z+eR6LcRYXa3Q==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-fetch@5.23.4': + resolution: {integrity: sha512-UhDg6elsek6NnV5z4VG1qMwR6vbp+rTMBEnl/v4hUyXQazU+CNdYkl++cpdmLwGI/7nXc28xtZiL90Es3I7viQ==} + engines: {node: '>= 14.0.0'} + + '@algolia/requester-node-http@5.23.4': + resolution: {integrity: sha512-jXGzGBRUS0oywQwnaCA6mMDJO7LoC3dYSLsyNfIqxDR4SNGLhtg3je0Y31lc24OA4nYyKAYgVLtjfrpcpsWShg==} + engines: {node: '>= 14.0.0'} + + '@babel/helper-string-parser@7.25.9': + resolution: {integrity: sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==} + engines: {node: '>=6.9.0'} + + '@babel/helper-validator-identifier@7.25.9': + resolution: {integrity: sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==} + engines: {node: '>=6.9.0'} + + '@babel/parser@7.27.0': + resolution: {integrity: sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg==} + engines: {node: '>=6.0.0'} + hasBin: true + + '@babel/types@7.27.0': + resolution: {integrity: sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg==} + engines: {node: '>=6.9.0'} + + '@bufbuild/protobuf@2.2.5': + resolution: {integrity: sha512-/g5EzJifw5GF8aren8wZ/G5oMuPoGeS6MQD3ca8ddcvdXR5UELUfdTZITCGNhNXynY/AYl3Z4plmxdj/tRl/hQ==} + + '@docsearch/css@3.8.2': + resolution: {integrity: sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ==} + + '@docsearch/js@3.8.2': + resolution: {integrity: sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ==} + + '@docsearch/react@3.8.2': + resolution: {integrity: sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg==} + peerDependencies: + '@types/react': '>= 16.8.0 < 19.0.0' + react: '>= 16.8.0 < 19.0.0' + react-dom: '>= 16.8.0 < 19.0.0' + search-insights: '>= 1 < 3' + peerDependenciesMeta: + '@types/react': + optional: true + react: + optional: true + react-dom: + optional: true + search-insights: + optional: true + + '@esbuild/aix-ppc64@0.21.5': + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + + '@esbuild/android-arm64@0.21.5': + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + + '@esbuild/android-arm@0.21.5': + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + + '@esbuild/android-x64@0.21.5': + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + + '@esbuild/darwin-arm64@0.21.5': + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + + '@esbuild/darwin-x64@0.21.5': + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + + '@esbuild/freebsd-arm64@0.21.5': + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + + '@esbuild/freebsd-x64@0.21.5': + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + + '@esbuild/linux-arm64@0.21.5': + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + + '@esbuild/linux-arm@0.21.5': + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + + '@esbuild/linux-ia32@0.21.5': + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + + '@esbuild/linux-loong64@0.21.5': + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + + '@esbuild/linux-mips64el@0.21.5': + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + + '@esbuild/linux-ppc64@0.21.5': + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + + '@esbuild/linux-riscv64@0.21.5': + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + + '@esbuild/linux-s390x@0.21.5': + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + + '@esbuild/linux-x64@0.21.5': + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + + '@esbuild/netbsd-x64@0.21.5': + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + + '@esbuild/openbsd-x64@0.21.5': + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + + '@esbuild/sunos-x64@0.21.5': + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + + '@esbuild/win32-arm64@0.21.5': + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + + '@esbuild/win32-ia32@0.21.5': + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + + '@esbuild/win32-x64@0.21.5': + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + + '@heroicons/vue@2.2.0': + resolution: {integrity: sha512-G3dbSxoeEKqbi/DFalhRxJU4mTXJn7GwZ7ae8NuEQzd1bqdd0jAbdaBZlHPcvPD2xI1iGzNVB4k20Un2AguYPw==} + peerDependencies: + vue: '>= 3' + + '@iconify-json/simple-icons@1.2.32': + resolution: {integrity: sha512-gxgLq0raip7SJaeJ0302vwhsqupQttS21B93Ci1kA/++B+hIgGw71HzTOWQoUhwjlrdWcoVUxSvpPJoMs7oURg==} + + '@iconify/types@2.0.0': + resolution: {integrity: sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg==} + + '@jridgewell/sourcemap-codec@1.5.0': + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + '@rollup/rollup-android-arm-eabi@4.40.0': + resolution: {integrity: sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg==} + cpu: [arm] + os: [android] + + '@rollup/rollup-android-arm64@4.40.0': + resolution: {integrity: sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w==} + cpu: [arm64] + os: [android] + + '@rollup/rollup-darwin-arm64@4.40.0': + resolution: {integrity: sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ==} + cpu: [arm64] + os: [darwin] + + '@rollup/rollup-darwin-x64@4.40.0': + resolution: {integrity: sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA==} + cpu: [x64] + os: [darwin] + + '@rollup/rollup-freebsd-arm64@4.40.0': + resolution: {integrity: sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg==} + cpu: [arm64] + os: [freebsd] + + '@rollup/rollup-freebsd-x64@4.40.0': + resolution: {integrity: sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw==} + cpu: [x64] + os: [freebsd] + + '@rollup/rollup-linux-arm-gnueabihf@4.40.0': + resolution: {integrity: sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA==} + cpu: [arm] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm-musleabihf@4.40.0': + resolution: {integrity: sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg==} + cpu: [arm] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-arm64-gnu@4.40.0': + resolution: {integrity: sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg==} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-arm64-musl@4.40.0': + resolution: {integrity: sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ==} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-loongarch64-gnu@4.40.0': + resolution: {integrity: sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg==} + cpu: [loong64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': + resolution: {integrity: sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw==} + cpu: [ppc64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-gnu@4.40.0': + resolution: {integrity: sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA==} + cpu: [riscv64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-riscv64-musl@4.40.0': + resolution: {integrity: sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ==} + cpu: [riscv64] + os: [linux] + libc: [musl] + + '@rollup/rollup-linux-s390x-gnu@4.40.0': + resolution: {integrity: sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw==} + cpu: [s390x] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-gnu@4.40.0': + resolution: {integrity: sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ==} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@rollup/rollup-linux-x64-musl@4.40.0': + resolution: {integrity: sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw==} + cpu: [x64] + os: [linux] + libc: [musl] + + '@rollup/rollup-win32-arm64-msvc@4.40.0': + resolution: {integrity: sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ==} + cpu: [arm64] + os: [win32] + + '@rollup/rollup-win32-ia32-msvc@4.40.0': + resolution: {integrity: sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA==} + cpu: [ia32] + os: [win32] + + '@rollup/rollup-win32-x64-msvc@4.40.0': + resolution: {integrity: sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ==} + cpu: [x64] + os: [win32] + + '@shikijs/core@2.5.0': + resolution: {integrity: sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg==} + + '@shikijs/engine-javascript@2.5.0': + resolution: {integrity: sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w==} + + '@shikijs/engine-oniguruma@2.5.0': + resolution: {integrity: sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw==} + + '@shikijs/langs@2.5.0': + resolution: {integrity: sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w==} + + '@shikijs/themes@2.5.0': + resolution: {integrity: sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw==} + + '@shikijs/transformers@2.5.0': + resolution: {integrity: sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg==} + + '@shikijs/types@2.5.0': + resolution: {integrity: sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw==} + + '@shikijs/vscode-textmate@10.0.2': + resolution: {integrity: sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg==} + + '@tailwindcss/node@4.1.4': + resolution: {integrity: sha512-MT5118zaiO6x6hNA04OWInuAiP1YISXql8Z+/Y8iisV5nuhM8VXlyhRuqc2PEviPszcXI66W44bCIk500Oolhw==} + + '@tailwindcss/oxide-android-arm64@4.1.4': + resolution: {integrity: sha512-xMMAe/SaCN/vHfQYui3fqaBDEXMu22BVwQ33veLc8ep+DNy7CWN52L+TTG9y1K397w9nkzv+Mw+mZWISiqhmlA==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [android] + + '@tailwindcss/oxide-darwin-arm64@4.1.4': + resolution: {integrity: sha512-JGRj0SYFuDuAGilWFBlshcexev2hOKfNkoX+0QTksKYq2zgF9VY/vVMq9m8IObYnLna0Xlg+ytCi2FN2rOL0Sg==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [darwin] + + '@tailwindcss/oxide-darwin-x64@4.1.4': + resolution: {integrity: sha512-sdDeLNvs3cYeWsEJ4H1DvjOzaGios4QbBTNLVLVs0XQ0V95bffT3+scptzYGPMjm7xv4+qMhCDrkHwhnUySEzA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [darwin] + + '@tailwindcss/oxide-freebsd-x64@4.1.4': + resolution: {integrity: sha512-VHxAqxqdghM83HslPhRsNhHo91McsxRJaEnShJOMu8mHmEj9Ig7ToHJtDukkuLWLzLboh2XSjq/0zO6wgvykNA==} + engines: {node: '>= 10'} + cpu: [x64] + os: [freebsd] + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': + resolution: {integrity: sha512-OTU/m/eV4gQKxy9r5acuesqaymyeSCnsx1cFto/I1WhPmi5HDxX1nkzb8KYBiwkHIGg7CTfo/AcGzoXAJBxLfg==} + engines: {node: '>= 10'} + cpu: [arm] + os: [linux] + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': + resolution: {integrity: sha512-hKlLNvbmUC6z5g/J4H+Zx7f7w15whSVImokLPmP6ff1QqTVE+TxUM9PGuNsjHvkvlHUtGTdDnOvGNSEUiXI1Ww==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-arm64-musl@4.1.4': + resolution: {integrity: sha512-X3As2xhtgPTY/m5edUtddmZ8rCruvBvtxYLMw9OsZdH01L2gS2icsHRwxdU0dMItNfVmrBezueXZCHxVeeb7Aw==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-linux-x64-gnu@4.1.4': + resolution: {integrity: sha512-2VG4DqhGaDSmYIu6C4ua2vSLXnJsb/C9liej7TuSO04NK+JJJgJucDUgmX6sn7Gw3Cs5ZJ9ZLrnI0QRDOjLfNQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [glibc] + + '@tailwindcss/oxide-linux-x64-musl@4.1.4': + resolution: {integrity: sha512-v+mxVgH2kmur/X5Mdrz9m7TsoVjbdYQT0b4Z+dr+I4RvreCNXyCFELZL/DO0M1RsidZTrm6O1eMnV6zlgEzTMQ==} + engines: {node: '>= 10'} + cpu: [x64] + os: [linux] + libc: [musl] + + '@tailwindcss/oxide-wasm32-wasi@4.1.4': + resolution: {integrity: sha512-2TLe9ir+9esCf6Wm+lLWTMbgklIjiF0pbmDnwmhR9MksVOq+e8aP3TSsXySnBDDvTTVd/vKu1aNttEGj3P6l8Q==} + engines: {node: '>=14.0.0'} + cpu: [wasm32] + bundledDependencies: + - '@napi-rs/wasm-runtime' + - '@emnapi/core' + - '@emnapi/runtime' + - '@tybys/wasm-util' + - '@emnapi/wasi-threads' + - tslib + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': + resolution: {integrity: sha512-VlnhfilPlO0ltxW9/BgfLI5547PYzqBMPIzRrk4W7uupgCt8z6Trw/tAj6QUtF2om+1MH281Pg+HHUJoLesmng==} + engines: {node: '>= 10'} + cpu: [arm64] + os: [win32] + + '@tailwindcss/oxide-win32-x64-msvc@4.1.4': + resolution: {integrity: sha512-+7S63t5zhYjslUGb8NcgLpFXD+Kq1F/zt5Xv5qTv7HaFTG/DHyHD9GA6ieNAxhgyA4IcKa/zy7Xx4Oad2/wuhw==} + engines: {node: '>= 10'} + cpu: [x64] + os: [win32] + + '@tailwindcss/oxide@4.1.4': + resolution: {integrity: sha512-p5wOpXyOJx7mKh5MXh5oKk+kqcz8T+bA3z/5VWWeQwFrmuBItGwz8Y2CHk/sJ+dNb9B0nYFfn0rj/cKHZyjahQ==} + engines: {node: '>= 10'} + + '@tailwindcss/vite@4.1.4': + resolution: {integrity: sha512-4UQeMrONbvrsXKXXp/uxmdEN5JIJ9RkH7YVzs6AMxC/KC1+Np7WZBaNIco7TEjlkthqxZbt8pU/ipD+hKjm80A==} + peerDependencies: + vite: ^5.2.0 || ^6 + + '@types/estree@1.0.7': + resolution: {integrity: sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ==} + + '@types/fs-extra@11.0.4': + resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + + '@types/hast@3.0.4': + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + + '@types/jsonfile@6.1.4': + resolution: {integrity: sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ==} + + '@types/linkify-it@5.0.0': + resolution: {integrity: sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q==} + + '@types/markdown-it@14.1.2': + resolution: {integrity: sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog==} + + '@types/mdast@4.0.4': + resolution: {integrity: sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA==} + + '@types/mdurl@2.0.0': + resolution: {integrity: sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg==} + + '@types/node@22.14.1': + resolution: {integrity: sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw==} + + '@types/unist@3.0.3': + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + + '@types/web-bluetooth@0.0.20': + resolution: {integrity: sha512-g9gZnnXVq7gM7v3tJCWV/qw7w+KeOlSHAhgF9RytFyifW6AF61hdT2ucrYhPq9hLs5JIryeupHV3qGk95dH9ow==} + + '@types/web-bluetooth@0.0.21': + resolution: {integrity: sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA==} + + '@ungap/structured-clone@1.3.0': + resolution: {integrity: sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g==} + + '@vitejs/plugin-vue@5.2.3': + resolution: {integrity: sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + vite: ^5.0.0 || ^6.0.0 + vue: ^3.2.25 + + '@vue/compiler-core@3.5.13': + resolution: {integrity: sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q==} + + '@vue/compiler-dom@3.5.13': + resolution: {integrity: sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA==} + + '@vue/compiler-sfc@3.5.13': + resolution: {integrity: sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ==} + + '@vue/compiler-ssr@3.5.13': + resolution: {integrity: sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA==} + + '@vue/devtools-api@7.7.5': + resolution: {integrity: sha512-HYV3tJGARROq5nlVMJh5KKHk7GU8Au3IrrmNNqr978m0edxgpHgYPDoNUGrvEgIbObz09SQezFR3A1EVmB5WZg==} + + '@vue/devtools-kit@7.7.5': + resolution: {integrity: sha512-S9VAVJYVAe4RPx2JZb9ZTEi0lqTySz2CBeF0wHT5D3dkTLnT9yMMGegKNl4b2EIELwLSkcI9bl2qp0/jW+upqA==} + + '@vue/devtools-shared@7.7.5': + resolution: {integrity: sha512-QBjG72RfpM0DKtpns2RZOxBltO226kOAls9e4Lri6YxS2gWTgL0H+wj1R2K76lxxIeOrqo4+2Ty6RQnzv+WSTQ==} + + '@vue/reactivity@3.5.13': + resolution: {integrity: sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg==} + + '@vue/repl@4.5.1': + resolution: {integrity: sha512-YYXvFue2GOrZ6EWnoA8yQVKzdCIn45+tpwJHzMof1uwrgyYAVY9ynxCsDYeAuWcpaAeylg/nybhFuqiFy2uvYA==} + + '@vue/runtime-core@3.5.13': + resolution: {integrity: sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw==} + + '@vue/runtime-dom@3.5.13': + resolution: {integrity: sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog==} + + '@vue/server-renderer@3.5.13': + resolution: {integrity: sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA==} + peerDependencies: + vue: 3.5.13 + + '@vue/shared@3.5.13': + resolution: {integrity: sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ==} + + '@vue/theme@2.3.0': + resolution: {integrity: sha512-eKd4ipY6i6P2XD2iRVgbxs936g7pesEY2AgNX24C/sjzcmCnm48J7uV8xKXI2du2qfA89/r5QQp7bqZVf2Tekw==} + peerDependencies: + vitepress: ^1.2.2 + + '@vueuse/core@10.11.1': + resolution: {integrity: sha512-guoy26JQktXPcz+0n3GukWIy/JDNKti9v6VEMu6kV2sYBsWuGiTU8OWdg+ADfUbHg3/3DlqySDe7JmdHrktiww==} + + '@vueuse/core@12.8.2': + resolution: {integrity: sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ==} + + '@vueuse/integrations@12.8.2': + resolution: {integrity: sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g==} + peerDependencies: + async-validator: ^4 + axios: ^1 + change-case: ^5 + drauu: ^0.4 + focus-trap: ^7 + fuse.js: ^7 + idb-keyval: ^6 + jwt-decode: ^4 + nprogress: ^0.2 + qrcode: ^1.5 + sortablejs: ^1 + universal-cookie: ^7 + peerDependenciesMeta: + async-validator: + optional: true + axios: + optional: true + change-case: + optional: true + drauu: + optional: true + focus-trap: + optional: true + fuse.js: + optional: true + idb-keyval: + optional: true + jwt-decode: + optional: true + nprogress: + optional: true + qrcode: + optional: true + sortablejs: + optional: true + universal-cookie: + optional: true + + '@vueuse/metadata@10.11.1': + resolution: {integrity: sha512-IGa5FXd003Ug1qAZmyE8wF3sJ81xGLSqTqtQ6jaVfkeZ4i5kS2mwQF61yhVqojRnenVew5PldLyRgvdl4YYuSw==} + + '@vueuse/metadata@12.8.2': + resolution: {integrity: sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A==} + + '@vueuse/shared@10.11.1': + resolution: {integrity: sha512-LHpC8711VFZlDaYUXEBbFBCQ7GS3dVU9mjOhhMhXP6txTV4EhYQg/KGnQuvt/sPAtoUKq7VVUnL6mVtFoL42sA==} + + '@vueuse/shared@12.8.2': + resolution: {integrity: sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w==} + + algoliasearch@5.23.4: + resolution: {integrity: sha512-QzAKFHl3fm53s44VHrTdEo0TkpL3XVUYQpnZy1r6/EHvMAyIg+O4hwprzlsNmcCHTNyVcF2S13DAUn7XhkC6qg==} + engines: {node: '>= 14.0.0'} + + birpc@2.3.0: + resolution: {integrity: sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g==} + + body-scroll-lock@4.0.0-beta.0: + resolution: {integrity: sha512-a7tP5+0Mw3YlUJcGAKUqIBkYYGlYxk2fnCasq/FUph1hadxlTRjF+gAcZksxANnaMnALjxEddmSi/H3OR8ugcQ==} + + buffer-builder@0.2.0: + resolution: {integrity: sha512-7VPMEPuYznPSoR21NE1zvd2Xna6c/CloiZCfcMXR1Jny6PjX0N4Nsa38zcBFo/FMK+BlA+FLKbJCQ0i2yxp+Xg==} + + ccount@2.0.1: + resolution: {integrity: sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg==} + + character-entities-html4@2.1.0: + resolution: {integrity: sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA==} + + character-entities-legacy@3.0.0: + resolution: {integrity: sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ==} + + colorjs.io@0.5.2: + resolution: {integrity: sha512-twmVoizEW7ylZSN32OgKdXRmo1qg+wT5/6C3xu5b9QsWzSFAhHLn2xd8ro0diCsKfCj1RdaTP/nrcW+vAoQPIw==} + + comma-separated-tokens@2.0.3: + resolution: {integrity: sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg==} + + copy-anything@3.0.5: + resolution: {integrity: sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w==} + engines: {node: '>=12.13'} + + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + + detect-libc@2.0.3: + resolution: {integrity: sha512-bwy0MGW55bG41VqxxypOsdSdGqLwXPI/focwgTYCFMbdUiBAxLg9CFzG08sz2aqzknwiX7Hkl0bQENjg8iLByw==} + engines: {node: '>=8'} + + devlop@1.1.0: + resolution: {integrity: sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA==} + + dynamics.js@1.1.5: + resolution: {integrity: sha512-c+LHNccaJS67T4Jfk9b/5CwYsZCHmc10+MplWB8WPFyqTMEqOf8MI56Rg0JRILWjtXnjuBO7xmrNevNnPX+NHg==} + + echarts@5.6.0: + resolution: {integrity: sha512-oTbVTsXfKuEhxftHqL5xprgLoc0k7uScAwtryCgWF6hPYFLRwOUHiFmHGCBKP5NPFNkDVopOieyUqYGH8Fa3kA==} + + emoji-regex-xs@1.0.0: + resolution: {integrity: sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg==} + + enhanced-resolve@5.18.1: + resolution: {integrity: sha512-ZSW3ma5GkcQBIpwZTSRAI8N71Uuwgs93IezB7mf7R60tC8ZbJideoDNKjHn2O9KIlx6rkGTTEk1xUCK2E1Y2Yg==} + engines: {node: '>=10.13.0'} + + entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + + esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + + estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + + focus-trap@7.6.4: + resolution: {integrity: sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw==} + + fs-extra@11.3.0: + resolution: {integrity: sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew==} + engines: {node: '>=14.14'} + + fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + + graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + + gsap@3.12.7: + resolution: {integrity: sha512-V4GsyVamhmKefvcAKaoy0h6si0xX7ogwBoBSs2CTJwt7luW0oZzC0LhdkyuKV8PJAXr7Yaj8pMjCKD4GJ+eEMg==} + + has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + + hast-util-to-html@9.0.5: + resolution: {integrity: sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw==} + + hast-util-whitespace@3.0.0: + resolution: {integrity: sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw==} + + hookable@5.5.3: + resolution: {integrity: sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ==} + + html-void-elements@3.0.0: + resolution: {integrity: sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg==} + + immutable@5.1.1: + resolution: {integrity: sha512-3jatXi9ObIsPGr3N5hGw/vWWcTkq6hUYhpQz4k0wLC+owqWi/LiugIw9x0EdNZ2yGedKN/HzePiBvaJRXa0Ujg==} + + is-what@4.1.16: + resolution: {integrity: sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A==} + engines: {node: '>=12.13'} + + jiti@2.4.2: + resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} + hasBin: true + + jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + + lightningcss-darwin-arm64@1.29.2: + resolution: {integrity: sha512-cK/eMabSViKn/PG8U/a7aCorpeKLMlK0bQeNHmdb7qUnBkNPnL+oV5DjJUo0kqWsJUapZsM4jCfYItbqBDvlcA==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [darwin] + + lightningcss-darwin-x64@1.29.2: + resolution: {integrity: sha512-j5qYxamyQw4kDXX5hnnCKMf3mLlHvG44f24Qyi2965/Ycz829MYqjrVg2H8BidybHBp9kom4D7DR5VqCKDXS0w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [darwin] + + lightningcss-freebsd-x64@1.29.2: + resolution: {integrity: sha512-wDk7M2tM78Ii8ek9YjnY8MjV5f5JN2qNVO+/0BAGZRvXKtQrBC4/cn4ssQIpKIPP44YXw6gFdpUF+Ps+RGsCwg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [freebsd] + + lightningcss-linux-arm-gnueabihf@1.29.2: + resolution: {integrity: sha512-IRUrOrAF2Z+KExdExe3Rz7NSTuuJ2HvCGlMKoquK5pjvo2JY4Rybr+NrKnq0U0hZnx5AnGsuFHjGnNT14w26sg==} + engines: {node: '>= 12.0.0'} + cpu: [arm] + os: [linux] + + lightningcss-linux-arm64-gnu@1.29.2: + resolution: {integrity: sha512-KKCpOlmhdjvUTX/mBuaKemp0oeDIBBLFiU5Fnqxh1/DZ4JPZi4evEH7TKoSBFOSOV3J7iEmmBaw/8dpiUvRKlQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [glibc] + + lightningcss-linux-arm64-musl@1.29.2: + resolution: {integrity: sha512-Q64eM1bPlOOUgxFmoPUefqzY1yV3ctFPE6d/Vt7WzLW4rKTv7MyYNky+FWxRpLkNASTnKQUaiMJ87zNODIrrKQ==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [linux] + libc: [musl] + + lightningcss-linux-x64-gnu@1.29.2: + resolution: {integrity: sha512-0v6idDCPG6epLXtBH/RPkHvYx74CVziHo6TMYga8O2EiQApnUPZsbR9nFNrg2cgBzk1AYqEd95TlrsL7nYABQg==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [glibc] + + lightningcss-linux-x64-musl@1.29.2: + resolution: {integrity: sha512-rMpz2yawkgGT8RULc5S4WiZopVMOFWjiItBT7aSfDX4NQav6M44rhn5hjtkKzB+wMTRlLLqxkeYEtQ3dd9696w==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [linux] + libc: [musl] + + lightningcss-win32-arm64-msvc@1.29.2: + resolution: {integrity: sha512-nL7zRW6evGQqYVu/bKGK+zShyz8OVzsCotFgc7judbt6wnB2KbiKKJwBE4SGoDBQ1O94RjW4asrCjQL4i8Fhbw==} + engines: {node: '>= 12.0.0'} + cpu: [arm64] + os: [win32] + + lightningcss-win32-x64-msvc@1.29.2: + resolution: {integrity: sha512-EdIUW3B2vLuHmv7urfzMI/h2fmlnOQBk1xlsDxkN1tCWKjNFjfLhGxYk8C8mzpSfr+A6jFFIi8fU6LbQGsRWjA==} + engines: {node: '>= 12.0.0'} + cpu: [x64] + os: [win32] + + lightningcss@1.29.2: + resolution: {integrity: sha512-6b6gd/RUXKaw5keVdSEtqFVdzWnU5jMxTUjA2bVcMNPLwSQ08Sv/UodBVtETLCn7k4S1Ibxwh7k68IwLZPgKaA==} + engines: {node: '>= 12.0.0'} + + magic-string@0.30.17: + resolution: {integrity: sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==} + + mark.js@8.11.1: + resolution: {integrity: sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ==} + + mdast-util-to-hast@13.2.0: + resolution: {integrity: sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA==} + + micromark-util-character@2.1.1: + resolution: {integrity: sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q==} + + micromark-util-encode@2.0.1: + resolution: {integrity: sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw==} + + micromark-util-sanitize-uri@2.0.1: + resolution: {integrity: sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ==} + + micromark-util-symbol@2.0.1: + resolution: {integrity: sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q==} + + micromark-util-types@2.0.2: + resolution: {integrity: sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA==} + + minisearch@7.1.2: + resolution: {integrity: sha512-R1Pd9eF+MD5JYDDSPAp/q1ougKglm14uEkPMvQ/05RGmx6G9wvmLTrTI/Q5iPNJLYqNdsDQ7qTGIcNWR+FrHmA==} + + mitt@3.0.1: + resolution: {integrity: sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==} + + nanoid@3.3.11: + resolution: {integrity: sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + + normalize.css@8.0.1: + resolution: {integrity: sha512-qizSNPO93t1YUuUhP22btGOo3chcvDFqFaj2TRybP0DMxkHOCTYwp3n34fel4a31ORXy4m1Xq0Gyqpb5m33qIg==} + + oniguruma-to-es@3.1.1: + resolution: {integrity: sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ==} + + perfect-debounce@1.0.0: + resolution: {integrity: sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA==} + + picocolors@1.1.1: + resolution: {integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==} + + postcss@8.5.3: + resolution: {integrity: sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A==} + engines: {node: ^10 || ^12 || >=14} + + preact@10.26.5: + resolution: {integrity: sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w==} + + property-information@7.0.0: + resolution: {integrity: sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg==} + + regex-recursion@6.0.2: + resolution: {integrity: sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg==} + + regex-utilities@2.3.0: + resolution: {integrity: sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng==} + + regex@6.0.1: + resolution: {integrity: sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA==} + + rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + + rollup@4.40.0: + resolution: {integrity: sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + + rxjs@7.8.2: + resolution: {integrity: sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==} + + sass-embedded-android-arm64@1.86.3: + resolution: {integrity: sha512-q+XwFp6WgAv+UgnQhsB8KQ95kppvWAB7DSoJp+8Vino8b9ND+1ai3cUUZPE5u4SnLZrgo5NtrbPvN5KLc4Pfyg==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [android] + + sass-embedded-android-arm@1.86.3: + resolution: {integrity: sha512-UyeXrFzZSvrGbvrWUBcspbsbivGgAgebLGJdSqJulgSyGbA6no3DWQ5Qpdd6+OAUC39BlpPu74Wx9s4RrVuaFw==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [android] + + sass-embedded-android-ia32@1.86.3: + resolution: {integrity: sha512-gTJjVh2cRzvGujXj5ApPk/owUTL5SiO7rDtNLrzYAzi1N5HRuLYXqk3h1IQY3+eCOBjGl7mQ9XyySbJs/3hDvg==} + engines: {node: '>=14.0.0'} + cpu: [ia32] + os: [android] + + sass-embedded-android-riscv64@1.86.3: + resolution: {integrity: sha512-Po3JnyiCS16kd6REo1IMUbFGYtvL9O0rmKaXx5vOuBaJD1LPy2LiSSp7TU7wkJ9IxsTDGzFaSeP1I9qb6D8VVg==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [android] + + sass-embedded-android-x64@1.86.3: + resolution: {integrity: sha512-+7h3jdDv/0kUFx0BvxYlq2fa7CcHiDPlta6k5OxO5K6jyqJwo9hc0Z052BoYEauWTqZ+vK6bB5rv2BIzq4U9nA==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [android] + + sass-embedded-darwin-arm64@1.86.3: + resolution: {integrity: sha512-EgLwV4ORm5Hr0DmIXo0Xw/vlzwLnfAiqD2jDXIglkBsc5czJmo4/IBdGXOP65TRnsgJEqvbU3aQhuawX5++x9A==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [darwin] + + sass-embedded-darwin-x64@1.86.3: + resolution: {integrity: sha512-dfKhfrGPRNLWLC82vy/vQGmNKmAiKWpdFuWiePRtg/E95pqw+sCu6080Y6oQLfFu37Iq3MpnXiSpDuSo7UnPWA==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [darwin] + + sass-embedded-linux-arm64@1.86.3: + resolution: {integrity: sha512-tYq5rywR53Qtc+0KI6pPipOvW7a47ETY69VxfqI9BR2RKw2hBbaz0bIw6OaOgEBv2/XNwcWb7a4sr7TqgkqKAA==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + + sass-embedded-linux-arm@1.86.3: + resolution: {integrity: sha512-+fVCIH+OR0SMHn2NEhb/VfbpHuUxcPtqMS34OCV3Ka99LYZUJZqth4M3lT/ppGl52mwIVLNYzR4iLe6mdZ6mYA==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + + sass-embedded-linux-ia32@1.86.3: + resolution: {integrity: sha512-CmQ5OkqnaeLdaF+bMqlYGooBuenqm3LvEN9H8BLhjkpWiFW8hnYMetiqMcJjhrXLvDw601KGqA5sr/Rsg5s45g==} + engines: {node: '>=14.0.0'} + cpu: [ia32] + os: [linux] + + sass-embedded-linux-musl-arm64@1.86.3: + resolution: {integrity: sha512-4zOr2C/eW89rxb4ozTfn7lBzyyM5ZigA1ZSRTcAR26Qbg/t2UksLdGnVX9/yxga0d6aOi0IvO/7iM2DPPRRotg==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [linux] + + sass-embedded-linux-musl-arm@1.86.3: + resolution: {integrity: sha512-SEm65SQknI4pl+mH5Xf231hOkHJyrlgh5nj4qDbiBG6gFeutaNkNIeRgKEg3cflXchCr8iV/q/SyPgjhhzQb7w==} + engines: {node: '>=14.0.0'} + cpu: [arm] + os: [linux] + + sass-embedded-linux-musl-ia32@1.86.3: + resolution: {integrity: sha512-84Tcld32LB1loiqUvczWyVBQRCChm0wNLlkT59qF29nxh8njFIVf9yaPgXcSyyjpPoD9Tu0wnq3dvVzoMCh9AQ==} + engines: {node: '>=14.0.0'} + cpu: [ia32] + os: [linux] + + sass-embedded-linux-musl-riscv64@1.86.3: + resolution: {integrity: sha512-IxEqoiD7vdNpiOwccybbV93NljBy64wSTkUOknGy21SyV43C8uqESOwTwW9ywa3KufImKm8L3uQAW/B0KhJMWg==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [linux] + + sass-embedded-linux-musl-x64@1.86.3: + resolution: {integrity: sha512-ePeTPXUxPK6JgHcUfnrkIyDtyt+zlAvF22mVZv6y1g/PZFm1lSfX+Za7TYHg9KaYqaaXDiw6zICX4i44HhR8rA==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + + sass-embedded-linux-riscv64@1.86.3: + resolution: {integrity: sha512-NuXQ72dwfNLe35E+RaXJ4Noq4EkFwM65eWwCwxEWyJO9qxOx1EXiCAJii6x8kkOh5daWuMU0VAI1B9RsJaqqQQ==} + engines: {node: '>=14.0.0'} + cpu: [riscv64] + os: [linux] + + sass-embedded-linux-x64@1.86.3: + resolution: {integrity: sha512-t8be9zJ5B82+og9bQmIQ83yMGYZMTMrlGA+uGWtYacmwg6w3093dk91Fx0YzNSZBp3Tk60qVYjCZnEIwy60x0g==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [linux] + + sass-embedded-win32-arm64@1.86.3: + resolution: {integrity: sha512-4ghuAzjX4q8Nksm0aifRz8hgXMMxS0SuymrFfkfJlrSx68pIgvAge6AOw0edoZoe0Tf5ZbsWUWamhkNyNxkTvw==} + engines: {node: '>=14.0.0'} + cpu: [arm64] + os: [win32] + + sass-embedded-win32-ia32@1.86.3: + resolution: {integrity: sha512-tCaK4zIRq9mLRPxLzBAdYlfCuS/xLNpmjunYxeWkIwlJo+k53h1udyXH/FInnQ2GgEz0xMXyvH3buuPgzwWYsw==} + engines: {node: '>=14.0.0'} + cpu: [ia32] + os: [win32] + + sass-embedded-win32-x64@1.86.3: + resolution: {integrity: sha512-zS+YNKfTF4SnOfpC77VTb0qNZyTXrxnAezSoRV0xnw6HlY+1WawMSSB6PbWtmbvyfXNgpmJUttoTtsvJjRCucg==} + engines: {node: '>=14.0.0'} + cpu: [x64] + os: [win32] + + sass-embedded@1.86.3: + resolution: {integrity: sha512-3pZSp24ibO1hdopj+W9DuiWsZOb2YY6AFRo/jjutKLBkqJGM1nJjXzhAYfzRV+Xn5BX1eTI4bBTE09P0XNHOZg==} + engines: {node: '>=16.0.0'} + hasBin: true + + search-insights@2.17.3: + resolution: {integrity: sha512-RQPdCYTa8A68uM2jwxoY842xDhvx3E5LFL1LxvxCNMev4o5mLuokczhzjAgGwUZBAmOKZknArSxLKmXtIi2AxQ==} + + shiki@2.5.0: + resolution: {integrity: sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ==} + + source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + + space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + + speakingurl@14.0.1: + resolution: {integrity: sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ==} + engines: {node: '>=0.10.0'} + + stringify-entities@4.0.4: + resolution: {integrity: sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg==} + + superjson@2.2.2: + resolution: {integrity: sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q==} + engines: {node: '>=16'} + + supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + + sync-child-process@1.0.2: + resolution: {integrity: sha512-8lD+t2KrrScJ/7KXCSyfhT3/hRq78rC0wBFqNJXv3mZyn6hW2ypM05JmlSvtqRbeq6jqA94oHbxAr2vYsJ8vDA==} + engines: {node: '>=16.0.0'} + + sync-message-port@1.1.3: + resolution: {integrity: sha512-GTt8rSKje5FilG+wEdfCkOcLL7LWqpMlr2c3LRuKt/YXxcJ52aGSbGBAdI4L3aaqfrBt6y711El53ItyH1NWzg==} + engines: {node: '>=16.0.0'} + + tabbable@6.2.0: + resolution: {integrity: sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew==} + + tailwindcss@4.1.4: + resolution: {integrity: sha512-1ZIUqtPITFbv/DxRmDr5/agPqJwF69d24m9qmM1939TJehgY539CtzeZRjbLt5G6fSy/7YqqYsfvoTEw9xUI2A==} + + tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + + tiny-decode@0.1.3: + resolution: {integrity: sha512-1z+tXaZpPUyREOfjKDQj5lR6HfD6Pa4NF7pb/9ep7sP4+X5WF76bGdJktWCY1Rm+aMR46vJ75VAL/oAptpD1AA==} + + trim-lines@3.0.1: + resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} + + tslib@2.3.0: + resolution: {integrity: sha512-N82ooyxVNm6h1riLCoyS9e3fuJ3AMG2zIZs2Gd1ATcSFjSA23Q0fzjjZeh0jbJvWVDZ0cJT8yaNNaaXHzueNjg==} + + typescript@5.8.3: + resolution: {integrity: sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==} + engines: {node: '>=14.17'} + hasBin: true + + undici-types@6.21.0: + resolution: {integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==} + + unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + + unist-util-position@5.0.0: + resolution: {integrity: sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA==} + + unist-util-stringify-position@4.0.0: + resolution: {integrity: sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ==} + + unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + + unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + + universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + + varint@6.0.0: + resolution: {integrity: sha512-cXEIW6cfr15lFv563k4GuVuW/fiwjknytD37jIOLSdSWuOI6WnO/oKwmP2FQTU2l01LP8/M5TSAJpzUaGe3uWg==} + + vfile-message@4.0.2: + resolution: {integrity: sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw==} + + vfile@6.0.3: + resolution: {integrity: sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q==} + + vite@5.4.18: + resolution: {integrity: sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + + vitepress@1.6.3: + resolution: {integrity: sha512-fCkfdOk8yRZT8GD9BFqusW3+GggWYZ/rYncOfmgcDtP3ualNHCAg+Robxp2/6xfH1WwPHtGpPwv7mbA3qomtBw==} + hasBin: true + peerDependencies: + markdown-it-mathjax3: ^4 + postcss: ^8 + peerDependenciesMeta: + markdown-it-mathjax3: + optional: true + postcss: + optional: true + + vue-demi@0.14.10: + resolution: {integrity: sha512-nMZBOwuzabUO0nLgIcc6rycZEebF6eeUfaiQx9+WSk8e29IbLvPU9feI6tqW4kTo3hvoYAJkMh8n8D0fuISphg==} + engines: {node: '>=12'} + hasBin: true + peerDependencies: + '@vue/composition-api': ^1.0.0-rc.1 + vue: ^3.0.0-0 || ^2.6.0 + peerDependenciesMeta: + '@vue/composition-api': + optional: true + + vue@3.5.13: + resolution: {integrity: sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + + zrender@5.6.1: + resolution: {integrity: sha512-OFXkDJKcrlx5su2XbzJvj/34Q3m6PvyCZkVPHGYpcCJ52ek4U/ymZyfuV1nKE23AyBJ51E/6Yr0mhZ7xGTO4ag==} + + zwitch@2.0.4: + resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} + +snapshots: + + '@algolia/autocomplete-core@1.17.7(@algolia/client-search@5.23.4)(algoliasearch@5.23.4)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-plugin-algolia-insights': 1.17.7(@algolia/client-search@5.23.4)(algoliasearch@5.23.4)(search-insights@2.17.3) + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.23.4)(algoliasearch@5.23.4) + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + - search-insights + + '@algolia/autocomplete-plugin-algolia-insights@1.17.7(@algolia/client-search@5.23.4)(algoliasearch@5.23.4)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.23.4)(algoliasearch@5.23.4) + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + - algoliasearch + + '@algolia/autocomplete-preset-algolia@1.17.7(@algolia/client-search@5.23.4)(algoliasearch@5.23.4)': + dependencies: + '@algolia/autocomplete-shared': 1.17.7(@algolia/client-search@5.23.4)(algoliasearch@5.23.4) + '@algolia/client-search': 5.23.4 + algoliasearch: 5.23.4 + + '@algolia/autocomplete-shared@1.17.7(@algolia/client-search@5.23.4)(algoliasearch@5.23.4)': + dependencies: + '@algolia/client-search': 5.23.4 + algoliasearch: 5.23.4 + + '@algolia/client-abtesting@5.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + '@algolia/requester-browser-xhr': 5.23.4 + '@algolia/requester-fetch': 5.23.4 + '@algolia/requester-node-http': 5.23.4 + + '@algolia/client-analytics@5.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + '@algolia/requester-browser-xhr': 5.23.4 + '@algolia/requester-fetch': 5.23.4 + '@algolia/requester-node-http': 5.23.4 + + '@algolia/client-common@5.23.4': {} + + '@algolia/client-insights@5.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + '@algolia/requester-browser-xhr': 5.23.4 + '@algolia/requester-fetch': 5.23.4 + '@algolia/requester-node-http': 5.23.4 + + '@algolia/client-personalization@5.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + '@algolia/requester-browser-xhr': 5.23.4 + '@algolia/requester-fetch': 5.23.4 + '@algolia/requester-node-http': 5.23.4 + + '@algolia/client-query-suggestions@5.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + '@algolia/requester-browser-xhr': 5.23.4 + '@algolia/requester-fetch': 5.23.4 + '@algolia/requester-node-http': 5.23.4 + + '@algolia/client-search@5.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + '@algolia/requester-browser-xhr': 5.23.4 + '@algolia/requester-fetch': 5.23.4 + '@algolia/requester-node-http': 5.23.4 + + '@algolia/ingestion@1.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + '@algolia/requester-browser-xhr': 5.23.4 + '@algolia/requester-fetch': 5.23.4 + '@algolia/requester-node-http': 5.23.4 + + '@algolia/monitoring@1.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + '@algolia/requester-browser-xhr': 5.23.4 + '@algolia/requester-fetch': 5.23.4 + '@algolia/requester-node-http': 5.23.4 + + '@algolia/recommend@5.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + '@algolia/requester-browser-xhr': 5.23.4 + '@algolia/requester-fetch': 5.23.4 + '@algolia/requester-node-http': 5.23.4 + + '@algolia/requester-browser-xhr@5.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + + '@algolia/requester-fetch@5.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + + '@algolia/requester-node-http@5.23.4': + dependencies: + '@algolia/client-common': 5.23.4 + + '@babel/helper-string-parser@7.25.9': {} + + '@babel/helper-validator-identifier@7.25.9': {} + + '@babel/parser@7.27.0': + dependencies: + '@babel/types': 7.27.0 + + '@babel/types@7.27.0': + dependencies: + '@babel/helper-string-parser': 7.25.9 + '@babel/helper-validator-identifier': 7.25.9 + + '@bufbuild/protobuf@2.2.5': {} + + '@docsearch/css@3.8.2': {} + + '@docsearch/js@3.8.2(@algolia/client-search@5.23.4)(search-insights@2.17.3)': + dependencies: + '@docsearch/react': 3.8.2(@algolia/client-search@5.23.4)(search-insights@2.17.3) + preact: 10.26.5 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - react + - react-dom + - search-insights + + '@docsearch/react@3.8.2(@algolia/client-search@5.23.4)(search-insights@2.17.3)': + dependencies: + '@algolia/autocomplete-core': 1.17.7(@algolia/client-search@5.23.4)(algoliasearch@5.23.4)(search-insights@2.17.3) + '@algolia/autocomplete-preset-algolia': 1.17.7(@algolia/client-search@5.23.4)(algoliasearch@5.23.4) + '@docsearch/css': 3.8.2 + algoliasearch: 5.23.4 + optionalDependencies: + search-insights: 2.17.3 + transitivePeerDependencies: + - '@algolia/client-search' + + '@esbuild/aix-ppc64@0.21.5': + optional: true + + '@esbuild/android-arm64@0.21.5': + optional: true + + '@esbuild/android-arm@0.21.5': + optional: true + + '@esbuild/android-x64@0.21.5': + optional: true + + '@esbuild/darwin-arm64@0.21.5': + optional: true + + '@esbuild/darwin-x64@0.21.5': + optional: true + + '@esbuild/freebsd-arm64@0.21.5': + optional: true + + '@esbuild/freebsd-x64@0.21.5': + optional: true + + '@esbuild/linux-arm64@0.21.5': + optional: true + + '@esbuild/linux-arm@0.21.5': + optional: true + + '@esbuild/linux-ia32@0.21.5': + optional: true + + '@esbuild/linux-loong64@0.21.5': + optional: true + + '@esbuild/linux-mips64el@0.21.5': + optional: true + + '@esbuild/linux-ppc64@0.21.5': + optional: true + + '@esbuild/linux-riscv64@0.21.5': + optional: true + + '@esbuild/linux-s390x@0.21.5': + optional: true + + '@esbuild/linux-x64@0.21.5': + optional: true + + '@esbuild/netbsd-x64@0.21.5': + optional: true + + '@esbuild/openbsd-x64@0.21.5': + optional: true + + '@esbuild/sunos-x64@0.21.5': + optional: true + + '@esbuild/win32-arm64@0.21.5': + optional: true + + '@esbuild/win32-ia32@0.21.5': + optional: true + + '@esbuild/win32-x64@0.21.5': + optional: true + + '@heroicons/vue@2.2.0(vue@3.5.13(typescript@5.8.3))': + dependencies: + vue: 3.5.13(typescript@5.8.3) + + '@iconify-json/simple-icons@1.2.32': + dependencies: + '@iconify/types': 2.0.0 + + '@iconify/types@2.0.0': {} + + '@jridgewell/sourcemap-codec@1.5.0': {} + + '@rollup/rollup-android-arm-eabi@4.40.0': + optional: true + + '@rollup/rollup-android-arm64@4.40.0': + optional: true + + '@rollup/rollup-darwin-arm64@4.40.0': + optional: true + + '@rollup/rollup-darwin-x64@4.40.0': + optional: true + + '@rollup/rollup-freebsd-arm64@4.40.0': + optional: true + + '@rollup/rollup-freebsd-x64@4.40.0': + optional: true + + '@rollup/rollup-linux-arm-gnueabihf@4.40.0': + optional: true + + '@rollup/rollup-linux-arm-musleabihf@4.40.0': + optional: true + + '@rollup/rollup-linux-arm64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-arm64-musl@4.40.0': + optional: true + + '@rollup/rollup-linux-loongarch64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-powerpc64le-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-riscv64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-riscv64-musl@4.40.0': + optional: true + + '@rollup/rollup-linux-s390x-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-x64-gnu@4.40.0': + optional: true + + '@rollup/rollup-linux-x64-musl@4.40.0': + optional: true + + '@rollup/rollup-win32-arm64-msvc@4.40.0': + optional: true + + '@rollup/rollup-win32-ia32-msvc@4.40.0': + optional: true + + '@rollup/rollup-win32-x64-msvc@4.40.0': + optional: true + + '@shikijs/core@2.5.0': + dependencies: + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + hast-util-to-html: 9.0.5 + + '@shikijs/engine-javascript@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + oniguruma-to-es: 3.1.1 + + '@shikijs/engine-oniguruma@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + + '@shikijs/langs@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/themes@2.5.0': + dependencies: + '@shikijs/types': 2.5.0 + + '@shikijs/transformers@2.5.0': + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/types': 2.5.0 + + '@shikijs/types@2.5.0': + dependencies: + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + '@shikijs/vscode-textmate@10.0.2': {} + + '@tailwindcss/node@4.1.4': + dependencies: + enhanced-resolve: 5.18.1 + jiti: 2.4.2 + lightningcss: 1.29.2 + tailwindcss: 4.1.4 + + '@tailwindcss/oxide-android-arm64@4.1.4': + optional: true + + '@tailwindcss/oxide-darwin-arm64@4.1.4': + optional: true + + '@tailwindcss/oxide-darwin-x64@4.1.4': + optional: true + + '@tailwindcss/oxide-freebsd-x64@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-arm-gnueabihf@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-gnu@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-arm64-musl@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-x64-gnu@4.1.4': + optional: true + + '@tailwindcss/oxide-linux-x64-musl@4.1.4': + optional: true + + '@tailwindcss/oxide-wasm32-wasi@4.1.4': + optional: true + + '@tailwindcss/oxide-win32-arm64-msvc@4.1.4': + optional: true + + '@tailwindcss/oxide-win32-x64-msvc@4.1.4': + optional: true + + '@tailwindcss/oxide@4.1.4': + optionalDependencies: + '@tailwindcss/oxide-android-arm64': 4.1.4 + '@tailwindcss/oxide-darwin-arm64': 4.1.4 + '@tailwindcss/oxide-darwin-x64': 4.1.4 + '@tailwindcss/oxide-freebsd-x64': 4.1.4 + '@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.4 + '@tailwindcss/oxide-linux-arm64-gnu': 4.1.4 + '@tailwindcss/oxide-linux-arm64-musl': 4.1.4 + '@tailwindcss/oxide-linux-x64-gnu': 4.1.4 + '@tailwindcss/oxide-linux-x64-musl': 4.1.4 + '@tailwindcss/oxide-wasm32-wasi': 4.1.4 + '@tailwindcss/oxide-win32-arm64-msvc': 4.1.4 + '@tailwindcss/oxide-win32-x64-msvc': 4.1.4 + + '@tailwindcss/vite@4.1.4(vite@5.4.18(@types/node@22.14.1)(lightningcss@1.29.2)(sass-embedded@1.86.3))': + dependencies: + '@tailwindcss/node': 4.1.4 + '@tailwindcss/oxide': 4.1.4 + tailwindcss: 4.1.4 + vite: 5.4.18(@types/node@22.14.1)(lightningcss@1.29.2)(sass-embedded@1.86.3) + + '@types/estree@1.0.7': {} + + '@types/fs-extra@11.0.4': + dependencies: + '@types/jsonfile': 6.1.4 + '@types/node': 22.14.1 + + '@types/hast@3.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/jsonfile@6.1.4': + dependencies: + '@types/node': 22.14.1 + + '@types/linkify-it@5.0.0': {} + + '@types/markdown-it@14.1.2': + dependencies: + '@types/linkify-it': 5.0.0 + '@types/mdurl': 2.0.0 + + '@types/mdast@4.0.4': + dependencies: + '@types/unist': 3.0.3 + + '@types/mdurl@2.0.0': {} + + '@types/node@22.14.1': + dependencies: + undici-types: 6.21.0 + + '@types/unist@3.0.3': {} + + '@types/web-bluetooth@0.0.20': {} + + '@types/web-bluetooth@0.0.21': {} + + '@ungap/structured-clone@1.3.0': {} + + '@vitejs/plugin-vue@5.2.3(vite@5.4.18(@types/node@22.14.1)(lightningcss@1.29.2)(sass-embedded@1.86.3))(vue@3.5.13(typescript@5.8.3))': + dependencies: + vite: 5.4.18(@types/node@22.14.1)(lightningcss@1.29.2)(sass-embedded@1.86.3) + vue: 3.5.13(typescript@5.8.3) + + '@vue/compiler-core@3.5.13': + dependencies: + '@babel/parser': 7.27.0 + '@vue/shared': 3.5.13 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + + '@vue/compiler-dom@3.5.13': + dependencies: + '@vue/compiler-core': 3.5.13 + '@vue/shared': 3.5.13 + + '@vue/compiler-sfc@3.5.13': + dependencies: + '@babel/parser': 7.27.0 + '@vue/compiler-core': 3.5.13 + '@vue/compiler-dom': 3.5.13 + '@vue/compiler-ssr': 3.5.13 + '@vue/shared': 3.5.13 + estree-walker: 2.0.2 + magic-string: 0.30.17 + postcss: 8.5.3 + source-map-js: 1.2.1 + + '@vue/compiler-ssr@3.5.13': + dependencies: + '@vue/compiler-dom': 3.5.13 + '@vue/shared': 3.5.13 + + '@vue/devtools-api@7.7.5': + dependencies: + '@vue/devtools-kit': 7.7.5 + + '@vue/devtools-kit@7.7.5': + dependencies: + '@vue/devtools-shared': 7.7.5 + birpc: 2.3.0 + hookable: 5.5.3 + mitt: 3.0.1 + perfect-debounce: 1.0.0 + speakingurl: 14.0.1 + superjson: 2.2.2 + + '@vue/devtools-shared@7.7.5': + dependencies: + rfdc: 1.4.1 + + '@vue/reactivity@3.5.13': + dependencies: + '@vue/shared': 3.5.13 + + '@vue/repl@4.5.1': {} + + '@vue/runtime-core@3.5.13': + dependencies: + '@vue/reactivity': 3.5.13 + '@vue/shared': 3.5.13 + + '@vue/runtime-dom@3.5.13': + dependencies: + '@vue/reactivity': 3.5.13 + '@vue/runtime-core': 3.5.13 + '@vue/shared': 3.5.13 + csstype: 3.1.3 + + '@vue/server-renderer@3.5.13(vue@3.5.13(typescript@5.8.3))': + dependencies: + '@vue/compiler-ssr': 3.5.13 + '@vue/shared': 3.5.13 + vue: 3.5.13(typescript@5.8.3) + + '@vue/shared@3.5.13': {} + + '@vue/theme@2.3.0(@algolia/client-search@5.23.4)(search-insights@2.17.3)(vitepress@1.6.3(@algolia/client-search@5.23.4)(@types/node@22.14.1)(lightningcss@1.29.2)(postcss@8.5.3)(sass-embedded@1.86.3)(search-insights@2.17.3)(typescript@5.8.3))(vue@3.5.13(typescript@5.8.3))': + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.23.4)(search-insights@2.17.3) + '@vueuse/core': 10.11.1(vue@3.5.13(typescript@5.8.3)) + body-scroll-lock: 4.0.0-beta.0 + normalize.css: 8.0.1 + tiny-decode: 0.1.3 + vitepress: 1.6.3(@algolia/client-search@5.23.4)(@types/node@22.14.1)(lightningcss@1.29.2)(postcss@8.5.3)(sass-embedded@1.86.3)(search-insights@2.17.3)(typescript@5.8.3) + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/react' + - '@vue/composition-api' + - react + - react-dom + - search-insights + - vue + + '@vueuse/core@10.11.1(vue@3.5.13(typescript@5.8.3))': + dependencies: + '@types/web-bluetooth': 0.0.20 + '@vueuse/metadata': 10.11.1 + '@vueuse/shared': 10.11.1(vue@3.5.13(typescript@5.8.3)) + vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/core@12.8.2(typescript@5.8.3)': + dependencies: + '@types/web-bluetooth': 0.0.21 + '@vueuse/metadata': 12.8.2 + '@vueuse/shared': 12.8.2(typescript@5.8.3) + vue: 3.5.13(typescript@5.8.3) + transitivePeerDependencies: + - typescript + + '@vueuse/integrations@12.8.2(focus-trap@7.6.4)(typescript@5.8.3)': + dependencies: + '@vueuse/core': 12.8.2(typescript@5.8.3) + '@vueuse/shared': 12.8.2(typescript@5.8.3) + vue: 3.5.13(typescript@5.8.3) + optionalDependencies: + focus-trap: 7.6.4 + transitivePeerDependencies: + - typescript + + '@vueuse/metadata@10.11.1': {} + + '@vueuse/metadata@12.8.2': {} + + '@vueuse/shared@10.11.1(vue@3.5.13(typescript@5.8.3))': + dependencies: + vue-demi: 0.14.10(vue@3.5.13(typescript@5.8.3)) + transitivePeerDependencies: + - '@vue/composition-api' + - vue + + '@vueuse/shared@12.8.2(typescript@5.8.3)': + dependencies: + vue: 3.5.13(typescript@5.8.3) + transitivePeerDependencies: + - typescript + + algoliasearch@5.23.4: + dependencies: + '@algolia/client-abtesting': 5.23.4 + '@algolia/client-analytics': 5.23.4 + '@algolia/client-common': 5.23.4 + '@algolia/client-insights': 5.23.4 + '@algolia/client-personalization': 5.23.4 + '@algolia/client-query-suggestions': 5.23.4 + '@algolia/client-search': 5.23.4 + '@algolia/ingestion': 1.23.4 + '@algolia/monitoring': 1.23.4 + '@algolia/recommend': 5.23.4 + '@algolia/requester-browser-xhr': 5.23.4 + '@algolia/requester-fetch': 5.23.4 + '@algolia/requester-node-http': 5.23.4 + + birpc@2.3.0: {} + + body-scroll-lock@4.0.0-beta.0: {} + + buffer-builder@0.2.0: {} + + ccount@2.0.1: {} + + character-entities-html4@2.1.0: {} + + character-entities-legacy@3.0.0: {} + + colorjs.io@0.5.2: {} + + comma-separated-tokens@2.0.3: {} + + copy-anything@3.0.5: + dependencies: + is-what: 4.1.16 + + csstype@3.1.3: {} + + dequal@2.0.3: {} + + detect-libc@2.0.3: {} + + devlop@1.1.0: + dependencies: + dequal: 2.0.3 + + dynamics.js@1.1.5: {} + + echarts@5.6.0: + dependencies: + tslib: 2.3.0 + zrender: 5.6.1 + + emoji-regex-xs@1.0.0: {} + + enhanced-resolve@5.18.1: + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + + entities@4.5.0: {} + + esbuild@0.21.5: + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + + estree-walker@2.0.2: {} + + focus-trap@7.6.4: + dependencies: + tabbable: 6.2.0 + + fs-extra@11.3.0: + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + + fsevents@2.3.3: + optional: true + + graceful-fs@4.2.11: {} + + gsap@3.12.7: {} + + has-flag@4.0.0: {} + + hast-util-to-html@9.0.5: + dependencies: + '@types/hast': 3.0.4 + '@types/unist': 3.0.3 + ccount: 2.0.1 + comma-separated-tokens: 2.0.3 + hast-util-whitespace: 3.0.0 + html-void-elements: 3.0.0 + mdast-util-to-hast: 13.2.0 + property-information: 7.0.0 + space-separated-tokens: 2.0.2 + stringify-entities: 4.0.4 + zwitch: 2.0.4 + + hast-util-whitespace@3.0.0: + dependencies: + '@types/hast': 3.0.4 + + hookable@5.5.3: {} + + html-void-elements@3.0.0: {} + + immutable@5.1.1: {} + + is-what@4.1.16: {} + + jiti@2.4.2: {} + + jsonfile@6.1.0: + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + + lightningcss-darwin-arm64@1.29.2: + optional: true + + lightningcss-darwin-x64@1.29.2: + optional: true + + lightningcss-freebsd-x64@1.29.2: + optional: true + + lightningcss-linux-arm-gnueabihf@1.29.2: + optional: true + + lightningcss-linux-arm64-gnu@1.29.2: + optional: true + + lightningcss-linux-arm64-musl@1.29.2: + optional: true + + lightningcss-linux-x64-gnu@1.29.2: + optional: true + + lightningcss-linux-x64-musl@1.29.2: + optional: true + + lightningcss-win32-arm64-msvc@1.29.2: + optional: true + + lightningcss-win32-x64-msvc@1.29.2: + optional: true + + lightningcss@1.29.2: + dependencies: + detect-libc: 2.0.3 + optionalDependencies: + lightningcss-darwin-arm64: 1.29.2 + lightningcss-darwin-x64: 1.29.2 + lightningcss-freebsd-x64: 1.29.2 + lightningcss-linux-arm-gnueabihf: 1.29.2 + lightningcss-linux-arm64-gnu: 1.29.2 + lightningcss-linux-arm64-musl: 1.29.2 + lightningcss-linux-x64-gnu: 1.29.2 + lightningcss-linux-x64-musl: 1.29.2 + lightningcss-win32-arm64-msvc: 1.29.2 + lightningcss-win32-x64-msvc: 1.29.2 + + magic-string@0.30.17: + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + + mark.js@8.11.1: {} + + mdast-util-to-hast@13.2.0: + dependencies: + '@types/hast': 3.0.4 + '@types/mdast': 4.0.4 + '@ungap/structured-clone': 1.3.0 + devlop: 1.1.0 + micromark-util-sanitize-uri: 2.0.1 + trim-lines: 3.0.1 + unist-util-position: 5.0.0 + unist-util-visit: 5.0.0 + vfile: 6.0.3 + + micromark-util-character@2.1.1: + dependencies: + micromark-util-symbol: 2.0.1 + micromark-util-types: 2.0.2 + + micromark-util-encode@2.0.1: {} + + micromark-util-sanitize-uri@2.0.1: + dependencies: + micromark-util-character: 2.1.1 + micromark-util-encode: 2.0.1 + micromark-util-symbol: 2.0.1 + + micromark-util-symbol@2.0.1: {} + + micromark-util-types@2.0.2: {} + + minisearch@7.1.2: {} + + mitt@3.0.1: {} + + nanoid@3.3.11: {} + + normalize.css@8.0.1: {} + + oniguruma-to-es@3.1.1: + dependencies: + emoji-regex-xs: 1.0.0 + regex: 6.0.1 + regex-recursion: 6.0.2 + + perfect-debounce@1.0.0: {} + + picocolors@1.1.1: {} + + postcss@8.5.3: + dependencies: + nanoid: 3.3.11 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + preact@10.26.5: {} + + property-information@7.0.0: {} + + regex-recursion@6.0.2: + dependencies: + regex-utilities: 2.3.0 + + regex-utilities@2.3.0: {} + + regex@6.0.1: + dependencies: + regex-utilities: 2.3.0 + + rfdc@1.4.1: {} + + rollup@4.40.0: + dependencies: + '@types/estree': 1.0.7 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.40.0 + '@rollup/rollup-android-arm64': 4.40.0 + '@rollup/rollup-darwin-arm64': 4.40.0 + '@rollup/rollup-darwin-x64': 4.40.0 + '@rollup/rollup-freebsd-arm64': 4.40.0 + '@rollup/rollup-freebsd-x64': 4.40.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.40.0 + '@rollup/rollup-linux-arm-musleabihf': 4.40.0 + '@rollup/rollup-linux-arm64-gnu': 4.40.0 + '@rollup/rollup-linux-arm64-musl': 4.40.0 + '@rollup/rollup-linux-loongarch64-gnu': 4.40.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.40.0 + '@rollup/rollup-linux-riscv64-gnu': 4.40.0 + '@rollup/rollup-linux-riscv64-musl': 4.40.0 + '@rollup/rollup-linux-s390x-gnu': 4.40.0 + '@rollup/rollup-linux-x64-gnu': 4.40.0 + '@rollup/rollup-linux-x64-musl': 4.40.0 + '@rollup/rollup-win32-arm64-msvc': 4.40.0 + '@rollup/rollup-win32-ia32-msvc': 4.40.0 + '@rollup/rollup-win32-x64-msvc': 4.40.0 + fsevents: 2.3.3 + + rxjs@7.8.2: + dependencies: + tslib: 2.3.0 + + sass-embedded-android-arm64@1.86.3: + optional: true + + sass-embedded-android-arm@1.86.3: + optional: true + + sass-embedded-android-ia32@1.86.3: + optional: true + + sass-embedded-android-riscv64@1.86.3: + optional: true + + sass-embedded-android-x64@1.86.3: + optional: true + + sass-embedded-darwin-arm64@1.86.3: + optional: true + + sass-embedded-darwin-x64@1.86.3: + optional: true + + sass-embedded-linux-arm64@1.86.3: + optional: true + + sass-embedded-linux-arm@1.86.3: + optional: true + + sass-embedded-linux-ia32@1.86.3: + optional: true + + sass-embedded-linux-musl-arm64@1.86.3: + optional: true + + sass-embedded-linux-musl-arm@1.86.3: + optional: true + + sass-embedded-linux-musl-ia32@1.86.3: + optional: true + + sass-embedded-linux-musl-riscv64@1.86.3: + optional: true + + sass-embedded-linux-musl-x64@1.86.3: + optional: true + + sass-embedded-linux-riscv64@1.86.3: + optional: true + + sass-embedded-linux-x64@1.86.3: + optional: true + + sass-embedded-win32-arm64@1.86.3: + optional: true + + sass-embedded-win32-ia32@1.86.3: + optional: true + + sass-embedded-win32-x64@1.86.3: + optional: true + + sass-embedded@1.86.3: + dependencies: + '@bufbuild/protobuf': 2.2.5 + buffer-builder: 0.2.0 + colorjs.io: 0.5.2 + immutable: 5.1.1 + rxjs: 7.8.2 + supports-color: 8.1.1 + sync-child-process: 1.0.2 + varint: 6.0.0 + optionalDependencies: + sass-embedded-android-arm: 1.86.3 + sass-embedded-android-arm64: 1.86.3 + sass-embedded-android-ia32: 1.86.3 + sass-embedded-android-riscv64: 1.86.3 + sass-embedded-android-x64: 1.86.3 + sass-embedded-darwin-arm64: 1.86.3 + sass-embedded-darwin-x64: 1.86.3 + sass-embedded-linux-arm: 1.86.3 + sass-embedded-linux-arm64: 1.86.3 + sass-embedded-linux-ia32: 1.86.3 + sass-embedded-linux-musl-arm: 1.86.3 + sass-embedded-linux-musl-arm64: 1.86.3 + sass-embedded-linux-musl-ia32: 1.86.3 + sass-embedded-linux-musl-riscv64: 1.86.3 + sass-embedded-linux-musl-x64: 1.86.3 + sass-embedded-linux-riscv64: 1.86.3 + sass-embedded-linux-x64: 1.86.3 + sass-embedded-win32-arm64: 1.86.3 + sass-embedded-win32-ia32: 1.86.3 + sass-embedded-win32-x64: 1.86.3 + + search-insights@2.17.3: {} + + shiki@2.5.0: + dependencies: + '@shikijs/core': 2.5.0 + '@shikijs/engine-javascript': 2.5.0 + '@shikijs/engine-oniguruma': 2.5.0 + '@shikijs/langs': 2.5.0 + '@shikijs/themes': 2.5.0 + '@shikijs/types': 2.5.0 + '@shikijs/vscode-textmate': 10.0.2 + '@types/hast': 3.0.4 + + source-map-js@1.2.1: {} + + space-separated-tokens@2.0.2: {} + + speakingurl@14.0.1: {} + + stringify-entities@4.0.4: + dependencies: + character-entities-html4: 2.1.0 + character-entities-legacy: 3.0.0 + + superjson@2.2.2: + dependencies: + copy-anything: 3.0.5 + + supports-color@8.1.1: + dependencies: + has-flag: 4.0.0 + + sync-child-process@1.0.2: + dependencies: + sync-message-port: 1.1.3 + + sync-message-port@1.1.3: {} + + tabbable@6.2.0: {} + + tailwindcss@4.1.4: {} + + tapable@2.2.1: {} + + tiny-decode@0.1.3: + dependencies: + entities: 4.5.0 + + trim-lines@3.0.1: {} + + tslib@2.3.0: {} + + typescript@5.8.3: {} + + undici-types@6.21.0: {} + + unist-util-is@6.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-position@5.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-stringify-position@4.0.0: + dependencies: + '@types/unist': 3.0.3 + + unist-util-visit-parents@6.0.1: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + + unist-util-visit@5.0.0: + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + + universalify@2.0.1: {} + + varint@6.0.0: {} + + vfile-message@4.0.2: + dependencies: + '@types/unist': 3.0.3 + unist-util-stringify-position: 4.0.0 + + vfile@6.0.3: + dependencies: + '@types/unist': 3.0.3 + vfile-message: 4.0.2 + + vite@5.4.18(@types/node@22.14.1)(lightningcss@1.29.2)(sass-embedded@1.86.3): + dependencies: + esbuild: 0.21.5 + postcss: 8.5.3 + rollup: 4.40.0 + optionalDependencies: + '@types/node': 22.14.1 + fsevents: 2.3.3 + lightningcss: 1.29.2 + sass-embedded: 1.86.3 + + vitepress@1.6.3(@algolia/client-search@5.23.4)(@types/node@22.14.1)(lightningcss@1.29.2)(postcss@8.5.3)(sass-embedded@1.86.3)(search-insights@2.17.3)(typescript@5.8.3): + dependencies: + '@docsearch/css': 3.8.2 + '@docsearch/js': 3.8.2(@algolia/client-search@5.23.4)(search-insights@2.17.3) + '@iconify-json/simple-icons': 1.2.32 + '@shikijs/core': 2.5.0 + '@shikijs/transformers': 2.5.0 + '@shikijs/types': 2.5.0 + '@types/markdown-it': 14.1.2 + '@vitejs/plugin-vue': 5.2.3(vite@5.4.18(@types/node@22.14.1)(lightningcss@1.29.2)(sass-embedded@1.86.3))(vue@3.5.13(typescript@5.8.3)) + '@vue/devtools-api': 7.7.5 + '@vue/shared': 3.5.13 + '@vueuse/core': 12.8.2(typescript@5.8.3) + '@vueuse/integrations': 12.8.2(focus-trap@7.6.4)(typescript@5.8.3) + focus-trap: 7.6.4 + mark.js: 8.11.1 + minisearch: 7.1.2 + shiki: 2.5.0 + vite: 5.4.18(@types/node@22.14.1)(lightningcss@1.29.2)(sass-embedded@1.86.3) + vue: 3.5.13(typescript@5.8.3) + optionalDependencies: + postcss: 8.5.3 + transitivePeerDependencies: + - '@algolia/client-search' + - '@types/node' + - '@types/react' + - async-validator + - axios + - change-case + - drauu + - fuse.js + - idb-keyval + - jwt-decode + - less + - lightningcss + - nprogress + - qrcode + - react + - react-dom + - sass + - sass-embedded + - search-insights + - sortablejs + - stylus + - sugarss + - terser + - typescript + - universal-cookie + + vue-demi@0.14.10(vue@3.5.13(typescript@5.8.3)): + dependencies: + vue: 3.5.13(typescript@5.8.3) + + vue@3.5.13(typescript@5.8.3): + dependencies: + '@vue/compiler-dom': 3.5.13 + '@vue/compiler-sfc': 3.5.13 + '@vue/runtime-dom': 3.5.13 + '@vue/server-renderer': 3.5.13(vue@3.5.13(typescript@5.8.3)) + '@vue/shared': 3.5.13 + optionalDependencies: + typescript: 5.8.3 + + zrender@5.6.1: + dependencies: + tslib: 2.3.0 + + zwitch@2.0.4: {} diff --git a/documents/tsconfig.json b/documents/tsconfig.json new file mode 100644 index 0000000000000000000000000000000000000000..52ad5cf79a2aea71f43e7568001ecaee1de6f2b6 --- /dev/null +++ b/documents/tsconfig.json @@ -0,0 +1,32 @@ +{ + "compilerOptions": { + "target": "es2017", + "module": "esnext", + "lib": ["ESNext", "DOM", "DOM.Iterable"], + "moduleResolution": "node", + "esModuleInterop": true, + "strict": true, + "strictNullChecks": true, + "strictFunctionTypes": true, + "declaration": true, + "declarationDir": "./types", + "resolveJsonModule": true, + "rootDir": "./", + "baseUrl": ".", + "jsx": "preserve", + "skipLibCheck": true, + "skipDefaultLibCheck": true, + "noUnusedLocals": true, + "types": [ + "@types/node" + ] + }, + "include": [ + "**/*", + "**/*.mts" + ], + "exclude": [ + "node_modules", + "**/*.md" + ] +} \ No newline at end of file diff --git a/documents/yarn.lock b/documents/yarn.lock new file mode 100644 index 0000000000000000000000000000000000000000..626a422598cf262b52872ccddde0701cd23a9b64 --- /dev/null +++ b/documents/yarn.lock @@ -0,0 +1,1232 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@algolia/autocomplete-core@1.17.7": + version "1.17.7" + resolved "https://registry.npmmirror.com/@algolia/autocomplete-core/-/autocomplete-core-1.17.7.tgz#2c410baa94a47c5c5f56ed712bb4a00ebe24088b" + integrity sha512-BjiPOW6ks90UKl7TwMv7oNQMnzU+t/wk9mgIDi6b1tXpUek7MW0lbNOUHpvam9pe3lVCf4xPFT+lK7s+e+fs7Q== + dependencies: + "@algolia/autocomplete-plugin-algolia-insights" "1.17.7" + "@algolia/autocomplete-shared" "1.17.7" + +"@algolia/autocomplete-plugin-algolia-insights@1.17.7": + version "1.17.7" + resolved "https://registry.npmmirror.com/@algolia/autocomplete-plugin-algolia-insights/-/autocomplete-plugin-algolia-insights-1.17.7.tgz#7d2b105f84e7dd8f0370aa4c4ab3b704e6760d82" + integrity sha512-Jca5Ude6yUOuyzjnz57og7Et3aXjbwCSDf/8onLHSQgw1qW3ALl9mrMWaXb5FmPVkV3EtkD2F/+NkT6VHyPu9A== + dependencies: + "@algolia/autocomplete-shared" "1.17.7" + +"@algolia/autocomplete-preset-algolia@1.17.7": + version "1.17.7" + resolved "https://registry.npmmirror.com/@algolia/autocomplete-preset-algolia/-/autocomplete-preset-algolia-1.17.7.tgz#c9badc0d73d62db5bf565d839d94ec0034680ae9" + integrity sha512-ggOQ950+nwbWROq2MOCIL71RE0DdQZsceqrg32UqnhDz8FlO9rL8ONHNsI2R1MH0tkgVIDKI/D0sMiUchsFdWA== + dependencies: + "@algolia/autocomplete-shared" "1.17.7" + +"@algolia/autocomplete-shared@1.17.7": + version "1.17.7" + resolved "https://registry.npmmirror.com/@algolia/autocomplete-shared/-/autocomplete-shared-1.17.7.tgz#105e84ad9d1a31d3fb86ba20dc890eefe1a313a0" + integrity sha512-o/1Vurr42U/qskRSuhBH+VKxMvkkUVTLU6WZQr+L5lGZZLYWyhdzWjW0iGXY7EkwRTjBqvN2EsR81yCTGV/kmg== + +"@algolia/client-abtesting@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/client-abtesting/-/client-abtesting-5.23.4.tgz#de89e757ca26e003dc4dbd7e7fac35c3071caaa4" + integrity sha512-WIMT2Kxy+FFWXWQxIU8QgbTioL+SGE24zhpj0kipG4uQbzXwONaWt7ffaYLjfge3gcGSgJVv+1VlahVckafluQ== + dependencies: + "@algolia/client-common" "5.23.4" + "@algolia/requester-browser-xhr" "5.23.4" + "@algolia/requester-fetch" "5.23.4" + "@algolia/requester-node-http" "5.23.4" + +"@algolia/client-analytics@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/client-analytics/-/client-analytics-5.23.4.tgz#4a918a775db1c596773a34414f9d4203a50b4291" + integrity sha512-4B9gChENsQA9kFmFlb+x3YhBz2Gx3vSsm81FHI1yJ3fn2zlxREHmfrjyqYoMunsU7BybT/o5Nb7ccCbm/vfseA== + dependencies: + "@algolia/client-common" "5.23.4" + "@algolia/requester-browser-xhr" "5.23.4" + "@algolia/requester-fetch" "5.23.4" + "@algolia/requester-node-http" "5.23.4" + +"@algolia/client-common@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/client-common/-/client-common-5.23.4.tgz#651506d080fd1feda1175c89ffb83fd7a2af20c2" + integrity sha512-bsj0lwU2ytiWLtl7sPunr+oLe+0YJql9FozJln5BnIiqfKOaseSDdV42060vUy+D4373f2XBI009K/rm2IXYMA== + +"@algolia/client-insights@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/client-insights/-/client-insights-5.23.4.tgz#a901e2dda6a7a8e6d8879b66e5776d22d1e95a04" + integrity sha512-XSCtAYvJ/hnfDHfRVMbBH0dayR+2ofVZy3jf5qyifjguC6rwxDsSdQvXpT0QFVyG+h8UPGtDhMPoUIng4wIcZA== + dependencies: + "@algolia/client-common" "5.23.4" + "@algolia/requester-browser-xhr" "5.23.4" + "@algolia/requester-fetch" "5.23.4" + "@algolia/requester-node-http" "5.23.4" + +"@algolia/client-personalization@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/client-personalization/-/client-personalization-5.23.4.tgz#d236f3ef648976307ca119899ad1459d40db93a6" + integrity sha512-l/0QvqgRFFOf7BnKSJ3myd1WbDr86ftVaa3PQwlsNh7IpIHmvVcT83Bi5zlORozVGMwaKfyPZo6O48PZELsOeA== + dependencies: + "@algolia/client-common" "5.23.4" + "@algolia/requester-browser-xhr" "5.23.4" + "@algolia/requester-fetch" "5.23.4" + "@algolia/requester-node-http" "5.23.4" + +"@algolia/client-query-suggestions@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/client-query-suggestions/-/client-query-suggestions-5.23.4.tgz#79579f525510bcc3aacc289040d9c2536e65f945" + integrity sha512-TB0htrDgVacVGtPDyENoM6VIeYqR+pMsDovW94dfi2JoaRxfqu/tYmLpvgWcOknP6wLbr8bA+G7t/NiGksNAwQ== + dependencies: + "@algolia/client-common" "5.23.4" + "@algolia/requester-browser-xhr" "5.23.4" + "@algolia/requester-fetch" "5.23.4" + "@algolia/requester-node-http" "5.23.4" + +"@algolia/client-search@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/client-search/-/client-search-5.23.4.tgz#7906ab4b704edd1ba2ac39100bf37e0279b4ebdc" + integrity sha512-uBGo6KwUP6z+u6HZWRui8UJClS7fgUIAiYd1prUqCbkzDiCngTOzxaJbEvrdkK0hGCQtnPDiuNhC5MhtVNN4Eg== + dependencies: + "@algolia/client-common" "5.23.4" + "@algolia/requester-browser-xhr" "5.23.4" + "@algolia/requester-fetch" "5.23.4" + "@algolia/requester-node-http" "5.23.4" + +"@algolia/ingestion@1.23.4": + version "1.23.4" + resolved "https://registry.npmmirror.com/@algolia/ingestion/-/ingestion-1.23.4.tgz#f542907b13e7bb97dede32101cb86ce7e8482318" + integrity sha512-Si6rFuGnSeEUPU9QchYvbknvEIyCRK7nkeaPVQdZpABU7m4V/tsiWdHmjVodtx3h20VZivJdHeQO9XbHxBOcCw== + dependencies: + "@algolia/client-common" "5.23.4" + "@algolia/requester-browser-xhr" "5.23.4" + "@algolia/requester-fetch" "5.23.4" + "@algolia/requester-node-http" "5.23.4" + +"@algolia/monitoring@1.23.4": + version "1.23.4" + resolved "https://registry.npmmirror.com/@algolia/monitoring/-/monitoring-1.23.4.tgz#be169ebdb56f3636c1428f4f20fb33c79d09160a" + integrity sha512-EXGoVVTshraqPJgr5cMd1fq7Jm71Ew6MpGCEaxI5PErBpJAmKdtjRIzs6JOGKHRaWLi+jdbJPYc2y8RN4qcx5Q== + dependencies: + "@algolia/client-common" "5.23.4" + "@algolia/requester-browser-xhr" "5.23.4" + "@algolia/requester-fetch" "5.23.4" + "@algolia/requester-node-http" "5.23.4" + +"@algolia/recommend@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/recommend/-/recommend-5.23.4.tgz#218ca0457d68045632953648b622047e0c57a338" + integrity sha512-1t6glwKVCkjvBNlng2itTf8fwaLSqkL4JaMENgR3WTGR8mmW2akocUy/ZYSQcG4TcR7qu4zW2UMGAwLoWoflgQ== + dependencies: + "@algolia/client-common" "5.23.4" + "@algolia/requester-browser-xhr" "5.23.4" + "@algolia/requester-fetch" "5.23.4" + "@algolia/requester-node-http" "5.23.4" + +"@algolia/requester-browser-xhr@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/requester-browser-xhr/-/requester-browser-xhr-5.23.4.tgz#ee8c88094e904511024e3ba7749b85a85f8d31bd" + integrity sha512-UUuizcgc5+VSY8hqzDFVdJ3Wcto03lpbFRGPgW12pHTlUQHUTADtIpIhkLLOZRCjXmCVhtr97Z+eR6LcRYXa3Q== + dependencies: + "@algolia/client-common" "5.23.4" + +"@algolia/requester-fetch@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/requester-fetch/-/requester-fetch-5.23.4.tgz#138dab9f52771cdb90c64dabb01d1fec3614446b" + integrity sha512-UhDg6elsek6NnV5z4VG1qMwR6vbp+rTMBEnl/v4hUyXQazU+CNdYkl++cpdmLwGI/7nXc28xtZiL90Es3I7viQ== + dependencies: + "@algolia/client-common" "5.23.4" + +"@algolia/requester-node-http@5.23.4": + version "5.23.4" + resolved "https://registry.npmmirror.com/@algolia/requester-node-http/-/requester-node-http-5.23.4.tgz#8cc9439ef2f21f04cbea7ddeef712aa2b3d18f62" + integrity sha512-jXGzGBRUS0oywQwnaCA6mMDJO7LoC3dYSLsyNfIqxDR4SNGLhtg3je0Y31lc24OA4nYyKAYgVLtjfrpcpsWShg== + dependencies: + "@algolia/client-common" "5.23.4" + +"@babel/helper-string-parser@^7.25.9": + version "7.25.9" + resolved "https://registry.npmmirror.com/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz#1aabb72ee72ed35789b4bbcad3ca2862ce614e8c" + integrity sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA== + +"@babel/helper-validator-identifier@^7.25.9": + version "7.25.9" + resolved "https://registry.npmmirror.com/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz#24b64e2c3ec7cd3b3c547729b8d16871f22cbdc7" + integrity sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ== + +"@babel/parser@^7.25.3": + version "7.27.0" + resolved "https://registry.npmmirror.com/@babel/parser/-/parser-7.27.0.tgz#3d7d6ee268e41d2600091cbd4e145ffee85a44ec" + integrity sha512-iaepho73/2Pz7w2eMS0Q5f83+0RKI7i4xmiYeBmDzfRVbQtTOG7Ts0S4HzJVsTMGI9keU8rNfuZr8DKfSt7Yyg== + dependencies: + "@babel/types" "^7.27.0" + +"@babel/types@^7.27.0": + version "7.27.0" + resolved "https://registry.npmmirror.com/@babel/types/-/types-7.27.0.tgz#ef9acb6b06c3173f6632d993ecb6d4ae470b4559" + integrity sha512-H45s8fVLYjbhFH62dIJ3WtmJ6RSPt/3DRO0ZcT2SUiYiQyz3BLVb9ADEnLl91m74aQPS3AzzeajZHYOalWe3bg== + dependencies: + "@babel/helper-string-parser" "^7.25.9" + "@babel/helper-validator-identifier" "^7.25.9" + +"@docsearch/css@3.8.2": + version "3.8.2" + resolved "https://registry.npmmirror.com/@docsearch/css/-/css-3.8.2.tgz#7973ceb6892c30f154ba254cd05c562257a44977" + integrity sha512-y05ayQFyUmCXze79+56v/4HpycYF3uFqB78pLPrSV5ZKAlDuIAAJNhaRi8tTdRNXh05yxX/TyNnzD6LwSM89vQ== + +"@docsearch/js@3.8.2": + version "3.8.2" + resolved "https://registry.npmmirror.com/@docsearch/js/-/js-3.8.2.tgz#bdcfc9837700eb38453b88e211ab5cc5a3813cc6" + integrity sha512-Q5wY66qHn0SwA7Taa0aDbHiJvaFJLOJyHmooQ7y8hlwwQLQ/5WwCcoX0g7ii04Qi2DJlHsd0XXzJ8Ypw9+9YmQ== + dependencies: + "@docsearch/react" "3.8.2" + preact "^10.0.0" + +"@docsearch/react@3.8.2": + version "3.8.2" + resolved "https://registry.npmmirror.com/@docsearch/react/-/react-3.8.2.tgz#7b11d39b61c976c0aa9fbde66e6b73b30f3acd42" + integrity sha512-xCRrJQlTt8N9GU0DG4ptwHRkfnSnD/YpdeaXe02iKfqs97TkZJv60yE+1eq/tjPcVnTW8dP5qLP7itifFVV5eg== + dependencies: + "@algolia/autocomplete-core" "1.17.7" + "@algolia/autocomplete-preset-algolia" "1.17.7" + "@docsearch/css" "3.8.2" + algoliasearch "^5.14.2" + +"@esbuild/aix-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz#c7184a326533fcdf1b8ee0733e21c713b975575f" + integrity sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ== + +"@esbuild/android-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz#09d9b4357780da9ea3a7dfb833a1f1ff439b4052" + integrity sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A== + +"@esbuild/android-arm@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/android-arm/-/android-arm-0.21.5.tgz#9b04384fb771926dfa6d7ad04324ecb2ab9b2e28" + integrity sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg== + +"@esbuild/android-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/android-x64/-/android-x64-0.21.5.tgz#29918ec2db754cedcb6c1b04de8cd6547af6461e" + integrity sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA== + +"@esbuild/darwin-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz#e495b539660e51690f3928af50a76fb0a6ccff2a" + integrity sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ== + +"@esbuild/darwin-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz#c13838fa57372839abdddc91d71542ceea2e1e22" + integrity sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw== + +"@esbuild/freebsd-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz#646b989aa20bf89fd071dd5dbfad69a3542e550e" + integrity sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g== + +"@esbuild/freebsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz#aa615cfc80af954d3458906e38ca22c18cf5c261" + integrity sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ== + +"@esbuild/linux-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz#70ac6fa14f5cb7e1f7f887bcffb680ad09922b5b" + integrity sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q== + +"@esbuild/linux-arm@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz#fc6fd11a8aca56c1f6f3894f2bea0479f8f626b9" + integrity sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA== + +"@esbuild/linux-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz#3271f53b3f93e3d093d518d1649d6d68d346ede2" + integrity sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg== + +"@esbuild/linux-loong64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz#ed62e04238c57026aea831c5a130b73c0f9f26df" + integrity sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg== + +"@esbuild/linux-mips64el@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz#e79b8eb48bf3b106fadec1ac8240fb97b4e64cbe" + integrity sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg== + +"@esbuild/linux-ppc64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz#5f2203860a143b9919d383ef7573521fb154c3e4" + integrity sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w== + +"@esbuild/linux-riscv64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz#07bcafd99322d5af62f618cb9e6a9b7f4bb825dc" + integrity sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA== + +"@esbuild/linux-s390x@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz#b7ccf686751d6a3e44b8627ababc8be3ef62d8de" + integrity sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A== + +"@esbuild/linux-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz#6d8f0c768e070e64309af8004bb94e68ab2bb3b0" + integrity sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ== + +"@esbuild/netbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz#bbe430f60d378ecb88decb219c602667387a6047" + integrity sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg== + +"@esbuild/openbsd-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz#99d1cf2937279560d2104821f5ccce220cb2af70" + integrity sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow== + +"@esbuild/sunos-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz#08741512c10d529566baba837b4fe052c8f3487b" + integrity sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg== + +"@esbuild/win32-arm64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz#675b7385398411240735016144ab2e99a60fc75d" + integrity sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A== + +"@esbuild/win32-ia32@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz#1bfc3ce98aa6ca9a0969e4d2af72144c59c1193b" + integrity sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA== + +"@esbuild/win32-x64@0.21.5": + version "0.21.5" + resolved "https://registry.npmmirror.com/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz#acad351d582d157bb145535db2a6ff53dd514b5c" + integrity sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw== + +"@iconify-json/simple-icons@^1.2.21": + version "1.2.32" + resolved "https://registry.npmmirror.com/@iconify-json/simple-icons/-/simple-icons-1.2.32.tgz#c6efa3c36c87ca35c79898383e69e046b2377df5" + integrity sha512-gxgLq0raip7SJaeJ0302vwhsqupQttS21B93Ci1kA/++B+hIgGw71HzTOWQoUhwjlrdWcoVUxSvpPJoMs7oURg== + dependencies: + "@iconify/types" "*" + +"@iconify/types@*": + version "2.0.0" + resolved "https://registry.npmmirror.com/@iconify/types/-/types-2.0.0.tgz#ab0e9ea681d6c8a1214f30cd741fe3a20cc57f57" + integrity sha512-+wluvCrRhXrhyOmRDJ3q8mux9JkKy5SJ/v8ol2tu4FVjyYvtEzkc/3pK15ET6RKg4b4w4BmTk1+gsCUhf21Ykg== + +"@jridgewell/sourcemap-codec@^1.5.0": + version "1.5.0" + resolved "https://registry.npmmirror.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.0.tgz#3188bcb273a414b0d215fd22a58540b989b9409a" + integrity sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ== + +"@rollup/rollup-android-arm-eabi@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.40.0.tgz#d964ee8ce4d18acf9358f96adc408689b6e27fe3" + integrity sha512-+Fbls/diZ0RDerhE8kyC6hjADCXA1K4yVNlH0EYfd2XjyH0UGgzaQ8MlT0pCXAThfxv3QUAczHaL+qSv1E4/Cg== + +"@rollup/rollup-android-arm64@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.40.0.tgz#9b5e130ecc32a5fc1e96c09ff371743ee71a62d3" + integrity sha512-PPA6aEEsTPRz+/4xxAmaoWDqh67N7wFbgFUJGMnanCFs0TV99M0M8QhhaSCks+n6EbQoFvLQgYOGXxlMGQe/6w== + +"@rollup/rollup-darwin-arm64@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.40.0.tgz#ef439182c739b20b3c4398cfc03e3c1249ac8903" + integrity sha512-GwYOcOakYHdfnjjKwqpTGgn5a6cUX7+Ra2HeNj/GdXvO2VJOOXCiYYlRFU4CubFM67EhbmzLOmACKEfvp3J1kQ== + +"@rollup/rollup-darwin-x64@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.40.0.tgz#d7380c1531ab0420ca3be16f17018ef72dd3d504" + integrity sha512-CoLEGJ+2eheqD9KBSxmma6ld01czS52Iw0e2qMZNpPDlf7Z9mj8xmMemxEucinev4LgHalDPczMyxzbq+Q+EtA== + +"@rollup/rollup-freebsd-arm64@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.40.0.tgz#cbcbd7248823c6b430ce543c59906dd3c6df0936" + integrity sha512-r7yGiS4HN/kibvESzmrOB/PxKMhPTlz+FcGvoUIKYoTyGd5toHp48g1uZy1o1xQvybwwpqpe010JrcGG2s5nkg== + +"@rollup/rollup-freebsd-x64@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.40.0.tgz#96bf6ff875bab5219c3472c95fa6eb992586a93b" + integrity sha512-mVDxzlf0oLzV3oZOr0SMJ0lSDd3xC4CmnWJ8Val8isp9jRGl5Dq//LLDSPFrasS7pSm6m5xAcKaw3sHXhBjoRw== + +"@rollup/rollup-linux-arm-gnueabihf@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.40.0.tgz#d80cd62ce6d40f8e611008d8dbf03b5e6bbf009c" + integrity sha512-y/qUMOpJxBMy8xCXD++jeu8t7kzjlOCkoxxajL58G62PJGBZVl/Gwpm7JK9+YvlB701rcQTzjUZ1JgUoPTnoQA== + +"@rollup/rollup-linux-arm-musleabihf@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.40.0.tgz#75440cfc1e8d0f87a239b4c31dfeaf4719b656b7" + integrity sha512-GoCsPibtVdJFPv/BOIvBKO/XmwZLwaNWdyD8TKlXuqp0veo2sHE+A/vpMQ5iSArRUz/uaoj4h5S6Pn0+PdhRjg== + +"@rollup/rollup-linux-arm64-gnu@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.40.0.tgz#ac527485ecbb619247fb08253ec8c551a0712e7c" + integrity sha512-L5ZLphTjjAD9leJzSLI7rr8fNqJMlGDKlazW2tX4IUF9P7R5TMQPElpH82Q7eNIDQnQlAyiNVfRPfP2vM5Avvg== + +"@rollup/rollup-linux-arm64-musl@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.40.0.tgz#74d2b5cb11cf714cd7d1682e7c8b39140e908552" + integrity sha512-ATZvCRGCDtv1Y4gpDIXsS+wfFeFuLwVxyUBSLawjgXK2tRE6fnsQEkE4csQQYWlBlsFztRzCnBvWVfcae/1qxQ== + +"@rollup/rollup-linux-loongarch64-gnu@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-loongarch64-gnu/-/rollup-linux-loongarch64-gnu-4.40.0.tgz#a0a310e51da0b5fea0e944b0abd4be899819aef6" + integrity sha512-wG9e2XtIhd++QugU5MD9i7OnpaVb08ji3P1y/hNbxrQ3sYEelKJOq1UJ5dXczeo6Hj2rfDEL5GdtkMSVLa/AOg== + +"@rollup/rollup-linux-powerpc64le-gnu@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-powerpc64le-gnu/-/rollup-linux-powerpc64le-gnu-4.40.0.tgz#4077e2862b0ac9f61916d6b474d988171bd43b83" + integrity sha512-vgXfWmj0f3jAUvC7TZSU/m/cOE558ILWDzS7jBhiCAFpY2WEBn5jqgbqvmzlMjtp8KlLcBlXVD2mkTSEQE6Ixw== + +"@rollup/rollup-linux-riscv64-gnu@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.40.0.tgz#5812a1a7a2f9581cbe12597307cc7ba3321cf2f3" + integrity sha512-uJkYTugqtPZBS3Z136arevt/FsKTF/J9dEMTX/cwR7lsAW4bShzI2R0pJVw+hcBTWF4dxVckYh72Hk3/hWNKvA== + +"@rollup/rollup-linux-riscv64-musl@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.40.0.tgz#973aaaf4adef4531375c36616de4e01647f90039" + integrity sha512-rKmSj6EXQRnhSkE22+WvrqOqRtk733x3p5sWpZilhmjnkHkpeCgWsFFo0dGnUGeA+OZjRl3+VYq+HyCOEuwcxQ== + +"@rollup/rollup-linux-s390x-gnu@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.40.0.tgz#9bad59e907ba5bfcf3e9dbd0247dfe583112f70b" + integrity sha512-SpnYlAfKPOoVsQqmTFJ0usx0z84bzGOS9anAC0AZ3rdSo3snecihbhFTlJZ8XMwzqAcodjFU4+/SM311dqE5Sw== + +"@rollup/rollup-linux-x64-gnu@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.40.0.tgz#68b045a720bd9b4d905f462b997590c2190a6de0" + integrity sha512-RcDGMtqF9EFN8i2RYN2W+64CdHruJ5rPqrlYw+cgM3uOVPSsnAQps7cpjXe9be/yDp8UC7VLoCoKC8J3Kn2FkQ== + +"@rollup/rollup-linux-x64-musl@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.40.0.tgz#8e703e2c2ad19ba7b2cb3d8c3a4ad11d4ee3a282" + integrity sha512-HZvjpiUmSNx5zFgwtQAV1GaGazT2RWvqeDi0hV+AtC8unqqDSsaFjPxfsO6qPtKRRg25SisACWnJ37Yio8ttaw== + +"@rollup/rollup-win32-arm64-msvc@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.40.0.tgz#c5bee19fa670ff5da5f066be6a58b4568e9c650b" + integrity sha512-UtZQQI5k/b8d7d3i9AZmA/t+Q4tk3hOC0tMOMSq2GlMYOfxbesxG4mJSeDp0EHs30N9bsfwUvs3zF4v/RzOeTQ== + +"@rollup/rollup-win32-ia32-msvc@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.40.0.tgz#846e02c17044bd922f6f483a3b4d36aac6e2b921" + integrity sha512-+m03kvI2f5syIqHXCZLPVYplP8pQch9JHyXKZ3AGMKlg8dCyr2PKHjwRLiW53LTrN/Nc3EqHOKxUxzoSPdKddA== + +"@rollup/rollup-win32-x64-msvc@4.40.0": + version "4.40.0" + resolved "https://registry.npmmirror.com/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.40.0.tgz#fd92d31a2931483c25677b9c6698106490cbbc76" + integrity sha512-lpPE1cLfP5oPzVjKMx10pgBmKELQnFJXHgvtHCtuJWOv8MxqdEIMNtgHgBFf7Ea2/7EuVwa9fodWUfXAlXZLZQ== + +"@shikijs/core@2.5.0", "@shikijs/core@^2.1.0": + version "2.5.0" + resolved "https://registry.npmmirror.com/@shikijs/core/-/core-2.5.0.tgz#e14d33961dfa3141393d4a76fc8923d0d1c4b62f" + integrity sha512-uu/8RExTKtavlpH7XqnVYBrfBkUc20ngXiX9NSrBhOVZYv/7XQRKUyhtkeflY5QsxC0GbJThCerruZfsUaSldg== + dependencies: + "@shikijs/engine-javascript" "2.5.0" + "@shikijs/engine-oniguruma" "2.5.0" + "@shikijs/types" "2.5.0" + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.4" + hast-util-to-html "^9.0.4" + +"@shikijs/engine-javascript@2.5.0": + version "2.5.0" + resolved "https://registry.npmmirror.com/@shikijs/engine-javascript/-/engine-javascript-2.5.0.tgz#e045c6ecfbda6c99137547b0a482e0b87f1053fc" + integrity sha512-VjnOpnQf8WuCEZtNUdjjwGUbtAVKuZkVQ/5cHy/tojVVRIRtlWMYVjyWhxOmIq05AlSOv72z7hRNRGVBgQOl0w== + dependencies: + "@shikijs/types" "2.5.0" + "@shikijs/vscode-textmate" "^10.0.2" + oniguruma-to-es "^3.1.0" + +"@shikijs/engine-oniguruma@2.5.0": + version "2.5.0" + resolved "https://registry.npmmirror.com/@shikijs/engine-oniguruma/-/engine-oniguruma-2.5.0.tgz#230de5693cc1da6c9d59c7ad83593c2027274817" + integrity sha512-pGd1wRATzbo/uatrCIILlAdFVKdxImWJGQ5rFiB5VZi2ve5xj3Ax9jny8QvkaV93btQEwR/rSz5ERFpC5mKNIw== + dependencies: + "@shikijs/types" "2.5.0" + "@shikijs/vscode-textmate" "^10.0.2" + +"@shikijs/langs@2.5.0": + version "2.5.0" + resolved "https://registry.npmmirror.com/@shikijs/langs/-/langs-2.5.0.tgz#97ab50c495922cc1ca06e192985b28dc73de5d50" + integrity sha512-Qfrrt5OsNH5R+5tJ/3uYBBZv3SuGmnRPejV9IlIbFH3HTGLDlkqgHymAlzklVmKBjAaVmkPkyikAV/sQ1wSL+w== + dependencies: + "@shikijs/types" "2.5.0" + +"@shikijs/themes@2.5.0": + version "2.5.0" + resolved "https://registry.npmmirror.com/@shikijs/themes/-/themes-2.5.0.tgz#8c6aecf73f5455681c8bec15797cf678162896cb" + integrity sha512-wGrk+R8tJnO0VMzmUExHR+QdSaPUl/NKs+a4cQQRWyoc3YFbUzuLEi/KWK1hj+8BfHRKm2jNhhJck1dfstJpiw== + dependencies: + "@shikijs/types" "2.5.0" + +"@shikijs/transformers@^2.1.0": + version "2.5.0" + resolved "https://registry.npmmirror.com/@shikijs/transformers/-/transformers-2.5.0.tgz#190c84786ff06c417580ab79177338a947168c55" + integrity sha512-SI494W5X60CaUwgi8u4q4m4s3YAFSxln3tzNjOSYqq54wlVgz0/NbbXEb3mdLbqMBztcmS7bVTaEd2w0qMmfeg== + dependencies: + "@shikijs/core" "2.5.0" + "@shikijs/types" "2.5.0" + +"@shikijs/types@2.5.0", "@shikijs/types@^2.1.0": + version "2.5.0" + resolved "https://registry.npmmirror.com/@shikijs/types/-/types-2.5.0.tgz#e949c7384802703a48b9d6425dd41673c164df69" + integrity sha512-ygl5yhxki9ZLNuNpPitBWvcy9fsSKKaRuO4BAlMyagszQidxcpLAr0qiW/q43DtSIDxO6hEbtYLiFZNXO/hdGw== + dependencies: + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.4" + +"@shikijs/vscode-textmate@^10.0.2": + version "10.0.2" + resolved "https://registry.npmmirror.com/@shikijs/vscode-textmate/-/vscode-textmate-10.0.2.tgz#a90ab31d0cc1dfb54c66a69e515bf624fa7b2224" + integrity sha512-83yeghZ2xxin3Nj8z1NMd/NCuca+gsYXswywDy5bHvwlWL8tpTQmzGeUuHd9FC3E/SBEMvzJRwWEOz5gGes9Qg== + +"@types/estree@1.0.7": + version "1.0.7" + resolved "https://registry.npmmirror.com/@types/estree/-/estree-1.0.7.tgz#4158d3105276773d5b7695cd4834b1722e4f37a8" + integrity sha512-w28IoSUCJpidD/TGviZwwMJckNESJZXFu7NBZ5YJ4mEUnNraUn9Pm8HSZm/jDF1pDWYKspWE7oVphigUPRakIQ== + +"@types/fs-extra@^11.0.4": + version "11.0.4" + resolved "https://registry.npmmirror.com/@types/fs-extra/-/fs-extra-11.0.4.tgz#e16a863bb8843fba8c5004362b5a73e17becca45" + integrity sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ== + dependencies: + "@types/jsonfile" "*" + "@types/node" "*" + +"@types/hast@^3.0.0", "@types/hast@^3.0.4": + version "3.0.4" + resolved "https://registry.npmmirror.com/@types/hast/-/hast-3.0.4.tgz#1d6b39993b82cea6ad783945b0508c25903e15aa" + integrity sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ== + dependencies: + "@types/unist" "*" + +"@types/jsonfile@*": + version "6.1.4" + resolved "https://registry.npmmirror.com/@types/jsonfile/-/jsonfile-6.1.4.tgz#614afec1a1164e7d670b4a7ad64df3e7beb7b702" + integrity sha512-D5qGUYwjvnNNextdU59/+fI+spnwtTFmyQP0h+PfIOSkNfpU6AOICUOkm4i0OnSk+NyjdPJrxCDro0sJsWlRpQ== + dependencies: + "@types/node" "*" + +"@types/linkify-it@^5": + version "5.0.0" + resolved "https://registry.npmmirror.com/@types/linkify-it/-/linkify-it-5.0.0.tgz#21413001973106cda1c3a9b91eedd4ccd5469d76" + integrity sha512-sVDA58zAw4eWAffKOaQH5/5j3XeayukzDk+ewSsnv3p4yJEZHCCzMDiZM8e0OUrRvmpGZ85jf4yDHkHsgBNr9Q== + +"@types/markdown-it@^14.1.2": + version "14.1.2" + resolved "https://registry.npmmirror.com/@types/markdown-it/-/markdown-it-14.1.2.tgz#57f2532a0800067d9b934f3521429a2e8bfb4c61" + integrity sha512-promo4eFwuiW+TfGxhi+0x3czqTYJkG8qB17ZUJiVF10Xm7NLVRSLUsfRTU/6h1e24VvRnXCx+hG7li58lkzog== + dependencies: + "@types/linkify-it" "^5" + "@types/mdurl" "^2" + +"@types/mdast@^4.0.0": + version "4.0.4" + resolved "https://registry.npmmirror.com/@types/mdast/-/mdast-4.0.4.tgz#7ccf72edd2f1aa7dd3437e180c64373585804dd6" + integrity sha512-kGaNbPh1k7AFzgpud/gMdvIm5xuECykRR+JnWKQno9TAXVa6WIVCGTPvYGekIDL4uwCZQSYbUxNBSb1aUo79oA== + dependencies: + "@types/unist" "*" + +"@types/mdurl@^2": + version "2.0.0" + resolved "https://registry.npmmirror.com/@types/mdurl/-/mdurl-2.0.0.tgz#d43878b5b20222682163ae6f897b20447233bdfd" + integrity sha512-RGdgjQUZba5p6QEFAVx2OGb8rQDL/cPRG7GiedRzMcJ1tYnUANBncjbSB1NRGwbvjcPeikRABz2nshyPk1bhWg== + +"@types/node@*": + version "22.14.1" + resolved "https://registry.npmmirror.com/@types/node/-/node-22.14.1.tgz#53b54585cec81c21eee3697521e31312d6ca1e6f" + integrity sha512-u0HuPQwe/dHrItgHHpmw3N2fYCR6x4ivMNbPHRkBVP4CvN+kiRrKHWk3i8tXiO/joPwXLMYvF9TTF0eqgHIuOw== + dependencies: + undici-types "~6.21.0" + +"@types/unist@*", "@types/unist@^3.0.0": + version "3.0.3" + resolved "https://registry.npmmirror.com/@types/unist/-/unist-3.0.3.tgz#acaab0f919ce69cce629c2d4ed2eb4adc1b6c20c" + integrity sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q== + +"@types/web-bluetooth@^0.0.21": + version "0.0.21" + resolved "https://registry.npmmirror.com/@types/web-bluetooth/-/web-bluetooth-0.0.21.tgz#525433c784aed9b457aaa0ee3d92aeb71f346b63" + integrity sha512-oIQLCGWtcFZy2JW77j9k8nHzAOpqMHLQejDA48XXMWH6tjCQHz5RCFz1bzsmROyL6PUm+LLnUiI4BCn221inxA== + +"@ungap/structured-clone@^1.0.0": + version "1.3.0" + resolved "https://registry.npmmirror.com/@ungap/structured-clone/-/structured-clone-1.3.0.tgz#d06bbb384ebcf6c505fde1c3d0ed4ddffe0aaff8" + integrity sha512-WmoN8qaIAo7WTYWbAZuG8PYEhn5fkz7dZrqTBZ7dtt//lL2Gwms1IcnQ5yHqjDfX8Ft5j4YzDM23f87zBfDe9g== + +"@vitejs/plugin-vue@^5.2.1": + version "5.2.3" + resolved "https://registry.npmmirror.com/@vitejs/plugin-vue/-/plugin-vue-5.2.3.tgz#71a8fc82d4d2e425af304c35bf389506f674d89b" + integrity sha512-IYSLEQj4LgZZuoVpdSUCw3dIynTWQgPlaRP6iAvMle4My0HdYwr5g5wQAfwOeHQBmYwEkqF70nRpSilr6PoUDg== + +"@vue/compiler-core@3.5.13": + version "3.5.13" + resolved "https://registry.npmmirror.com/@vue/compiler-core/-/compiler-core-3.5.13.tgz#b0ae6c4347f60c03e849a05d34e5bf747c9bda05" + integrity sha512-oOdAkwqUfW1WqpwSYJce06wvt6HljgY3fGeM9NcVA1HaYOij3mZG9Rkysn0OHuyUAGMbEbARIpsG+LPVlBJ5/Q== + dependencies: + "@babel/parser" "^7.25.3" + "@vue/shared" "3.5.13" + entities "^4.5.0" + estree-walker "^2.0.2" + source-map-js "^1.2.0" + +"@vue/compiler-dom@3.5.13": + version "3.5.13" + resolved "https://registry.npmmirror.com/@vue/compiler-dom/-/compiler-dom-3.5.13.tgz#bb1b8758dbc542b3658dda973b98a1c9311a8a58" + integrity sha512-ZOJ46sMOKUjO3e94wPdCzQ6P1Lx/vhp2RSvfaab88Ajexs0AHeV0uasYhi99WPaogmBlRHNRuly8xV75cNTMDA== + dependencies: + "@vue/compiler-core" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/compiler-sfc@3.5.13": + version "3.5.13" + resolved "https://registry.npmmirror.com/@vue/compiler-sfc/-/compiler-sfc-3.5.13.tgz#461f8bd343b5c06fac4189c4fef8af32dea82b46" + integrity sha512-6VdaljMpD82w6c2749Zhf5T9u5uLBWKnVue6XWxprDobftnletJ8+oel7sexFfM3qIxNmVE7LSFGTpv6obNyaQ== + dependencies: + "@babel/parser" "^7.25.3" + "@vue/compiler-core" "3.5.13" + "@vue/compiler-dom" "3.5.13" + "@vue/compiler-ssr" "3.5.13" + "@vue/shared" "3.5.13" + estree-walker "^2.0.2" + magic-string "^0.30.11" + postcss "^8.4.48" + source-map-js "^1.2.0" + +"@vue/compiler-ssr@3.5.13": + version "3.5.13" + resolved "https://registry.npmmirror.com/@vue/compiler-ssr/-/compiler-ssr-3.5.13.tgz#e771adcca6d3d000f91a4277c972a996d07f43ba" + integrity sha512-wMH6vrYHxQl/IybKJagqbquvxpWCuVYpoUJfCqFZwa/JY1GdATAQ+TgVtgrwwMZ0D07QhA99rs/EAAWfvG6KpA== + dependencies: + "@vue/compiler-dom" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/devtools-api@^7.7.0": + version "7.7.5" + resolved "https://registry.npmmirror.com/@vue/devtools-api/-/devtools-api-7.7.5.tgz#1e6c3d72c1a77419c1940bc94ee12d2949334aaf" + integrity sha512-HYV3tJGARROq5nlVMJh5KKHk7GU8Au3IrrmNNqr978m0edxgpHgYPDoNUGrvEgIbObz09SQezFR3A1EVmB5WZg== + dependencies: + "@vue/devtools-kit" "^7.7.5" + +"@vue/devtools-kit@^7.7.5": + version "7.7.5" + resolved "https://registry.npmmirror.com/@vue/devtools-kit/-/devtools-kit-7.7.5.tgz#2992fbf793064b302a324d423b35e9a85c0903f5" + integrity sha512-S9VAVJYVAe4RPx2JZb9ZTEi0lqTySz2CBeF0wHT5D3dkTLnT9yMMGegKNl4b2EIELwLSkcI9bl2qp0/jW+upqA== + dependencies: + "@vue/devtools-shared" "^7.7.5" + birpc "^2.3.0" + hookable "^5.5.3" + mitt "^3.0.1" + perfect-debounce "^1.0.0" + speakingurl "^14.0.1" + superjson "^2.2.2" + +"@vue/devtools-shared@^7.7.5": + version "7.7.5" + resolved "https://registry.npmmirror.com/@vue/devtools-shared/-/devtools-shared-7.7.5.tgz#0be847df75d72ff7e6be05a1581abeade7edc31e" + integrity sha512-QBjG72RfpM0DKtpns2RZOxBltO226kOAls9e4Lri6YxS2gWTgL0H+wj1R2K76lxxIeOrqo4+2Ty6RQnzv+WSTQ== + dependencies: + rfdc "^1.4.1" + +"@vue/reactivity@3.5.13": + version "3.5.13" + resolved "https://registry.npmmirror.com/@vue/reactivity/-/reactivity-3.5.13.tgz#b41ff2bb865e093899a22219f5b25f97b6fe155f" + integrity sha512-NaCwtw8o48B9I6L1zl2p41OHo/2Z4wqYGGIK1Khu5T7yxrn+ATOixn/Udn2m+6kZKB/J7cuT9DbWWhRxqixACg== + dependencies: + "@vue/shared" "3.5.13" + +"@vue/runtime-core@3.5.13": + version "3.5.13" + resolved "https://registry.npmmirror.com/@vue/runtime-core/-/runtime-core-3.5.13.tgz#1fafa4bf0b97af0ebdd9dbfe98cd630da363a455" + integrity sha512-Fj4YRQ3Az0WTZw1sFe+QDb0aXCerigEpw418pw1HBUKFtnQHWzwojaukAs2X/c9DQz4MQ4bsXTGlcpGxU/RCIw== + dependencies: + "@vue/reactivity" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/runtime-dom@3.5.13": + version "3.5.13" + resolved "https://registry.npmmirror.com/@vue/runtime-dom/-/runtime-dom-3.5.13.tgz#610fc795de9246300e8ae8865930d534e1246215" + integrity sha512-dLaj94s93NYLqjLiyFzVs9X6dWhTdAlEAciC3Moq7gzAc13VJUdCnjjRurNM6uTLFATRHexHCTu/Xp3eW6yoog== + dependencies: + "@vue/reactivity" "3.5.13" + "@vue/runtime-core" "3.5.13" + "@vue/shared" "3.5.13" + csstype "^3.1.3" + +"@vue/server-renderer@3.5.13": + version "3.5.13" + resolved "https://registry.npmmirror.com/@vue/server-renderer/-/server-renderer-3.5.13.tgz#429ead62ee51de789646c22efe908e489aad46f7" + integrity sha512-wAi4IRJV/2SAW3htkTlB+dHeRmpTiVIK1OGLWV1yeStVSebSQQOwGwIq0D3ZIoBj2C2qpgz5+vX9iEBkTdk5YA== + dependencies: + "@vue/compiler-ssr" "3.5.13" + "@vue/shared" "3.5.13" + +"@vue/shared@3.5.13", "@vue/shared@^3.5.13": + version "3.5.13" + resolved "https://registry.npmmirror.com/@vue/shared/-/shared-3.5.13.tgz#87b309a6379c22b926e696893237826f64339b6f" + integrity sha512-/hnE/qP5ZoGpol0a5mDi45bOd7t3tjYJBjsgCsivow7D48cJeV5l05RD82lPqi7gRiphZM37rnhW1l6ZoCNNnQ== + +"@vueuse/core@12.8.2", "@vueuse/core@^12.4.0": + version "12.8.2" + resolved "https://registry.npmmirror.com/@vueuse/core/-/core-12.8.2.tgz#007c6dd29a7d1f6933e916e7a2f8ef3c3f968eaa" + integrity sha512-HbvCmZdzAu3VGi/pWYm5Ut+Kd9mn1ZHnn4L5G8kOQTPs/IwIAmJoBrmYk2ckLArgMXZj0AW3n5CAejLUO+PhdQ== + dependencies: + "@types/web-bluetooth" "^0.0.21" + "@vueuse/metadata" "12.8.2" + "@vueuse/shared" "12.8.2" + vue "^3.5.13" + +"@vueuse/integrations@^12.4.0": + version "12.8.2" + resolved "https://registry.npmmirror.com/@vueuse/integrations/-/integrations-12.8.2.tgz#d04f33d86fe985c9a27c98addcfde9f30f2db1df" + integrity sha512-fbGYivgK5uBTRt7p5F3zy6VrETlV9RtZjBqd1/HxGdjdckBgBM4ugP8LHpjolqTj14TXTxSK1ZfgPbHYyGuH7g== + dependencies: + "@vueuse/core" "12.8.2" + "@vueuse/shared" "12.8.2" + vue "^3.5.13" + +"@vueuse/metadata@12.8.2": + version "12.8.2" + resolved "https://registry.npmmirror.com/@vueuse/metadata/-/metadata-12.8.2.tgz#6cb3a4e97cdcf528329eebc1bda73cd7f64318d3" + integrity sha512-rAyLGEuoBJ/Il5AmFHiziCPdQzRt88VxR+Y/A/QhJ1EWtWqPBBAxTAFaSkviwEuOEZNtW8pvkPgoCZQ+HxqW1A== + +"@vueuse/shared@12.8.2": + version "12.8.2" + resolved "https://registry.npmmirror.com/@vueuse/shared/-/shared-12.8.2.tgz#b9e4611d0603629c8e151f982459da394e22f930" + integrity sha512-dznP38YzxZoNloI0qpEfpkms8knDtaoQ6Y/sfS0L7Yki4zh40LFHEhur0odJC6xTHG5dxWVPiUWBXn+wCG2s5w== + dependencies: + vue "^3.5.13" + +algoliasearch@^5.14.2: + version "5.23.4" + resolved "https://registry.npmmirror.com/algoliasearch/-/algoliasearch-5.23.4.tgz#2f8c6e6f540b0a73effa69cb05310f7843012e2d" + integrity sha512-QzAKFHl3fm53s44VHrTdEo0TkpL3XVUYQpnZy1r6/EHvMAyIg+O4hwprzlsNmcCHTNyVcF2S13DAUn7XhkC6qg== + dependencies: + "@algolia/client-abtesting" "5.23.4" + "@algolia/client-analytics" "5.23.4" + "@algolia/client-common" "5.23.4" + "@algolia/client-insights" "5.23.4" + "@algolia/client-personalization" "5.23.4" + "@algolia/client-query-suggestions" "5.23.4" + "@algolia/client-search" "5.23.4" + "@algolia/ingestion" "1.23.4" + "@algolia/monitoring" "1.23.4" + "@algolia/recommend" "5.23.4" + "@algolia/requester-browser-xhr" "5.23.4" + "@algolia/requester-fetch" "5.23.4" + "@algolia/requester-node-http" "5.23.4" + +birpc@^2.3.0: + version "2.3.0" + resolved "https://registry.npmmirror.com/birpc/-/birpc-2.3.0.tgz#e5a402dc785ef952a2383ef3cfc075e0842f3e8c" + integrity sha512-ijbtkn/F3Pvzb6jHypHRyve2QApOCZDR25D/VnkY2G/lBNcXCTsnsCxgY4k4PkVB7zfwzYbY3O9Lcqe3xufS5g== + +ccount@^2.0.0: + version "2.0.1" + resolved "https://registry.npmmirror.com/ccount/-/ccount-2.0.1.tgz#17a3bf82302e0870d6da43a01311a8bc02a3ecf5" + integrity sha512-eyrF0jiFpY+3drT6383f1qhkbGsLSifNAjA61IUjZjmLCWjItY6LB9ft9YhoDgwfmclB2zhu51Lc7+95b8NRAg== + +character-entities-html4@^2.0.0: + version "2.1.0" + resolved "https://registry.npmmirror.com/character-entities-html4/-/character-entities-html4-2.1.0.tgz#1f1adb940c971a4b22ba39ddca6b618dc6e56b2b" + integrity sha512-1v7fgQRj6hnSwFpq1Eu0ynr/CDEw0rXo2B61qXrLNdHZmPKgb7fqS1a2JwF0rISo9q77jDI8VMEHoApn8qDoZA== + +character-entities-legacy@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/character-entities-legacy/-/character-entities-legacy-3.0.0.tgz#76bc83a90738901d7bc223a9e93759fdd560125b" + integrity sha512-RpPp0asT/6ufRm//AJVwpViZbGM/MkjQFxJccQRHmISF/22NBtsHqAWmL+/pmkPWoIUJdWyeVleTl1wydHATVQ== + +comma-separated-tokens@^2.0.0: + version "2.0.3" + resolved "https://registry.npmmirror.com/comma-separated-tokens/-/comma-separated-tokens-2.0.3.tgz#4e89c9458acb61bc8fef19f4529973b2392839ee" + integrity sha512-Fu4hJdvzeylCfQPp9SGWidpzrMs7tTrlu6Vb8XGaRGck8QSNZJJp538Wrb60Lax4fPwR64ViY468OIUTbRlGZg== + +copy-anything@^3.0.2: + version "3.0.5" + resolved "https://registry.npmmirror.com/copy-anything/-/copy-anything-3.0.5.tgz#2d92dce8c498f790fa7ad16b01a1ae5a45b020a0" + integrity sha512-yCEafptTtb4bk7GLEQoM8KVJpxAfdBJYaXyzQEgQQQgYrZiDp8SJmGKlYza6CYjEDNstAdNdKA3UuoULlEbS6w== + dependencies: + is-what "^4.1.8" + +csstype@^3.1.3: + version "3.1.3" + resolved "https://registry.npmmirror.com/csstype/-/csstype-3.1.3.tgz#d80ff294d114fb0e6ac500fbf85b60137d7eff81" + integrity sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw== + +dequal@^2.0.0: + version "2.0.3" + resolved "https://registry.npmmirror.com/dequal/-/dequal-2.0.3.tgz#2644214f1997d39ed0ee0ece72335490a7ac67be" + integrity sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA== + +devlop@^1.0.0: + version "1.1.0" + resolved "https://registry.npmmirror.com/devlop/-/devlop-1.1.0.tgz#4db7c2ca4dc6e0e834c30be70c94bbc976dc7018" + integrity sha512-RWmIqhcFf1lRYBvNmr7qTNuyCt/7/ns2jbpp1+PalgE/rDQcBT0fioSMUpJ93irlUhC5hrg4cYqe6U+0ImW0rA== + dependencies: + dequal "^2.0.0" + +emoji-regex-xs@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/emoji-regex-xs/-/emoji-regex-xs-1.0.0.tgz#e8af22e5d9dbd7f7f22d280af3d19d2aab5b0724" + integrity sha512-LRlerrMYoIDrT6jgpeZ2YYl/L8EulRTt5hQcYjy5AInh7HWXKimpqx68aknBFpGL2+/IcogTcaydJEgaTmOpDg== + +entities@^4.5.0: + version "4.5.0" + resolved "https://registry.npmmirror.com/entities/-/entities-4.5.0.tgz#5d268ea5e7113ec74c4d033b79ea5a35a488fb48" + integrity sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw== + +esbuild@^0.21.3: + version "0.21.5" + resolved "https://registry.npmmirror.com/esbuild/-/esbuild-0.21.5.tgz#9ca301b120922959b766360d8ac830da0d02997d" + integrity sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw== + optionalDependencies: + "@esbuild/aix-ppc64" "0.21.5" + "@esbuild/android-arm" "0.21.5" + "@esbuild/android-arm64" "0.21.5" + "@esbuild/android-x64" "0.21.5" + "@esbuild/darwin-arm64" "0.21.5" + "@esbuild/darwin-x64" "0.21.5" + "@esbuild/freebsd-arm64" "0.21.5" + "@esbuild/freebsd-x64" "0.21.5" + "@esbuild/linux-arm" "0.21.5" + "@esbuild/linux-arm64" "0.21.5" + "@esbuild/linux-ia32" "0.21.5" + "@esbuild/linux-loong64" "0.21.5" + "@esbuild/linux-mips64el" "0.21.5" + "@esbuild/linux-ppc64" "0.21.5" + "@esbuild/linux-riscv64" "0.21.5" + "@esbuild/linux-s390x" "0.21.5" + "@esbuild/linux-x64" "0.21.5" + "@esbuild/netbsd-x64" "0.21.5" + "@esbuild/openbsd-x64" "0.21.5" + "@esbuild/sunos-x64" "0.21.5" + "@esbuild/win32-arm64" "0.21.5" + "@esbuild/win32-ia32" "0.21.5" + "@esbuild/win32-x64" "0.21.5" + +estree-walker@^2.0.2: + version "2.0.2" + resolved "https://registry.npmmirror.com/estree-walker/-/estree-walker-2.0.2.tgz#52f010178c2a4c117a7757cfe942adb7d2da4cac" + integrity sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w== + +focus-trap@^7.6.4: + version "7.6.4" + resolved "https://registry.npmmirror.com/focus-trap/-/focus-trap-7.6.4.tgz#455ec5c51fee5ae99604ca15142409ffbbf84db9" + integrity sha512-xx560wGBk7seZ6y933idtjJQc1l+ck+pI3sKvhKozdBV1dRZoKhkW5xoCaFv9tQiX5RH1xfSxjuNu6g+lmN/gw== + dependencies: + tabbable "^6.2.0" + +fs-extra@^11.3.0: + version "11.3.0" + resolved "https://registry.npmmirror.com/fs-extra/-/fs-extra-11.3.0.tgz#0daced136bbaf65a555a326719af931adc7a314d" + integrity sha512-Z4XaCL6dUDHfP/jT25jJKMmtxvuwbkrD1vNSMFlo9lNLY2c5FHYSQgHPRZUjAB26TpDEoW9HCOgplrdbaPV/ew== + dependencies: + graceful-fs "^4.2.0" + jsonfile "^6.0.1" + universalify "^2.0.0" + +fsevents@~2.3.2, fsevents@~2.3.3: + version "2.3.3" + resolved "https://registry.npmmirror.com/fsevents/-/fsevents-2.3.3.tgz#cac6407785d03675a2a5e1a5305c697b347d90d6" + integrity sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw== + +graceful-fs@^4.1.6, graceful-fs@^4.2.0: + version "4.2.11" + resolved "https://registry.npmmirror.com/graceful-fs/-/graceful-fs-4.2.11.tgz#4183e4e8bf08bb6e05bbb2f7d2e0c8f712ca40e3" + integrity sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ== + +hast-util-to-html@^9.0.4: + version "9.0.5" + resolved "https://registry.npmmirror.com/hast-util-to-html/-/hast-util-to-html-9.0.5.tgz#ccc673a55bb8e85775b08ac28380f72d47167005" + integrity sha512-OguPdidb+fbHQSU4Q4ZiLKnzWo8Wwsf5bZfbvu7//a9oTYoqD/fWpe96NuHkoS9h0ccGOTe0C4NGXdtS0iObOw== + dependencies: + "@types/hast" "^3.0.0" + "@types/unist" "^3.0.0" + ccount "^2.0.0" + comma-separated-tokens "^2.0.0" + hast-util-whitespace "^3.0.0" + html-void-elements "^3.0.0" + mdast-util-to-hast "^13.0.0" + property-information "^7.0.0" + space-separated-tokens "^2.0.0" + stringify-entities "^4.0.0" + zwitch "^2.0.4" + +hast-util-whitespace@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/hast-util-whitespace/-/hast-util-whitespace-3.0.0.tgz#7778ed9d3c92dd9e8c5c8f648a49c21fc51cb621" + integrity sha512-88JUN06ipLwsnv+dVn+OIYOvAuvBMy/Qoi6O7mQHxdPXpjy+Cd6xRkWwux7DKO+4sYILtLBRIKgsdpS2gQc7qw== + dependencies: + "@types/hast" "^3.0.0" + +hookable@^5.5.3: + version "5.5.3" + resolved "https://registry.npmmirror.com/hookable/-/hookable-5.5.3.tgz#6cfc358984a1ef991e2518cb9ed4a778bbd3215d" + integrity sha512-Yc+BQe8SvoXH1643Qez1zqLRmbA5rCL+sSmk6TVos0LWVfNIB7PGncdlId77WzLGSIB5KaWgTaNTs2lNVEI6VQ== + +html-void-elements@^3.0.0: + version "3.0.0" + resolved "https://registry.npmmirror.com/html-void-elements/-/html-void-elements-3.0.0.tgz#fc9dbd84af9e747249034d4d62602def6517f1d7" + integrity sha512-bEqo66MRXsUGxWHV5IP0PUiAWwoEjba4VCzg0LjFJBpchPaTfyfCKTG6bc5F8ucKec3q5y6qOdGyYTSBEvhCrg== + +is-what@^4.1.8: + version "4.1.16" + resolved "https://registry.npmmirror.com/is-what/-/is-what-4.1.16.tgz#1ad860a19da8b4895ad5495da3182ce2acdd7a6f" + integrity sha512-ZhMwEosbFJkA0YhFnNDgTM4ZxDRsS6HqTo7qsZM08fehyRYIYa0yHu5R6mgo1n/8MgaPBXiPimPD77baVFYg+A== + +jsonfile@^6.0.1: + version "6.1.0" + resolved "https://registry.npmmirror.com/jsonfile/-/jsonfile-6.1.0.tgz#bc55b2634793c679ec6403094eb13698a6ec0aae" + integrity sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ== + dependencies: + universalify "^2.0.0" + optionalDependencies: + graceful-fs "^4.1.6" + +magic-string@^0.30.11: + version "0.30.17" + resolved "https://registry.npmmirror.com/magic-string/-/magic-string-0.30.17.tgz#450a449673d2460e5bbcfba9a61916a1714c7453" + integrity sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA== + dependencies: + "@jridgewell/sourcemap-codec" "^1.5.0" + +mark.js@8.11.1: + version "8.11.1" + resolved "https://registry.npmmirror.com/mark.js/-/mark.js-8.11.1.tgz#180f1f9ebef8b0e638e4166ad52db879beb2ffc5" + integrity sha512-1I+1qpDt4idfgLQG+BNWmrqku+7/2bi5nLf4YwF8y8zXvmfiTBY3PV3ZibfrjBueCByROpuBjLLFCajqkgYoLQ== + +mdast-util-to-hast@^13.0.0: + version "13.2.0" + resolved "https://registry.npmmirror.com/mdast-util-to-hast/-/mdast-util-to-hast-13.2.0.tgz#5ca58e5b921cc0a3ded1bc02eed79a4fe4fe41f4" + integrity sha512-QGYKEuUsYT9ykKBCMOEDLsU5JRObWQusAolFMeko/tYPufNkRffBAQjIE+99jbA87xv6FgmjLtwjh9wBWajwAA== + dependencies: + "@types/hast" "^3.0.0" + "@types/mdast" "^4.0.0" + "@ungap/structured-clone" "^1.0.0" + devlop "^1.0.0" + micromark-util-sanitize-uri "^2.0.0" + trim-lines "^3.0.0" + unist-util-position "^5.0.0" + unist-util-visit "^5.0.0" + vfile "^6.0.0" + +micromark-util-character@^2.0.0: + version "2.1.1" + resolved "https://registry.npmmirror.com/micromark-util-character/-/micromark-util-character-2.1.1.tgz#2f987831a40d4c510ac261e89852c4e9703ccda6" + integrity sha512-wv8tdUTJ3thSFFFJKtpYKOYiGP2+v96Hvk4Tu8KpCAsTMs6yi+nVmGh1syvSCsaxz45J6Jbw+9DD6g97+NV67Q== + dependencies: + micromark-util-symbol "^2.0.0" + micromark-util-types "^2.0.0" + +micromark-util-encode@^2.0.0: + version "2.0.1" + resolved "https://registry.npmmirror.com/micromark-util-encode/-/micromark-util-encode-2.0.1.tgz#0d51d1c095551cfaac368326963cf55f15f540b8" + integrity sha512-c3cVx2y4KqUnwopcO9b/SCdo2O67LwJJ/UyqGfbigahfegL9myoEFoDYZgkT7f36T0bLrM9hZTAaAyH+PCAXjw== + +micromark-util-sanitize-uri@^2.0.0: + version "2.0.1" + resolved "https://registry.npmmirror.com/micromark-util-sanitize-uri/-/micromark-util-sanitize-uri-2.0.1.tgz#ab89789b818a58752b73d6b55238621b7faa8fd7" + integrity sha512-9N9IomZ/YuGGZZmQec1MbgxtlgougxTodVwDzzEouPKo3qFWvymFHWcnDi2vzV1ff6kas9ucW+o3yzJK9YB1AQ== + dependencies: + micromark-util-character "^2.0.0" + micromark-util-encode "^2.0.0" + micromark-util-symbol "^2.0.0" + +micromark-util-symbol@^2.0.0: + version "2.0.1" + resolved "https://registry.npmmirror.com/micromark-util-symbol/-/micromark-util-symbol-2.0.1.tgz#e5da494e8eb2b071a0d08fb34f6cefec6c0a19b8" + integrity sha512-vs5t8Apaud9N28kgCrRUdEed4UJ+wWNvicHLPxCa9ENlYuAY31M0ETy5y1vA33YoNPDFTghEbnh6efaE8h4x0Q== + +micromark-util-types@^2.0.0: + version "2.0.2" + resolved "https://registry.npmmirror.com/micromark-util-types/-/micromark-util-types-2.0.2.tgz#f00225f5f5a0ebc3254f96c36b6605c4b393908e" + integrity sha512-Yw0ECSpJoViF1qTU4DC6NwtC4aWGt1EkzaQB8KPPyCRR8z9TWeV0HbEFGTO+ZY1wB22zmxnJqhPyTpOVCpeHTA== + +minisearch@^7.1.1: + version "7.1.2" + resolved "https://registry.npmmirror.com/minisearch/-/minisearch-7.1.2.tgz#296ee8d1906cc378f7e57a3a71f07e5205a75df5" + integrity sha512-R1Pd9eF+MD5JYDDSPAp/q1ougKglm14uEkPMvQ/05RGmx6G9wvmLTrTI/Q5iPNJLYqNdsDQ7qTGIcNWR+FrHmA== + +mitt@^3.0.1: + version "3.0.1" + resolved "https://registry.npmmirror.com/mitt/-/mitt-3.0.1.tgz#ea36cf0cc30403601ae074c8f77b7092cdab36d1" + integrity sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw== + +nanoid@^3.3.8: + version "3.3.11" + resolved "https://registry.npmmirror.com/nanoid/-/nanoid-3.3.11.tgz#4f4f112cefbe303202f2199838128936266d185b" + integrity sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w== + +oniguruma-to-es@^3.1.0: + version "3.1.1" + resolved "https://registry.npmmirror.com/oniguruma-to-es/-/oniguruma-to-es-3.1.1.tgz#480e4bac4d3bc9439ac0d2124f0725e7a0d76d17" + integrity sha512-bUH8SDvPkH3ho3dvwJwfonjlQ4R80vjyvrU8YpxuROddv55vAEJrTuCuCVUhhsHbtlD9tGGbaNApGQckXhS8iQ== + dependencies: + emoji-regex-xs "^1.0.0" + regex "^6.0.1" + regex-recursion "^6.0.2" + +perfect-debounce@^1.0.0: + version "1.0.0" + resolved "https://registry.npmmirror.com/perfect-debounce/-/perfect-debounce-1.0.0.tgz#9c2e8bc30b169cc984a58b7d5b28049839591d2a" + integrity sha512-xCy9V055GLEqoFaHoC1SoLIaLmWctgCUaBaWxDZ7/Zx4CTyX7cJQLJOok/orfjZAh9kEYpjJa4d0KcJmCbctZA== + +picocolors@^1.1.1: + version "1.1.1" + resolved "https://registry.npmmirror.com/picocolors/-/picocolors-1.1.1.tgz#3d321af3eab939b083c8f929a1d12cda81c26b6b" + integrity sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA== + +postcss@^8.4.43, postcss@^8.4.48: + version "8.5.3" + resolved "https://registry.npmmirror.com/postcss/-/postcss-8.5.3.tgz#1463b6f1c7fb16fe258736cba29a2de35237eafb" + integrity sha512-dle9A3yYxlBSrt8Fu+IpjGT8SY8hN0mlaA6GY8t0P5PjIOZemULz/E2Bnm/2dcUOena75OTNkHI76uZBNUUq3A== + dependencies: + nanoid "^3.3.8" + picocolors "^1.1.1" + source-map-js "^1.2.1" + +preact@^10.0.0: + version "10.26.5" + resolved "https://registry.npmmirror.com/preact/-/preact-10.26.5.tgz#7e1e998af178f139e4c7cb53f441bf2179f44ad2" + integrity sha512-fmpDkgfGU6JYux9teDWLhj9mKN55tyepwYbxHgQuIxbWQzgFg5vk7Mrrtfx7xRxq798ynkY4DDDxZr235Kk+4w== + +property-information@^7.0.0: + version "7.0.0" + resolved "https://registry.npmmirror.com/property-information/-/property-information-7.0.0.tgz#3508a6d6b0b8eb3ca6eb2c6623b164d2ed2ab112" + integrity sha512-7D/qOz/+Y4X/rzSB6jKxKUsQnphO046ei8qxG59mtM3RG3DHgTK81HrxrmoDVINJb8NKT5ZsRbwHvQ6B68Iyhg== + +regex-recursion@^6.0.2: + version "6.0.2" + resolved "https://registry.npmmirror.com/regex-recursion/-/regex-recursion-6.0.2.tgz#a0b1977a74c87f073377b938dbedfab2ea582b33" + integrity sha512-0YCaSCq2VRIebiaUviZNs0cBz1kg5kVS2UKUfNIx8YVs1cN3AV7NTctO5FOKBA+UT2BPJIWZauYHPqJODG50cg== + dependencies: + regex-utilities "^2.3.0" + +regex-utilities@^2.3.0: + version "2.3.0" + resolved "https://registry.npmmirror.com/regex-utilities/-/regex-utilities-2.3.0.tgz#87163512a15dce2908cf079c8960d5158ff43280" + integrity sha512-8VhliFJAWRaUiVvREIiW2NXXTmHs4vMNnSzuJVhscgmGav3g9VDxLrQndI3dZZVVdp0ZO/5v0xmX516/7M9cng== + +regex@^6.0.1: + version "6.0.1" + resolved "https://registry.npmmirror.com/regex/-/regex-6.0.1.tgz#282fa4435d0c700b09c0eb0982b602e05ab6a34f" + integrity sha512-uorlqlzAKjKQZ5P+kTJr3eeJGSVroLKoHmquUj4zHWuR+hEyNqlXsSKlYYF5F4NI6nl7tWCs0apKJ0lmfsXAPA== + dependencies: + regex-utilities "^2.3.0" + +rfdc@^1.4.1: + version "1.4.1" + resolved "https://registry.npmmirror.com/rfdc/-/rfdc-1.4.1.tgz#778f76c4fb731d93414e8f925fbecf64cce7f6ca" + integrity sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA== + +rollup@^4.20.0: + version "4.40.0" + resolved "https://registry.npmmirror.com/rollup/-/rollup-4.40.0.tgz#13742a615f423ccba457554f006873d5a4de1920" + integrity sha512-Noe455xmA96nnqH5piFtLobsGbCij7Tu+tb3c1vYjNbTkfzGqXqQXG3wJaYXkRZuQ0vEYN4bhwg7QnIrqB5B+w== + dependencies: + "@types/estree" "1.0.7" + optionalDependencies: + "@rollup/rollup-android-arm-eabi" "4.40.0" + "@rollup/rollup-android-arm64" "4.40.0" + "@rollup/rollup-darwin-arm64" "4.40.0" + "@rollup/rollup-darwin-x64" "4.40.0" + "@rollup/rollup-freebsd-arm64" "4.40.0" + "@rollup/rollup-freebsd-x64" "4.40.0" + "@rollup/rollup-linux-arm-gnueabihf" "4.40.0" + "@rollup/rollup-linux-arm-musleabihf" "4.40.0" + "@rollup/rollup-linux-arm64-gnu" "4.40.0" + "@rollup/rollup-linux-arm64-musl" "4.40.0" + "@rollup/rollup-linux-loongarch64-gnu" "4.40.0" + "@rollup/rollup-linux-powerpc64le-gnu" "4.40.0" + "@rollup/rollup-linux-riscv64-gnu" "4.40.0" + "@rollup/rollup-linux-riscv64-musl" "4.40.0" + "@rollup/rollup-linux-s390x-gnu" "4.40.0" + "@rollup/rollup-linux-x64-gnu" "4.40.0" + "@rollup/rollup-linux-x64-musl" "4.40.0" + "@rollup/rollup-win32-arm64-msvc" "4.40.0" + "@rollup/rollup-win32-ia32-msvc" "4.40.0" + "@rollup/rollup-win32-x64-msvc" "4.40.0" + fsevents "~2.3.2" + +shiki@^2.1.0: + version "2.5.0" + resolved "https://registry.npmmirror.com/shiki/-/shiki-2.5.0.tgz#09d01ebf3b0b06580431ce3ddc023320442cf223" + integrity sha512-mI//trrsaiCIPsja5CNfsyNOqgAZUb6VpJA+340toL42UpzQlXpwRV9nch69X6gaUxrr9kaOOa6e3y3uAkGFxQ== + dependencies: + "@shikijs/core" "2.5.0" + "@shikijs/engine-javascript" "2.5.0" + "@shikijs/engine-oniguruma" "2.5.0" + "@shikijs/langs" "2.5.0" + "@shikijs/themes" "2.5.0" + "@shikijs/types" "2.5.0" + "@shikijs/vscode-textmate" "^10.0.2" + "@types/hast" "^3.0.4" + +source-map-js@^1.2.0, source-map-js@^1.2.1: + version "1.2.1" + resolved "https://registry.npmmirror.com/source-map-js/-/source-map-js-1.2.1.tgz#1ce5650fddd87abc099eda37dcff024c2667ae46" + integrity sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA== + +space-separated-tokens@^2.0.0: + version "2.0.2" + resolved "https://registry.npmmirror.com/space-separated-tokens/-/space-separated-tokens-2.0.2.tgz#1ecd9d2350a3844572c3f4a312bceb018348859f" + integrity sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q== + +speakingurl@^14.0.1: + version "14.0.1" + resolved "https://registry.npmmirror.com/speakingurl/-/speakingurl-14.0.1.tgz#f37ec8ddc4ab98e9600c1c9ec324a8c48d772a53" + integrity sha512-1POYv7uv2gXoyGFpBCmpDVSNV74IfsWlDW216UPjbWufNf+bSU6GdbDsxdcxtfwb4xlI3yxzOTKClUosxARYrQ== + +stringify-entities@^4.0.0: + version "4.0.4" + resolved "https://registry.npmmirror.com/stringify-entities/-/stringify-entities-4.0.4.tgz#b3b79ef5f277cc4ac73caeb0236c5ba939b3a4f3" + integrity sha512-IwfBptatlO+QCJUo19AqvrPNqlVMpW9YEL2LIVY+Rpv2qsjCGxaDLNRgeGsQWJhfItebuJhsGSLjaBbNSQ+ieg== + dependencies: + character-entities-html4 "^2.0.0" + character-entities-legacy "^3.0.0" + +superjson@^2.2.2: + version "2.2.2" + resolved "https://registry.npmmirror.com/superjson/-/superjson-2.2.2.tgz#9d52bf0bf6b5751a3c3472f1292e714782ba3173" + integrity sha512-5JRxVqC8I8NuOUjzBbvVJAKNM8qoVuH0O77h4WInc/qC2q5IreqKxYwgkga3PfA22OayK2ikceb/B26dztPl+Q== + dependencies: + copy-anything "^3.0.2" + +tabbable@^6.2.0: + version "6.2.0" + resolved "https://registry.npmmirror.com/tabbable/-/tabbable-6.2.0.tgz#732fb62bc0175cfcec257330be187dcfba1f3b97" + integrity sha512-Cat63mxsVJlzYvN51JmVXIgNoUokrIaT2zLclCXjRd8boZ0004U4KCs/sToJ75C6sdlByWxpYnb5Boif1VSFew== + +trim-lines@^3.0.0: + version "3.0.1" + resolved "https://registry.npmmirror.com/trim-lines/-/trim-lines-3.0.1.tgz#d802e332a07df861c48802c04321017b1bd87338" + integrity sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg== + +typescript@^5.8.3: + version "5.8.3" + resolved "https://registry.npmmirror.com/typescript/-/typescript-5.8.3.tgz#92f8a3e5e3cf497356f4178c34cd65a7f5e8440e" + integrity sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ== + +undici-types@~6.21.0: + version "6.21.0" + resolved "https://registry.npmmirror.com/undici-types/-/undici-types-6.21.0.tgz#691d00af3909be93a7faa13be61b3a5b50ef12cb" + integrity sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ== + +unist-util-is@^6.0.0: + version "6.0.0" + resolved "https://registry.npmmirror.com/unist-util-is/-/unist-util-is-6.0.0.tgz#b775956486aff107a9ded971d996c173374be424" + integrity sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-position@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/unist-util-position/-/unist-util-position-5.0.0.tgz#678f20ab5ca1207a97d7ea8a388373c9cf896be4" + integrity sha512-fucsC7HjXvkB5R3kTCO7kUjRdrS0BJt3M/FPxmHMBOm8JQi2BsHAHFsy27E0EolP8rp0NzXsJ+jNPyDWvOJZPA== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-stringify-position@^4.0.0: + version "4.0.0" + resolved "https://registry.npmmirror.com/unist-util-stringify-position/-/unist-util-stringify-position-4.0.0.tgz#449c6e21a880e0855bf5aabadeb3a740314abac2" + integrity sha512-0ASV06AAoKCDkS2+xw5RXJywruurpbC4JZSm7nr7MOt1ojAzvyyaO+UxZf18j8FCF6kmzCZKcAgN/yu2gm2XgQ== + dependencies: + "@types/unist" "^3.0.0" + +unist-util-visit-parents@^6.0.0: + version "6.0.1" + resolved "https://registry.npmmirror.com/unist-util-visit-parents/-/unist-util-visit-parents-6.0.1.tgz#4d5f85755c3b8f0dc69e21eca5d6d82d22162815" + integrity sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + +unist-util-visit@^5.0.0: + version "5.0.0" + resolved "https://registry.npmmirror.com/unist-util-visit/-/unist-util-visit-5.0.0.tgz#a7de1f31f72ffd3519ea71814cccf5fd6a9217d6" + integrity sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg== + dependencies: + "@types/unist" "^3.0.0" + unist-util-is "^6.0.0" + unist-util-visit-parents "^6.0.0" + +universalify@^2.0.0: + version "2.0.1" + resolved "https://registry.npmmirror.com/universalify/-/universalify-2.0.1.tgz#168efc2180964e6386d061e094df61afe239b18d" + integrity sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw== + +vfile-message@^4.0.0: + version "4.0.2" + resolved "https://registry.npmmirror.com/vfile-message/-/vfile-message-4.0.2.tgz#c883c9f677c72c166362fd635f21fc165a7d1181" + integrity sha512-jRDZ1IMLttGj41KcZvlrYAaI3CfqpLpfpf+Mfig13viT6NKvRzWZ+lXz0Y5D60w6uJIBAOGq9mSHf0gktF0duw== + dependencies: + "@types/unist" "^3.0.0" + unist-util-stringify-position "^4.0.0" + +vfile@^6.0.0: + version "6.0.3" + resolved "https://registry.npmmirror.com/vfile/-/vfile-6.0.3.tgz#3652ab1c496531852bf55a6bac57af981ebc38ab" + integrity sha512-KzIbH/9tXat2u30jf+smMwFCsno4wHVdNmzFyL+T/L3UGqqk6JKfVqOFOZEpZSHADH1k40ab6NUIXZq422ov3Q== + dependencies: + "@types/unist" "^3.0.0" + vfile-message "^4.0.0" + +vite@^5.4.14: + version "5.4.18" + resolved "https://registry.npmmirror.com/vite/-/vite-5.4.18.tgz#b5af357f9d5ebb2e0c085779b7a37a77f09168a4" + integrity sha512-1oDcnEp3lVyHCuQ2YFelM4Alm2o91xNoMncRm1U7S+JdYfYOvbiGZ3/CxGttrOu2M/KcGz7cRC2DoNUA6urmMA== + dependencies: + esbuild "^0.21.3" + postcss "^8.4.43" + rollup "^4.20.0" + optionalDependencies: + fsevents "~2.3.3" + +vitepress@^1.6.3: + version "1.6.3" + resolved "https://registry.npmmirror.com/vitepress/-/vitepress-1.6.3.tgz#4e4662ce2ad55ef64604ecf4f96231a8da2fe9ba" + integrity sha512-fCkfdOk8yRZT8GD9BFqusW3+GggWYZ/rYncOfmgcDtP3ualNHCAg+Robxp2/6xfH1WwPHtGpPwv7mbA3qomtBw== + dependencies: + "@docsearch/css" "3.8.2" + "@docsearch/js" "3.8.2" + "@iconify-json/simple-icons" "^1.2.21" + "@shikijs/core" "^2.1.0" + "@shikijs/transformers" "^2.1.0" + "@shikijs/types" "^2.1.0" + "@types/markdown-it" "^14.1.2" + "@vitejs/plugin-vue" "^5.2.1" + "@vue/devtools-api" "^7.7.0" + "@vue/shared" "^3.5.13" + "@vueuse/core" "^12.4.0" + "@vueuse/integrations" "^12.4.0" + focus-trap "^7.6.4" + mark.js "8.11.1" + minisearch "^7.1.1" + shiki "^2.1.0" + vite "^5.4.14" + vue "^3.5.13" + +vue@^3.5.13: + version "3.5.13" + resolved "https://registry.npmmirror.com/vue/-/vue-3.5.13.tgz#9f760a1a982b09c0c04a867903fc339c9f29ec0a" + integrity sha512-wmeiSMxkZCSc+PM2w2VRsOYAZC8GdipNFRTsLSfodVqI9mbejKeXEGr8SckuLnrQPGe3oJN5c3K0vpoU9q/wCQ== + dependencies: + "@vue/compiler-dom" "3.5.13" + "@vue/compiler-sfc" "3.5.13" + "@vue/runtime-dom" "3.5.13" + "@vue/server-renderer" "3.5.13" + "@vue/shared" "3.5.13" + +zwitch@^2.0.4: + version "2.0.4" + resolved "https://registry.npmmirror.com/zwitch/-/zwitch-2.0.4.tgz#c827d4b0acb76fc3e685a4c6ec2902d51070e9d7" + integrity sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A== diff --git a/hooks/hook-vosk.py b/hooks/hook-vosk.py new file mode 100644 index 0000000000000000000000000000000000000000..b1317efe9bee25c3e9cdc7ea19ac48c344df5834 --- /dev/null +++ b/hooks/hook-vosk.py @@ -0,0 +1,63 @@ +""" +PyInstaller 钩子文件: hook-vosk.py + +解决 vosk 在打包时找不到模型或依赖库的问题 +""" + +import os +from PyInstaller.utils.hooks import ( + collect_dynamic_libs, + copy_metadata, + collect_submodules +) + +# 常量定义 +DEFAULT_MODEL_PATH = "models/vosk-model-small-cn-0.22" + +# 收集 datas 和 binaries +datas = [] +binaries = [] + +# 收集 vosk 的元数据 +datas.extend(copy_metadata('vosk')) + +# 收集 vosk 可能用到的动态库 +binaries.extend(collect_dynamic_libs('vosk')) + +# 获取模型路径 +try: + model_path = os.path.join(os.getcwd(), DEFAULT_MODEL_PATH) + if os.path.exists(model_path) and os.path.isdir(model_path): + print(f"Found Vosk model directory: {model_path}") + + # 收集模型目录下的所有文件 + for root, dirs, files in os.walk(model_path): + rel_dir = os.path.relpath(root, os.getcwd()) + for file in files: + # 跳过临时文件 + if file.startswith('.') or file.endswith('.tmp'): + continue + + src_file = os.path.join(root, file) + # 确保是相对路径 + datas.append((src_file, rel_dir)) + + print(f"Added {len(datas)} model files to package resources") + else: + print(f"Vosk model directory not found: {model_path}") +except Exception as e: + print(f"Error collecting Vosk model files: {e}") + +# 自动收集 vosk 的所有子模块 +hiddenimports = collect_submodules('vosk') + +# 添加其他可能未被自动发现的依赖 +additional_imports = [ + 'cffi', # vosk 依赖的 cffi + 'packaging.version', # vosk 检查版本 + 'numpy', # 音频处理 + 'sounddevice', # 录音功能 +] + +# 合并所有导入 +hiddenimports.extend(additional_imports) \ No newline at end of file diff --git a/libs/libopus/linux/arm64/libopus.so b/libs/libopus/linux/arm64/libopus.so new file mode 100644 index 0000000000000000000000000000000000000000..f90967d13f7813ad2395fb26d37127409bb442d8 --- /dev/null +++ b/libs/libopus/linux/arm64/libopus.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:0f61f954352899a62e2deb5e8c2bcbc0281ffa20153f48ac835c86c1fb2ebc41 +size 493032 diff --git a/libs/libopus/linux/x64/libopus.so b/libs/libopus/linux/x64/libopus.so new file mode 100644 index 0000000000000000000000000000000000000000..dcf7d4f6a42a2d230c801a8e5f07c3734b76d3a7 --- /dev/null +++ b/libs/libopus/linux/x64/libopus.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1665c9aa4c4ba43ccd14e1d89263b39aca7584fd1e212e5beb1fd77ceb3e2100 +size 623008 diff --git a/libs/libopus/mac/arm64/libopus.dylib b/libs/libopus/mac/arm64/libopus.dylib new file mode 100644 index 0000000000000000000000000000000000000000..d53309f9c9429713afbd882d1450ffbee47f8e55 --- /dev/null +++ b/libs/libopus/mac/arm64/libopus.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2a9dc44a29041a43a430d31d3d5cbb89e2d3d09c5f3b00a0b708e330562ee935 +size 472120 diff --git a/libs/libopus/mac/x64/libopus.dylib b/libs/libopus/mac/x64/libopus.dylib new file mode 100644 index 0000000000000000000000000000000000000000..668c6ad9b05a00a1b529b3b921ec364fbcc3c063 --- /dev/null +++ b/libs/libopus/mac/x64/libopus.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:32ac8bfce8f0480085e17b3ff8aac66dc49ccb8204a6baf9465924ddd759c9ef +size 550856 diff --git a/libs/libopus/win/x86_64/opus.dll b/libs/libopus/win/x86_64/opus.dll new file mode 100644 index 0000000000000000000000000000000000000000..eb0e7d4c0b2b82e6149a61c2d235a61ac38f7d53 --- /dev/null +++ b/libs/libopus/win/x86_64/opus.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:90b7e18a832f6c984e80d30095da569370c266a0b95ddef42d16c79c00af027c +size 463360 diff --git a/libs/webrtc_apm/Android.meta b/libs/webrtc_apm/Android.meta new file mode 100644 index 0000000000000000000000000000000000000000..bf153564a24c4396acc0095846ed8a82fc15733b --- /dev/null +++ b/libs/webrtc_apm/Android.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 5bbc57ade5c2ca543861df39f2647fba +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/README.md b/libs/webrtc_apm/README.md new file mode 100644 index 0000000000000000000000000000000000000000..aa46c655ca9fe7bdb9e8785e8d0c392a13c3c0bd --- /dev/null +++ b/libs/webrtc_apm/README.md @@ -0,0 +1 @@ +https://github.com/SylarLi/webrtc-audio-processing \ No newline at end of file diff --git a/libs/webrtc_apm/README.md.meta b/libs/webrtc_apm/README.md.meta new file mode 100644 index 0000000000000000000000000000000000000000..2c888add175d0b4a88bfb878c3e5a0997994807b --- /dev/null +++ b/libs/webrtc_apm/README.md.meta @@ -0,0 +1,7 @@ +fileFormatVersion: 2 +guid: e32dc67c2815bf0408f6a6ae95523fbb +TextScriptImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/WebRTCAPMWrapper.cs b/libs/webrtc_apm/WebRTCAPMWrapper.cs new file mode 100644 index 0000000000000000000000000000000000000000..72301cfb31d0d4236e2bc05e1a680d1d546e6e53 --- /dev/null +++ b/libs/webrtc_apm/WebRTCAPMWrapper.cs @@ -0,0 +1,490 @@ +using System; +using System.Runtime.InteropServices; + +public static class WebRTCAPMWrapper +{ +#if UNITY_IOS + private const string LibraryName = "__Internal"; +#elif UNITY_ANDROID && !UNITY_EDITOR + private const string LibraryName = "libwebrtc_apm"; +#else + private const string LibraryName = "libwebrtc_apm"; +#endif + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr WebRTC_APM_Create(); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern void WebRTC_APM_Destroy(IntPtr handle); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr WebRTC_APM_CreateStreamConfig(int sampleRate, int numChannels); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern IntPtr WebRTC_APM_DestroyStreamConfig(IntPtr streamConfig); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern int WebRTC_APM_ApplyConfig(IntPtr handle, ref Config config); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern int WebRTC_APM_ProcessReverseStream(IntPtr handle, ref short src, IntPtr srcConfig, + IntPtr destConfig, ref short dest); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern int WebRTC_APM_ProcessStream(IntPtr handle, ref short src, IntPtr srcConfig, + IntPtr destConfig, ref short dest); + + [DllImport(LibraryName, CallingConvention = CallingConvention.Cdecl)] + public static extern void WebRTC_APM_SetStreamDelayMs(IntPtr handle, int delayMs); + + [StructLayout(LayoutKind.Sequential)] + public struct Config + { + /// + /// Sets the properties of the audio processing pipeline. + /// + [StructLayout(LayoutKind.Sequential)] + public struct Pipeline + { + /// + /// Ways to downmix a multi-channel track to mono. + /// + public enum DownmixMethod + { + /// Average across channels. + AverageChannels, + + /// Use the first channel. + UseFirstChannel + } + + /// + /// Maximum allowed processing rate used internally. May only be set to + /// 32000 or 48000 and any differing values will be treated as 48000. + /// + public int MaximumInternalProcessingRate; + + /// Allow multi-channel processing of render audio. + [MarshalAs(UnmanagedType.I1)] + public bool MultiChannelRender; + + /// + /// Allow multi-channel processing of capture audio when AEC3 is active + /// or a custom AEC is injected. + /// + [MarshalAs(UnmanagedType.I1)] + public bool MultiChannelCapture; + + /// + /// Indicates how to downmix multi-channel capture audio to mono (when needed). + /// + public DownmixMethod CaptureDownmixMethod; + } + + /// + /// Enabled the pre-amplifier. It amplifies the capture signal + /// before any other processing is done. + /// + [StructLayout(LayoutKind.Sequential)] + public struct PreAmplifier + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + public float FixedGainFactor; + } + + /// + /// Functionality for general level adjustment in the capture pipeline. + /// + [StructLayout(LayoutKind.Sequential)] + public struct CaptureLevelAdjustment + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + + /// The pre_gain_factor scales the signal before any processing is done. + public float PreGainFactor; + + /// The post_gain_factor scales the signal after all processing is done. + public float PostGainFactor; + + [StructLayout(LayoutKind.Sequential)] + public struct AnalogMicGainEmulation + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + + /// + /// Initial analog gain level to use for the emulated analog gain. + /// Must be in the range [0...255]. + /// + public int InitialLevel; + } + + public AnalogMicGainEmulation MicGainEmulation; + } + + [StructLayout(LayoutKind.Sequential)] + public struct HighPassFilter + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + [MarshalAs(UnmanagedType.I1)] + public bool ApplyInFullBand; + } + + [StructLayout(LayoutKind.Sequential)] + public struct EchoCanceller + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + [MarshalAs(UnmanagedType.I1)] + public bool MobileMode; + [MarshalAs(UnmanagedType.I1)] + public bool ExportLinearAecOutput; + + /// + /// Enforce the highpass filter to be on (has no effect for the mobile mode). + /// + [MarshalAs(UnmanagedType.I1)] + public bool EnforceHighPassFiltering; + } + + /// Enables background noise suppression. + [StructLayout(LayoutKind.Sequential)] + public struct NoiseSuppression + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + + public enum Level + { + Low, + Moderate, + High, + VeryHigh + } + + public Level NoiseLevel; + [MarshalAs(UnmanagedType.I1)] + public bool AnalyzeLinearAecOutputWhenAvailable; + } + + /// Enables transient suppression. + [StructLayout(LayoutKind.Sequential)] + public struct TransientSuppression + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + } + + /// + /// Enables automatic gain control (AGC) functionality. + /// The automatic gain control (AGC) component brings the signal to an + /// appropriate range. This is done by applying a digital gain directly and, + /// in the analog mode, prescribing an analog gain to be applied at the audio HAL. + /// + [StructLayout(LayoutKind.Sequential)] + public struct GainController1 + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + + public enum Mode + { + /// + /// Adaptive mode intended for use if an analog volume control is available + /// on the capture device. + /// + AdaptiveAnalog, + + /// + /// Adaptive mode intended for situations in which an analog volume control + /// is unavailable. + /// + AdaptiveDigital, + + /// + /// Fixed mode which enables only the digital compression stage. + /// + FixedDigital + } + + public Mode ControllerMode; + + /// + /// Sets the target peak level (or envelope) of the AGC in dBFs (decibels + /// from digital full-scale). Limited to [0, 31]. + /// + public int TargetLevelDbfs; + + /// + /// Sets the maximum gain the digital compression stage may apply, in dB. + /// Limited to [0, 90]. + /// + public int CompressionGainDb; + + /// + /// When enabled, the compression stage will hard limit the signal to the + /// target level. + /// + [MarshalAs(UnmanagedType.I1)] + public bool EnableLimiter; + + [StructLayout(LayoutKind.Sequential)] + public struct AnalogGainController + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + public int StartupMinVolume; + + /// + /// Lowest analog microphone level that will be applied in response to clipping. + /// + public int ClippedLevelMin; + + /// If true, an adaptive digital gain is applied. + [MarshalAs(UnmanagedType.I1)] + public bool EnableDigitalAdaptive; + + /// + /// Amount the microphone level is lowered with every clipping event. + /// Limited to (0, 255]. + /// + public int ClippedLevelStep; + + /// + /// Proportion of clipped samples required to declare a clipping event. + /// Limited to (0.f, 1.f). + /// + public float ClippedRatioThreshold; + + /// + /// Time in frames to wait after a clipping event before checking again. + /// Limited to values higher than 0. + /// + public int ClippedWaitFrames; + + [StructLayout(LayoutKind.Sequential)] + public struct ClippingPredictor + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + + public enum Mode + { + /// Clipping event prediction mode with fixed step estimation. + ClippingEventPrediction, + + /// Clipped peak estimation mode with adaptive step estimation. + AdaptiveStepClippingPeakPrediction, + + /// Clipped peak estimation mode with fixed step estimation. + FixedStepClippingPeakPrediction + } + + public Mode PredictorMode; + + /// Number of frames in the sliding analysis window. + public int WindowLength; + + /// Number of frames in the sliding reference window. + public int ReferenceWindowLength; + + /// Reference window delay (unit: number of frames). + public int ReferenceWindowDelay; + + /// Clipping prediction threshold (dBFS). + public float ClippingThreshold; + + /// Crest factor drop threshold (dB). + public float CrestFactorMargin; + + /// + /// If true, the recommended clipped level step is used to modify the analog gain. + /// Otherwise, the predictor runs without affecting the analog gain. + /// + [MarshalAs(UnmanagedType.I1)] + public bool UsePredictedStep; + } + + public ClippingPredictor Predictor; + } + + public AnalogGainController AnalogController; + } + + /// + /// Parameters for AGC2, which brings the captured audio signal to the desired level by + /// combining three different controllers and a limiter. + /// + [StructLayout(LayoutKind.Sequential)] + public struct GainController2 + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + + /// + /// Parameters for the input volume controller, which adjusts the input volume + /// applied when the audio is captured. + /// + [StructLayout(LayoutKind.Sequential)] + public struct InputVolumeController + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + } + + /// + /// Parameters for the adaptive digital controller, which adjusts and applies + /// a digital gain after echo cancellation and noise suppression. + /// + [StructLayout(LayoutKind.Sequential)] + public struct AdaptiveDigital + { + [MarshalAs(UnmanagedType.I1)] + public bool Enabled; + public float HeadroomDb; + public float MaxGainDb; + public float InitialGainDb; + public float MaxGainChangeDbPerSecond; + public float MaxOutputNoiseLevelDbfs; + } + + /// + /// Parameters for the fixed digital controller, which applies a fixed digital + /// gain after the adaptive digital controller and before the limiter. + /// + [StructLayout(LayoutKind.Sequential)] + public struct FixedDigital + { + /// + /// By setting gain_db to a value greater than zero, the limiter can be + /// turned into a compressor that first applies a fixed gain. + /// + public float GainDb; + } + + public InputVolumeController VolumeController; + public AdaptiveDigital AdaptiveController; + public FixedDigital FixedController; + } + + public Pipeline PipelineConfig; + public PreAmplifier PreAmp; + public CaptureLevelAdjustment LevelAdjustment; + public HighPassFilter HighPass; + public EchoCanceller Echo; + public NoiseSuppression NoiseSuppress; + public TransientSuppression TransientSuppress; + public GainController1 GainControl1; + public GainController2 GainControl2; + + /// + /// Creates a new Config instance with default settings. + /// + /// A Config instance initialized with default values. + public static Config Build() + { + return new Config + { + PipelineConfig = new Pipeline + { + MaximumInternalProcessingRate = 48000, + MultiChannelRender = false, + MultiChannelCapture = false, + CaptureDownmixMethod = Pipeline.DownmixMethod.AverageChannels + }, + PreAmp = new PreAmplifier + { + Enabled = false, + FixedGainFactor = 1.0f + }, + LevelAdjustment = new CaptureLevelAdjustment + { + Enabled = false, + PreGainFactor = 1.0f, + PostGainFactor = 1.0f, + MicGainEmulation = new CaptureLevelAdjustment.AnalogMicGainEmulation + { + Enabled = false, + InitialLevel = 255 + } + }, + HighPass = new HighPassFilter + { + Enabled = false, + ApplyInFullBand = true + }, + Echo = new EchoCanceller + { + Enabled = false, + MobileMode = false, + ExportLinearAecOutput = false, + EnforceHighPassFiltering = true + }, + NoiseSuppress = new NoiseSuppression + { + Enabled = false, + NoiseLevel = NoiseSuppression.Level.Moderate, + AnalyzeLinearAecOutputWhenAvailable = false + }, + TransientSuppress = new TransientSuppression + { + Enabled = false + }, + GainControl1 = new GainController1 + { + Enabled = false, + ControllerMode = GainController1.Mode.AdaptiveAnalog, + TargetLevelDbfs = 3, + CompressionGainDb = 9, + EnableLimiter = true, + AnalogController = new GainController1.AnalogGainController + { + Enabled = true, + StartupMinVolume = 0, + ClippedLevelMin = 70, + EnableDigitalAdaptive = true, + ClippedLevelStep = 15, + ClippedRatioThreshold = 0.1f, + ClippedWaitFrames = 300, + Predictor = new GainController1.AnalogGainController.ClippingPredictor + { + Enabled = false, + PredictorMode = GainController1.AnalogGainController.ClippingPredictor.Mode + .ClippingEventPrediction, + WindowLength = 5, + ReferenceWindowLength = 5, + ReferenceWindowDelay = 5, + ClippingThreshold = -1.0f, + CrestFactorMargin = 3.0f, + UsePredictedStep = true + } + } + }, + GainControl2 = new GainController2 + { + Enabled = false, + VolumeController = new GainController2.InputVolumeController + { + Enabled = false + }, + AdaptiveController = new GainController2.AdaptiveDigital + { + Enabled = false, + HeadroomDb = 5.0f, + MaxGainDb = 50.0f, + InitialGainDb = 15.0f, + MaxGainChangeDbPerSecond = 6.0f, + MaxOutputNoiseLevelDbfs = -50.0f + }, + FixedController = new GainController2.FixedDigital + { + GainDb = 0.0f + } + } + }; + } + } +} \ No newline at end of file diff --git a/libs/webrtc_apm/WebRTCAPMWrapper.cs.meta b/libs/webrtc_apm/WebRTCAPMWrapper.cs.meta new file mode 100644 index 0000000000000000000000000000000000000000..d5e49c6c954cfd98c84149ad3fbb5148048a7fe3 --- /dev/null +++ b/libs/webrtc_apm/WebRTCAPMWrapper.cs.meta @@ -0,0 +1,3 @@ +fileFormatVersion: 2 +guid: 1a4811726fdd4bd9a274d5b7372d55ef +timeCreated: 1742537346 \ No newline at end of file diff --git a/libs/webrtc_apm/ios.meta b/libs/webrtc_apm/ios.meta new file mode 100644 index 0000000000000000000000000000000000000000..1ebf55a94141ad9c2893d0a719734d0956953f24 --- /dev/null +++ b/libs/webrtc_apm/ios.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: da1b0716acba2314096295c598c7d85d +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/linux.meta b/libs/webrtc_apm/linux.meta new file mode 100644 index 0000000000000000000000000000000000000000..8a37be4563efd32907a4c83d366834e2a5f4f572 --- /dev/null +++ b/libs/webrtc_apm/linux.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: e7670c6d49dcb064e96c1548fd4cf700 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/linux/x64.meta b/libs/webrtc_apm/linux/x64.meta new file mode 100644 index 0000000000000000000000000000000000000000..cdebaf68a9671029299affad1062232a42ec0713 --- /dev/null +++ b/libs/webrtc_apm/linux/x64.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fd25a653e08fbec478d01ec41c9fcf59 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/linux/x64/libwebrtc_apm.so b/libs/webrtc_apm/linux/x64/libwebrtc_apm.so new file mode 100644 index 0000000000000000000000000000000000000000..9b668d0994217e7fd749746fde014b5124d77243 --- /dev/null +++ b/libs/webrtc_apm/linux/x64/libwebrtc_apm.so @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9d7f333762f119c4964d97bbda9a34e3c2da2eb685d2efcdbfc3d790caf65233 +size 1574488 diff --git a/libs/webrtc_apm/linux/x64/libwebrtc_apm.so.meta b/libs/webrtc_apm/linux/x64/libwebrtc_apm.so.meta new file mode 100644 index 0000000000000000000000000000000000000000..20e446531c1122d78258f816214f081b8b8bbd66 --- /dev/null +++ b/libs/webrtc_apm/linux/x64/libwebrtc_apm.so.meta @@ -0,0 +1,82 @@ +fileFormatVersion: 2 +guid: a933b629fba04d84aaba31bd897ada7f +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 0 + Exclude Linux64: 0 + Exclude OSXUniversal: 0 + Exclude Win: 0 + Exclude Win64: 0 + Exclude iOS: 1 + - first: + Android: Android + second: + enabled: 0 + settings: + AndroidSharedLibraryType: Executable + CPU: ARMv7 + Is16KbAligned: false + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: x86_64 + DefaultValueInitialized: true + OS: Linux + - first: + Standalone: Linux64 + second: + enabled: 1 + settings: + CPU: x86_64 + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: None + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: x86 + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: x86_64 + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/mac.meta b/libs/webrtc_apm/mac.meta new file mode 100644 index 0000000000000000000000000000000000000000..1d00338e3d7dbb99f3c92940e213fe79dce87f4a --- /dev/null +++ b/libs/webrtc_apm/mac.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: b3c9be8a7ac4483479c3b080817cddbd +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/mac/arm64.meta b/libs/webrtc_apm/mac/arm64.meta new file mode 100644 index 0000000000000000000000000000000000000000..a5563222e4ee7c13f2e4a812637882a13a2a3ece --- /dev/null +++ b/libs/webrtc_apm/mac/arm64.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 0c334f268aa4df44a926b911fd886b28 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/mac/arm64/libwebrtc_apm.dylib b/libs/webrtc_apm/mac/arm64/libwebrtc_apm.dylib new file mode 100644 index 0000000000000000000000000000000000000000..4e48a288aad64bb122fa13f64c4c064ce82da07b --- /dev/null +++ b/libs/webrtc_apm/mac/arm64/libwebrtc_apm.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:36f8d752625c7738ce37a6eff3dafbc5a95f617b85f5f3926088a80c931d8c50 +size 1132544 diff --git a/libs/webrtc_apm/mac/arm64/libwebrtc_apm.dylib.meta b/libs/webrtc_apm/mac/arm64/libwebrtc_apm.dylib.meta new file mode 100644 index 0000000000000000000000000000000000000000..f7541027c51e5778ba01b2e2d29f5ccd8a694ab9 --- /dev/null +++ b/libs/webrtc_apm/mac/arm64/libwebrtc_apm.dylib.meta @@ -0,0 +1,81 @@ +fileFormatVersion: 2 +guid: 95fe115f57f81f54cacc01312e2ae2ea +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 0 + Exclude Linux64: 1 + Exclude OSXUniversal: 0 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Android: Android + second: + enabled: 0 + settings: + AndroidSharedLibraryType: Executable + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: ARM64 + DefaultValueInitialized: true + OS: OSX + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: ARM64 + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: x86 + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: x86_64 + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/mac/x64.meta b/libs/webrtc_apm/mac/x64.meta new file mode 100644 index 0000000000000000000000000000000000000000..5325d2580dca92bee2c665fc660c0974bfca4407 --- /dev/null +++ b/libs/webrtc_apm/mac/x64.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: fb65e8eda949ec74992f62aa3561ba48 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/mac/x64/libwebrtc_apm.dylib b/libs/webrtc_apm/mac/x64/libwebrtc_apm.dylib new file mode 100644 index 0000000000000000000000000000000000000000..f646919cc2714cca1ed86c6164fc17cd2510ea1f --- /dev/null +++ b/libs/webrtc_apm/mac/x64/libwebrtc_apm.dylib @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:81dfdc558fe8ccd01701bd66bbffb9c744d427e8c19b9d54352470747d99a836 +size 1341864 diff --git a/libs/webrtc_apm/mac/x64/libwebrtc_apm.dylib.meta b/libs/webrtc_apm/mac/x64/libwebrtc_apm.dylib.meta new file mode 100644 index 0000000000000000000000000000000000000000..8975ad2eb140ab326e19522c118771f44fc3f215 --- /dev/null +++ b/libs/webrtc_apm/mac/x64/libwebrtc_apm.dylib.meta @@ -0,0 +1,81 @@ +fileFormatVersion: 2 +guid: 40e99861ba3ca854790a6fa0863b3dfe +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 0 + Exclude Linux64: 1 + Exclude OSXUniversal: 0 + Exclude Win: 1 + Exclude Win64: 1 + Exclude iOS: 1 + - first: + Android: Android + second: + enabled: 0 + settings: + AndroidSharedLibraryType: Executable + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: x86_64 + DefaultValueInitialized: true + OS: OSX + - first: + Standalone: Linux64 + second: + enabled: 0 + settings: + CPU: x86_64 + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: x86_64 + - first: + Standalone: Win + second: + enabled: 0 + settings: + CPU: x86 + - first: + Standalone: Win64 + second: + enabled: 0 + settings: + CPU: x86_64 + - first: + iPhone: iOS + second: + enabled: 0 + settings: + AddToEmbeddedBinaries: false + CPU: AnyCPU + CompileFlags: + FrameworkDependencies: + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/win.meta b/libs/webrtc_apm/win.meta new file mode 100644 index 0000000000000000000000000000000000000000..4a0235591870cb51412f4a7c7e4b7edd05d47a2f --- /dev/null +++ b/libs/webrtc_apm/win.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 699beebf23c04984faa7a7985f05bbc3 +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/win/x86_64.meta b/libs/webrtc_apm/win/x86_64.meta new file mode 100644 index 0000000000000000000000000000000000000000..a5d89ff0a6db5b388fb7d00748411061411fd110 --- /dev/null +++ b/libs/webrtc_apm/win/x86_64.meta @@ -0,0 +1,8 @@ +fileFormatVersion: 2 +guid: 8c6352ba193b54346be33bb55e62a53f +folderAsset: yes +DefaultImporter: + externalObjects: {} + userData: + assetBundleName: + assetBundleVariant: diff --git a/libs/webrtc_apm/win/x86_64/libwebrtc_apm.dll b/libs/webrtc_apm/win/x86_64/libwebrtc_apm.dll new file mode 100644 index 0000000000000000000000000000000000000000..f62c6a15a1dab769c2446934b30d21f7abaa0952 --- /dev/null +++ b/libs/webrtc_apm/win/x86_64/libwebrtc_apm.dll @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cc1b9276fa8763f097ec6f03977c69dc156fb0d0b13b6dbadf8ab98862d2d11a +size 718336 diff --git a/libs/webrtc_apm/win/x86_64/libwebrtc_apm.dll.meta b/libs/webrtc_apm/win/x86_64/libwebrtc_apm.dll.meta new file mode 100644 index 0000000000000000000000000000000000000000..e614da51515bffdc5e3347c9097d71cee287e855 --- /dev/null +++ b/libs/webrtc_apm/win/x86_64/libwebrtc_apm.dll.meta @@ -0,0 +1,71 @@ +fileFormatVersion: 2 +guid: ef41b73b1f4f797489d180957bb60e2a +PluginImporter: + externalObjects: {} + serializedVersion: 2 + iconMap: {} + executionOrder: {} + defineConstraints: [] + isPreloaded: 0 + isOverridable: 0 + isExplicitlyReferenced: 0 + validateReferences: 1 + platformData: + - first: + : Any + second: + enabled: 0 + settings: + Exclude Android: 1 + Exclude Editor: 0 + Exclude Linux64: 0 + Exclude OSXUniversal: 0 + Exclude Win: 0 + Exclude Win64: 0 + - first: + Android: Android + second: + enabled: 0 + settings: + AndroidSharedLibraryType: Executable + CPU: ARMv7 + - first: + Any: + second: + enabled: 0 + settings: {} + - first: + Editor: Editor + second: + enabled: 1 + settings: + CPU: x86_64 + DefaultValueInitialized: true + OS: Windows + - first: + Standalone: Linux64 + second: + enabled: 1 + settings: + CPU: AnyCPU + - first: + Standalone: OSXUniversal + second: + enabled: 1 + settings: + CPU: None + - first: + Standalone: Win + second: + enabled: 1 + settings: + CPU: x86 + - first: + Standalone: Win64 + second: + enabled: 1 + settings: + CPU: x86_64 + userData: + assetBundleName: + assetBundleVariant: diff --git a/main.py b/main.py new file mode 100644 index 0000000000000000000000000000000000000000..f69a08f694547966ff17ac78f2085d293e23cd2a --- /dev/null +++ b/main.py @@ -0,0 +1,84 @@ +import argparse +import logging +import sys +import signal +from src.application import Application +from src.utils.logging_config import setup_logging, get_logger + +logger = get_logger(__name__) +# 配置日志 + +def parse_args(): + """解析命令行参数""" + parser = argparse.ArgumentParser(description='小智Ai客户端') + + # 添加界面模式参数 + parser.add_argument( + '--mode', + choices=['gui', 'cli'], + default='gui', + help='运行模式:gui(图形界面) 或 cli(命令行)' + ) + + # 添加协议选择参数 + parser.add_argument( + '--protocol', + choices=['mqtt', 'websocket'], + default='websocket', + help='通信协议:mqtt 或 websocket' + ) + + return parser.parse_args() + +def signal_handler(sig, frame): + """处理Ctrl+C信号""" + logger.info("接收到中断信号,正在关闭...") + app = Application.get_instance() + app.shutdown() + sys.exit(0) + + +def main(): + """程序入口点""" + # 注册信号处理器 + signal.signal(signal.SIGINT, signal_handler) + # 解析命令行参数 + args = parse_args() + try: + # 日志 + setup_logging() + # 创建并运行应用程序 + app = Application.get_instance() + + logger.info("应用程序已启动,按Ctrl+C退出") + + # 启动应用,传入参数 + app.run( + mode=args.mode, + protocol=args.protocol + ) + + # 如果是GUI模式且使用了PyQt界面,启动Qt事件循环 + if args.mode == 'gui': + # 获取QApplication实例并运行事件循环 + try: + from PyQt5.QtWidgets import QApplication + qt_app = QApplication.instance() + if qt_app: + logger.info("开始Qt事件循环") + qt_app.exec_() + logger.info("Qt事件循环结束") + except ImportError: + logger.warning("PyQt5未安装,无法启动Qt事件循环") + except Exception as e: + logger.error(f"Qt事件循环出错: {e}", exc_info=True) + + except Exception as e: + logger.error(f"程序发生错误: {e}", exc_info=True) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..217bdab24a90bc7f107c94a94d560ba704e75107 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,45 @@ +annotated-types==0.7.0 +anyio==4.9.0 +certifi==2025.1.31 +cffi==1.17.1 +charset-normalizer==3.4.1 +colorama==0.4.6 +comtypes==1.4.6 +cryptography==44.0.1 +distro==1.9.0 +exceptiongroup==1.2.2 +h11==0.14.0 +httpcore==1.0.7 +httpx==0.28.1 +idna==3.10 +jiter==0.9.0 +numpy==2.0.2 +openai +opuslib==3.0.1 +paho-mqtt==2.1.0 +psutil==7.0.0 +PyAudio==0.2.14 +pycaw==20240210 +pycparser==2.22 +pydantic==2.10.6 +pydantic_core==2.27.2 +pynput==1.8.0 +pyperclip==1.9.0 +pypinyin==0.53.0 +requests==2.32.3 +six==1.17.0 +sniffio==1.3.1 +srt==3.5.3 +tqdm==4.67.1 +typing_extensions==4.12.2 +urllib3==2.3.0 +vosk==0.3.45 +webrtcvad-wheels==2.0.14 +websockets==11.0.3 +colorlog==6.9.0 +edge-tts==6.1.17 +soundfile>=0.12.1 +pydub>=0.25.1 +pyttsx3==2.98 +pygame==2.6.1 +wmi==1.5.1 \ No newline at end of file diff --git a/requirements_mac.txt b/requirements_mac.txt new file mode 100644 index 0000000000000000000000000000000000000000..a1c2f2dc467f755ce35feb23bfcf9f0fb421eb74 --- /dev/null +++ b/requirements_mac.txt @@ -0,0 +1,44 @@ +applescript==2021.2.9 # macOS音量控制 +anyio==4.9.0 +certifi==2025.1.31 +cffi==1.17.1 +charset-normalizer==3.4.1 +colorama==0.4.6 +comtypes==1.4.6 +cryptography==44.0.1 +distro==1.9.0 +exceptiongroup==1.2.2 +h11==0.14.0 +httpcore==1.0.7 +httpx==0.28.1 +idna==3.10 +jiter==0.9.0 +numpy==2.0.2 +openai +opuslib==3.0.1 +paho-mqtt==2.1.0 +psutil==7.0.0 +PyAudio==0.2.14 +pycaw==20240210 +pycparser==2.22 +pydantic==2.10.6 +pydantic_core==2.27.2 +pynput==1.8.0 +pyperclip==1.9.0 +pypinyin==0.53.0 +requests==2.32.3 +six==1.17.0 +sniffio==1.3.1 +srt==3.5.3 +tqdm==4.67.1 +typing_extensions==4.12.2 +urllib3==2.3.0 +vosk==0.3.44 +webrtcvad-wheels==2.0.14 +websockets==11.0.3 +colorlog==6.9.0 +edge-tts==6.1.17 +soundfile>=0.12.1 +pydub>=0.25.1 +pyttsx3==2.98 +pygame==2.6.1 \ No newline at end of file diff --git a/scripts/camera_scanner.py b/scripts/camera_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..5afc28a6b03640ecd8825203ffb6caac1e3b74ab --- /dev/null +++ b/scripts/camera_scanner.py @@ -0,0 +1,263 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# 文件名: camera_scanner.py + +import cv2 +import json +import logging +import sys +import time +from pathlib import Path + +# 添加项目根目录到系统路径,以便导入src中的模块 +project_root = Path(__file__).parent.parent +sys.path.append(str(project_root)) + +# 导入ConfigManager类 +from src.utils.config_manager import ConfigManager + +# 设置日志记录 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger("CameraScanner") + + +def get_camera_capabilities(cam): + """获取摄像头的参数和能力""" + capabilities = {} + + # 获取可用的分辨率 + standard_resolutions = [ + (640, 480), # VGA + (800, 600), # SVGA + (1024, 768), # XGA + (1280, 720), # HD + (1280, 960), # 4:3 HD + (1920, 1080), # Full HD + (2560, 1440), # QHD + (3840, 2160) # 4K UHD + ] + + supported_resolutions = [] + original_width = int(cam.get(cv2.CAP_PROP_FRAME_WIDTH)) + original_height = int(cam.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + # 记录原始分辨率 + capabilities["default_resolution"] = (original_width, original_height) + + # 测试标准分辨率 + for width, height in standard_resolutions: + cam.set(cv2.CAP_PROP_FRAME_WIDTH, width) + cam.set(cv2.CAP_PROP_FRAME_HEIGHT, height) + actual_width = int(cam.get(cv2.CAP_PROP_FRAME_WIDTH)) + actual_height = int(cam.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + # 如果设置成功(实际分辨率与请求的相同) + if actual_width == width and actual_height == height: + supported_resolutions.append((width, height)) + + # 恢复原始分辨率 + cam.set(cv2.CAP_PROP_FRAME_WIDTH, original_width) + cam.set(cv2.CAP_PROP_FRAME_HEIGHT, original_height) + + capabilities["supported_resolutions"] = supported_resolutions + + # 获取帧率 + fps = int(cam.get(cv2.CAP_PROP_FPS)) + capabilities["fps"] = fps if fps > 0 else 30 # 默认为30fps + + # 获取后端名称 + backend_name = cam.getBackendName() + capabilities["backend"] = backend_name + + return capabilities + + +def detect_cameras(): + """检测并列出所有可用摄像头""" + print("\n===== 摄像头设备检测 =====\n") + + # 获取ConfigManager实例 + config_manager = ConfigManager.get_instance() + + # 获取当前相机配置 + current_camera_config = config_manager.get_config("CAMERA", {}) + logger.info(f"当前相机配置: {current_camera_config}") + + # 存储找到的设备 + camera_devices = [] + + # 尝试打开多个摄像头索引 + max_cameras_to_check = 10 # 最多检查10个摄像头索引 + + for i in range(max_cameras_to_check): + try: + # 尝试打开摄像头 + cap = cv2.VideoCapture(i) + + if cap.isOpened(): + # 获取摄像头信息 + device_name = f"Camera {i}" + try: + # 在某些系统上可能可以获取设备名称 + device_name = cap.getBackendName() + f" Camera {i}" + except Exception as e: + logger.warning(f"获取设备{i}名称失败: {e}") + pass + + # 读取一帧以确保摄像头正常工作 + ret, frame = cap.read() + if not ret: + print(f"设备 {i}: 打开成功但无法读取画面,跳过") + cap.release() + continue + + # 获取摄像头能力 + capabilities = get_camera_capabilities(cap) + + # 打印设备信息 + width, height = capabilities['default_resolution'] + resolutions_str = ", ".join( + [f"{w}x{h}" for w, h in capabilities['supported_resolutions']] + ) + + print(f"设备 {i}: {device_name}") + print(f" - 默认分辨率: {width}x{height}") + print(f" - 支持分辨率: {resolutions_str}") + print(f" - 帧率: {capabilities['fps']}") + print(f" - 后端: {capabilities['backend']}") + print("") + + # 添加到设备列表 + camera_devices.append({ + "index": i, + "name": device_name, + "capabilities": capabilities + }) + + # 显示预览画面 + print(f"正在显示设备 {i} 的预览画面,按 'q' 键继续...") + preview_start = time.time() + + while time.time() - preview_start < 3: # 预览3秒 + ret, frame = cap.read() + if ret: + cv2.imshow(f'Camera {i} Preview', frame) + if cv2.waitKey(1) & 0xFF == ord('q'): + break + + cv2.destroyAllWindows() + cap.release() + + else: + # 如果连续两个索引无法打开摄像头,则认为没有更多摄像头了 + consecutive_failures = 0 + for j in range(i, i + 2): + temp_cap = cv2.VideoCapture(j) + if not temp_cap.isOpened(): + consecutive_failures += 1 + temp_cap.release() + + if consecutive_failures >= 2 and i > 0: + break + + except Exception as e: + print(f"检测设备 {i} 时出错: {e}") + + # 总结找到的设备 + print("\n===== 设备总结 =====\n") + + if not camera_devices: + print("未找到可用的摄像头设备!") + return None + + print(f"找到 {len(camera_devices)} 个摄像头设备:") + for device in camera_devices: + width, height = device['capabilities']['default_resolution'] + print(f" - 设备 {device['index']}: {device['name']}") + print(f" 分辨率: {width}x{height}") + + # 推荐最佳设备 + print("\n===== 推荐设备 =====\n") + + # 首选高清摄像头,其次是分辨率最高的 + recommended_camera = None + highest_resolution = 0 + + for device in camera_devices: + width, height = device['capabilities']['default_resolution'] + resolution = width * height + + # 如果是HD或以上分辨率 + if width >= 1280 and height >= 720: + if resolution > highest_resolution: + highest_resolution = resolution + recommended_camera = device + elif recommended_camera is None or resolution > highest_resolution: + highest_resolution = resolution + recommended_camera = device + + # 打印推荐设备 + if recommended_camera: + r_width, r_height = recommended_camera['capabilities']['default_resolution'] + print(f"推荐摄像头: 设备 {recommended_camera['index']} " + f"({recommended_camera['name']})") + print(f" - 分辨率: {r_width}x{r_height}") + print(f" - 帧率: {recommended_camera['capabilities']['fps']}") + + # 从现有配置中获取VL API信息 + vl_url = current_camera_config.get( + "Loacl_VL_url", + "https://open.bigmodel.cn/api/paas/v4/" + ) + vl_api_key = current_camera_config.get("VLapi_key", "你自己的key") + model = current_camera_config.get("models", "glm-4v-plus") + + # 生成配置文件示例 + print("\n===== 配置文件示例 =====\n") + + new_camera_config = { + "camera_index": recommended_camera['index'], + "frame_width": r_width, + "frame_height": r_height, + "fps": recommended_camera['capabilities']['fps'], + "Loacl_VL_url": vl_url, # 保留原有值 + "VLapi_key": vl_api_key, # 保留原有值 + "models": model # 保留原有值 + } + + print(json.dumps(new_camera_config, indent=2, ensure_ascii=False)) + + # 询问是否更新配置文件 + print("\n是否要更新配置文件中的摄像头配置?(y/n)") + choice = input().strip().lower() + + if choice == 'y': + try: + # 使用ConfigManager更新配置 + success = config_manager.update_config("CAMERA", new_camera_config) + + if success: + print("\n摄像头配置已成功更新到config.json!") + else: + print("\n更新摄像头配置失败!") + + except Exception as e: + logger.error(f"更新配置时出错: {e}") + print(f"\n更新配置时出错: {e}") + + return camera_devices + + +if __name__ == "__main__": + try: + cameras = detect_cameras() + if cameras: + print(f"\n检测到 {len(cameras)} 个摄像头设备!") + else: + print("\n未检测到可用的摄像头设备!") + except Exception as e: + logger.error(f"检测过程中出错: {e}") + print(f"检测过程中出错: {e}") \ No newline at end of file diff --git a/scripts/dir_tree.py b/scripts/dir_tree.py new file mode 100644 index 0000000000000000000000000000000000000000..bd1d3991c7fcc0967a8bf6027fceb39206392bf4 --- /dev/null +++ b/scripts/dir_tree.py @@ -0,0 +1,28 @@ +import os + +# 需要排除的目录 & 文件(你可以自定义) +EXCLUDED_DIRS = {".git", ".idea", ".venv", "build", "dist", "__pycache__", "desktop", "logs", "models", "documents"} +EXCLUDED_FILES = {".DS_Store", "Thumbs.db"} + +def print_directory_tree(start_path=".", indent=""): + try: + files = sorted(os.listdir(start_path)) + except PermissionError: + return + + files = [f for f in files if f not in EXCLUDED_FILES] # 过滤不需要的文件 + dirs = [d for d in files if os.path.isdir(os.path.join(start_path, d)) and d not in EXCLUDED_DIRS] + files = [f for f in files if os.path.isfile(os.path.join(start_path, f))] + + for index, file in enumerate(dirs + files): + path = os.path.join(start_path, file) + is_last = index == len(dirs + files) - 1 + prefix = "└── " if is_last else "├── " + print(indent + prefix + file) + + if os.path.isdir(path): + next_indent = indent + (" " if is_last else "│ ") + print_directory_tree(path, next_indent) + +if __name__ == "__main__": + print_directory_tree("..") diff --git a/scripts/ha_device_manager_ui.py b/scripts/ha_device_manager_ui.py new file mode 100644 index 0000000000000000000000000000000000000000..bb3f689d6c14c68a55c23a88cb3be45828aed3a0 --- /dev/null +++ b/scripts/ha_device_manager_ui.py @@ -0,0 +1,1010 @@ +# -*- coding: utf-8 -*- +""" +Home Assistant设备管理器 - 图形界面 +用于查询Home Assistant设备并将其添加到配置文件中 +""" + +import os +import sys +import json +import time +import threading +from typing import Dict, List, Optional, Any, Tuple +import logging + +# 添加项目根目录到系统路径 +current_dir = os.path.dirname(os.path.abspath(__file__)) +project_root = os.path.dirname(current_dir) +sys.path.append(project_root) + +# 导入项目配置管理器 +from src.utils.config_manager import ConfigManager + +try: + from PyQt5.QtWidgets import ( + QApplication, QMainWindow, QWidget, QPushButton, QTableWidgetItem, + QHeaderView, QMessageBox, QHBoxLayout, QLineEdit, QComboBox, + QTableWidget, QStackedWidget, QTabBar, QAbstractItemView, QVBoxLayout, + QFrame # 添加 QFrame + ) + from PyQt5.QtCore import Qt, QThread, pyqtSignal + from PyQt5.QtGui import QFont, QIcon, QColor + from PyQt5 import uic + + # 移除 QFluentWidgets 相关导入 + # try: + # from qfluentwidgets import ( + # ComboBox, LineEdit, SearchLineEdit, TableWidget, Theme, setTheme, setThemeColor, + # SegmentedWidget + # ) + # except ImportError: + # print("错误: 未安装qfluentwidgets库") + # print("请运行: pip install qfluentwidgets") + # sys.exit(1) + +except ImportError: + print("错误: 未安装PyQt5库") + print("请运行: pip install PyQt5") + sys.exit(1) + +try: + import requests +except ImportError: + print("错误: 未安装requests库") + print("请运行: pip install requests") + sys.exit(1) + +# 设备类型和图标映射 +DOMAIN_ICONS = { + "light": "灯具 💡", + "switch": "开关 🔌", + "sensor": "传感器 🌡️", + "climate": "空调 ❄️", + "fan": "风扇 💨", + "media_player": "媒体播放器 📺", + "camera": "摄像头 📷", + "cover": "窗帘 🪟", + "vacuum": "扫地机器人 🧹", + "binary_sensor": "二元传感器 🔔", + "lock": "锁 🔒", + "alarm_control_panel": "安防面板 🚨", + "automation": "自动化 ⚙️", + "script": "脚本 📜" +} + +class DeviceLoadThread(QThread): + """加载设备的线程""" + devices_loaded = pyqtSignal(list) + error_occurred = pyqtSignal(str) + + def __init__(self, url, token, domain="all"): + super().__init__() + self.url = url + self.token = token + self.domain = domain + self._is_running = True + + def run(self): + try: + # 检查线程是否应该继续运行 + if not self._is_running: + return + + devices = self.get_device_list(self.url, self.token, self.domain) + + # 再次检查线程是否应该继续运行 + if not self._is_running: + return + + self.devices_loaded.emit(devices) + except Exception as e: + if self._is_running: # 只有在线程仍应运行时才发出错误信号 + self.error_occurred.emit(str(e)) + + def terminate(self): + """安全终止线程""" + self._is_running = False + super().terminate() # 调用QThread的terminate方法 + + def get_device_list(self, url: str, token: str, domain: str = "all") -> List[Dict[str, Any]]: + """从Home Assistant API获取设备列表""" + headers = { + "Authorization": f"Bearer {token}", + "Content-Type": "application/json", + } + + try: + # 获取所有状态 + response = requests.get(f"{url}/api/states", headers=headers, timeout=10) + + if response.status_code != 200: + error_msg = f"错误: 无法获取设备列表 (HTTP {response.status_code}): {response.text}" + self.error_occurred.emit(error_msg) + return [] + + # 检查线程是否应该继续运行 + if not self._is_running: + return [] + + # 解析响应 + entities = response.json() + + # 过滤指定域的实体 + domain_entities = [] + for entity in entities: + # 检查线程是否应该继续运行 + if not self._is_running: + return [] + + entity_id = entity.get("entity_id", "") + entity_domain = entity_id.split(".", 1)[0] if "." in entity_id else "" + + if domain == "all" or entity_domain == domain: + domain_entities.append({ + "entity_id": entity_id, + "domain": entity_domain, + "friendly_name": entity.get("attributes", {}).get("friendly_name", entity_id), + "state": entity.get("state", "unknown") + }) + + # 按域和名称排序 + domain_entities.sort(key=lambda x: (x["domain"], x["friendly_name"])) + return domain_entities + + except Exception as e: + if self._is_running: # 只有在线程仍应运行时才发出错误信号 + self.error_occurred.emit(f"错误: 获取设备列表失败 - {e}") + return [] + +class HomeAssistantDeviceManager(QMainWindow): + """Home Assistant设备管理器GUI""" + + def __init__(self): + super().__init__() + + # 从配置文件获取Home Assistant配置 + self.config = ConfigManager.get_instance() + self.ha_url = self.config.get_config("HOME_ASSISTANT.URL", "") + self.ha_token = self.config.get_config("HOME_ASSISTANT.TOKEN", "") + + if not self.ha_url or not self.ha_token: + QMessageBox.critical( + self, + "配置错误", + "未找到Home Assistant配置,请确保config/config.json中包含有效的HOME_ASSISTANT.URL和HOME_ASSISTANT.TOKEN" + ) + sys.exit(1) + + # 已添加的设备 + self.added_devices = self.config.get_config("HOME_ASSISTANT.DEVICES", []) + + # 当前获取的设备列表 + self.current_devices = [] + + # 存储域映射关系 + self.domain_mapping = {} + + # 线程管理 + self.threads = [] # 保存活动线程的引用 + self.load_thread = None # 当前加载线程 + + # 初始化logger + self.logger = logging.getLogger("HADeviceManager") + + # 加载UI文件 + self.load_ui() + + # 应用样式表进行美化 + self.apply_stylesheet() + + # 初始化UI组件 + self.init_ui() + + # 连接信号槽 - 除导航信号外的其他信号 + self.connect_signals() + + # 加载设备 + self.load_devices("all") + + def closeEvent(self, event): + """窗口关闭事件处理""" + # 停止所有线程 + self.stop_all_threads() + super().closeEvent(event) + + def stop_all_threads(self): + """停止所有线程""" + # 先停止当前加载线程 + if self.load_thread and self.load_thread.isRunning(): + self.logger.info("停止当前加载线程...") + try: + self.load_thread.terminate() # 使用我们定义的安全终止方法 + if not self.load_thread.wait(1000): # 等待最多1秒 + self.logger.warning("加载线程未能在1秒内停止") + except Exception as e: + self.logger.error(f"停止加载线程时出错: {e}") + + # 停止所有其他线程 + for thread in self.threads[:]: # 使用副本进行迭代 + if thread and thread.isRunning(): + self.logger.info(f"停止线程: {thread}") + try: + if hasattr(thread, 'terminate'): + thread.terminate() # 使用我们定义的安全终止方法 + if not thread.wait(1000): # 等待最多1秒 + self.logger.warning(f"线程未能在1秒内停止: {thread}") + except Exception as e: + self.logger.error(f"停止线程时出错: {e}") + + # 清空线程列表 + self.threads.clear() + self.load_thread = None + + def apply_stylesheet(self): + """应用自定义样式表美化界面""" + stylesheet = """ + QMainWindow { + background-color: #f0f0f0; /* 窗口背景色 */ + } + + /* 卡片样式 (使用 QFrame 替代) */ + QFrame#available_card, QFrame#added_card { + background-color: white; + border-radius: 8px; + border: 1px solid #dcdcdc; + padding: 5px; /* 内边距 */ + } + + /* 导航栏样式 (QTabBar) */ + QTabBar::tab { + background: #e1e1e1; + border: 1px solid #c4c4c4; + border-bottom: none; /* 无下边框 */ + border-top-left-radius: 4px; + border-top-right-radius: 4px; + padding: 8px 15px; + margin-right: 2px; + color: #333; /* 标签文字颜色 */ + } + + QTabBar::tab:selected { + background: white; /* 选中时背景与卡片一致 */ + border-color: #c4c4c4; + margin-bottom: -1px; /* 轻微重叠,消除边框 */ + color: #000; /* 选中标签文字颜色 */ + } + + QTabBar::tab:!selected { + margin-top: 2px; /* 未选中标签稍低 */ + } + + /* Tab Bar下划线 (可选) */ + /* QTabBar { + border-bottom: 1px solid #c4c4c4; + } */ + + /* 通用控件样式 */ + QComboBox, QLineEdit, QPushButton { + padding: 6px 10px; + border: 1px solid #cccccc; + border-radius: 4px; + min-height: 20px; /* 保证最小高度 */ + font-size: 10pt; /* 统一字体大小 */ + } + + QLineEdit, QComboBox { + background-color: white; + } + + /* 按钮样式 */ + QPushButton { + background-color: #0078d4; /* 蓝色背景 */ + color: white; + font-weight: bold; + min-width: 70px; /* 按钮最小宽度 */ + } + + QPushButton:hover { + background-color: #005a9e; + } + + QPushButton:pressed { + background-color: #003f6e; + } + + QPushButton#delete_button { /* 可以为特定按钮设置样式,如果需要 */ + background-color: #e74c3c; /* 红色删除按钮 */ + } + QPushButton#delete_button:hover { + background-color: #c0392b; + } + + /* 下拉框箭头 */ + QComboBox::drop-down { + border: none; + padding-right: 5px; + } + QComboBox::down-arrow { + image: url(:/qt-project.org/styles/commonstyle/images/standardbutton-down-arrow-16.png); /* 使用系统箭头 */ + width: 12px; + height: 12px; + } + + /* 表格样式 */ + QTableWidget { + border: 1px solid #dcdcdc; + gridline-color: #e0e0e0; + selection-background-color: #a6d1f4; /* 选中行背景色 */ + selection-color: black; /* 选中行文字颜色 */ + alternate-background-color: #f9f9f9; /* 隔行变色 */ + font-size: 10pt; + } + /* QTableWidget::item { + padding: 4px; /* 单元格内边距 */ + /* } */ + + /* 表头样式 */ + QHeaderView::section { + background-color: #e8e8e8; + padding: 5px; + border: 1px solid #dcdcdc; + border-bottom: none; /* 移除表头底部边框 */ + font-weight: bold; + font-size: 10pt; + } + + /* 滚动条美化 (可选,可能需要根据平台调整) */ + QScrollBar:vertical { + border: 1px solid #cccccc; + background: #f0f0f0; + width: 12px; + margin: 0px 0px 0px 0px; + } + QScrollBar::handle:vertical { + background: #c0c0c0; + min-height: 20px; + border-radius: 6px; + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; + background: none; + } + QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { + background: none; + } + + QScrollBar:horizontal { + border: 1px solid #cccccc; + background: #f0f0f0; + height: 12px; + margin: 0px 0px 0px 0px; + } + QScrollBar::handle:horizontal { + background: #c0c0c0; + min-width: 20px; + border-radius: 6px; + } + QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { + width: 0px; + background: none; + } + QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { + background: none; + } + + """ + self.setStyleSheet(stylesheet) + self.logger.info("已应用自定义样式表") + + def load_ui(self): + """加载UI文件""" + ui_path = os.path.join(current_dir, "ha_manage.ui") + uic.loadUi(ui_path, self) + + def init_ui(self): + """初始化UI组件""" + try: + # 加载UI文件 + ui_path = os.path.join(current_dir, "ha_manage.ui") + uic.loadUi(ui_path, self) + + # 设置表格基本属性,保留功能性设置 + self.device_table.verticalHeader().setVisible(False) + self.device_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) # Prompt列 + self.device_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) # 设备ID列 + self.device_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) # 类型列 + self.device_table.horizontalHeader().setSectionResizeMode(3, QHeaderView.ResizeToContents) # 状态列 + + self.added_device_table.verticalHeader().setVisible(False) + self.added_device_table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) # Prompt列 + self.added_device_table.horizontalHeader().setSectionResizeMode(1, QHeaderView.ResizeToContents) # 设备ID列 + self.added_device_table.horizontalHeader().setSectionResizeMode(2, QHeaderView.ResizeToContents) # 操作列 + + # 初始化导航TabBar + self._setup_navigation() + + # 连接信号 - SearchLineEdit 替换为 QLineEdit + self.search_input.textChanged.connect(self.filter_devices) + + # 设置下拉菜单数据 - QComboBox + self.domain_combo.clear() + self.domain_mapping = {"全部": "all"} + self.domain_combo.addItem("全部") + domains = [ + ("light", "灯光 💡"), + ("switch", "开关 🔌"), + ("sensor", "传感器 🌡️"), + ("binary_sensor", "二元传感器 🔔"), + ("climate", "温控 ❄️"), + ("fan", "风扇 💨"), + ("cover", "窗帘 🪟"), + ("media_player", "媒体播放器 📺") + ] + for domain_id, domain_name in domains: + self.domain_mapping[domain_name] = domain_id + self.domain_combo.addItem(domain_name) + + # 设置默认选中项为 "全部" (索引 0) + self.domain_combo.setCurrentIndex(0) + + # 使用正确的方法名称连接信号 - QComboBox 使用 currentIndexChanged 或 currentTextChanged + self.domain_combo.currentTextChanged.connect(self.domain_changed) + + # 加载设备列表 + self.load_devices("all") + + except Exception as e: + self.logger.error(f"初始化UI失败: {str(e)}") + raise + + def _setup_navigation(self): + """设置导航栏 - 使用 QTabBar""" + # 假设 UI 文件中已将 nav_segment 替换为 QTabBar + self.logger.info("开始设置导航栏 (QTabBar)") + + try: + # 获取 QTabBar 实例 (假设 objectName 为 nav_tab_bar) + # 注意:如果 UI 文件中的 objectName 不同,需要相应修改 + # self.nav_tab_bar = self.findChild(QTabBar, "nav_tab_bar") + # 如果 uic.loadUi 已经加载了正确的对象名 nav_segment (即使它是 QTabBar),则可以直接使用 + if not isinstance(self.nav_segment, QTabBar): + # Fallback or error handling if it's not a QTabBar as expected after UI update + self.logger.error("导航控件 'nav_segment' 不是 QTabBar 类型!") + # 可以在这里尝试查找,或者抛出错误 + tab_bar = self.findChild(QTabBar) + if tab_bar: + self.nav_segment = tab_bar + self.logger.warning("已自动查找并设置 QTabBar 实例。请确保 UI 文件中的名称一致。") + else: + QMessageBox.critical(self, "UI错误", "未能找到导航栏控件 (QTabBar)。请检查UI文件。") + return + + # 清空并添加导航项 + # QTabBar 没有 clear() 方法,需要循环移除 + # self.nav_segment.clear() + # Remove existing tabs before adding new ones + while self.nav_segment.count() > 0: + self.nav_segment.removeTab(0) # 循环移除第一个tab直到为空 + + self.nav_segment.addTab("可用设备") # index 0 + self.nav_segment.addTab("已添加设备") # index 1 + + # 存储映射关系,如果需要通过 key 访问 + self._nav_keys = ["available", "added"] + + # 连接信号 - QTabBar 使用 currentChanged(int index) + self.nav_segment.currentChanged.connect(self.on_page_changed_by_index) + + # 设置默认选中项 (索引 0) + self.nav_segment.setCurrentIndex(0) + self.logger.info("导航栏设置完成,默认选中索引 0 ('可用设备')") + except Exception as e: + self.logger.error(f"设置导航栏失败: {e}") + # 防止程序崩溃,显示错误提示 + QMessageBox.warning(self, "警告", f"导航栏设置失败: {e}") + + def connect_signals(self): + """连接信号槽""" + # 域选择变化 + self.domain_combo.currentTextChanged.connect(self.domain_changed) + + # 搜索框文本变化 + self.search_input.textChanged.connect(self.filter_devices) + + # 刷新按钮点击 + self.refresh_button.clicked.connect(self.refresh_devices) + + # 添加设备按钮点击 + self.add_button.clicked.connect(self.add_selected_device) + + # 已添加设备表格单元格编辑 + self.added_device_table.cellChanged.connect(self.on_prompt_edited) + + # 可用设备表格单元格编辑 + self.device_table.cellChanged.connect(self.on_available_device_prompt_edited) + + def on_page_changed_by_index(self, index: int): + """当 QTabBar 切换时调用""" + try: + routeKey = self._nav_keys[index] + self.logger.info(f"切换到页面索引 {index}, key: {routeKey}") + + # 页面切换逻辑 + if routeKey == "available": + self.stackedWidget.setCurrentIndex(0) + elif routeKey == "added": + self.stackedWidget.setCurrentIndex(1) + self.reload_config() # 先重新加载配置文件 + self.refresh_added_devices() + else: + self.logger.warning(f"未知的导航索引: {index}, key: {routeKey}") + except IndexError: + self.logger.error(f"导航索引越界: {index}") + except Exception as e: + self.logger.error(f"页面切换处理失败: {e}") + + def reload_config(self): + """重新从磁盘加载配置文件""" + try: + # 获取配置文件路径 + config_path = os.path.join(project_root, "config", "config.json") + + # 确保文件存在 + if not os.path.exists(config_path): + self.logger.warning(f"配置文件不存在: {config_path}") + return + + # 读取配置文件 + with open(config_path, "r", encoding="utf-8") as f: + config_data = json.load(f) + + # 更新内存中的设备列表 + if "HOME_ASSISTANT" in config_data and "DEVICES" in config_data["HOME_ASSISTANT"]: + self.added_devices = config_data["HOME_ASSISTANT"]["DEVICES"] + self.logger.info(f"已从配置文件重新加载 {len(self.added_devices)} 个设备") + else: + self.added_devices = [] + self.logger.warning("配置文件中未找到设备配置") + + except Exception as e: + self.logger.error(f"重新加载配置文件失败: {e}") + QMessageBox.warning(self, "警告", f"重新加载配置文件失败: {e}") + + def domain_changed(self): + """当域选择变化时调用""" + current_text = self.domain_combo.currentText() + domain = self.domain_mapping.get(current_text, "all") + self.load_devices(domain) + + def load_devices(self, domain): + """加载设备列表""" + # 清空搜索框 + self.search_input.clear() + + # 显示加载中 + self.device_table.setRowCount(0) + loading_row = self.device_table.rowCount() + self.device_table.insertRow(loading_row) + loading_item = QTableWidgetItem("正在加载设备...") + loading_item.setTextAlignment(Qt.AlignCenter) + self.device_table.setItem(loading_row, 0, loading_item) + self.device_table.setSpan(loading_row, 0, 1, 4) + + # 确保之前的线程已经停止 + if self.load_thread and self.load_thread.isRunning(): + self.logger.info("等待上一个加载线程完成...") + # 尝试先等待线程完成 + if not self.load_thread.wait(1000): # 等待最多1秒 + self.logger.warning("上一个加载线程未在1秒内完成,强制终止") + # 如果线程无法在1秒内完成,从线程列表中移除 + if self.load_thread in self.threads: + self.threads.remove(self.load_thread) + self.load_thread = None + + # 启动加载线程 + self.load_thread = DeviceLoadThread(self.ha_url, self.ha_token, domain) + self.load_thread.devices_loaded.connect(self.update_device_table) + self.load_thread.error_occurred.connect(self.show_error) + self.load_thread.start() + + # 将线程添加到线程列表 + self.threads.append(self.load_thread) + + def update_device_table(self, devices): + """更新设备表格""" + # 线程完成后从线程列表中移除 + sender = self.sender() + if sender in self.threads: + self.threads.remove(sender) + + self.current_devices = devices + self.device_table.setRowCount(0) + + if not devices: + # 显示无设备信息 + no_device_row = self.device_table.rowCount() + self.device_table.insertRow(no_device_row) + no_device_item = QTableWidgetItem("未找到设备") + no_device_item.setTextAlignment(Qt.AlignCenter) + self.device_table.setItem(no_device_row, 0, no_device_item) + self.device_table.setSpan(no_device_row, 0, 1, 4) + return + + # 填充设备表格 + for device in devices: + row = self.device_table.rowCount() + self.device_table.insertRow(row) + + # Prompt (第0列) - 设置为可编辑 + friendly_name_item = QTableWidgetItem(device["friendly_name"]) + # QTableWidgetItem 默认是可编辑的 + self.device_table.setItem(row, 0, friendly_name_item) + + # 设备ID (第1列) - 设置为不可编辑 + entity_id_item = QTableWidgetItem(device["entity_id"]) + entity_id_item.setFlags(entity_id_item.flags() & ~Qt.ItemIsEditable) # 设置为不可编辑 + self.device_table.setItem(row, 1, entity_id_item) + + # 设备类型 (第2列) - 设置为不可编辑 + domain = device["domain"] + domain_display = DOMAIN_ICONS.get(domain, domain) + domain_item = QTableWidgetItem(domain_display) + domain_item.setFlags(domain_item.flags() & ~Qt.ItemIsEditable) # 设置为不可编辑 + self.device_table.setItem(row, 2, domain_item) + + # 设备状态 (第3列) - 设置为不可编辑 + state = device["state"] + state_item = QTableWidgetItem(state) + state_item.setFlags(state_item.flags() & ~Qt.ItemIsEditable) # 设置为不可编辑 + self.device_table.setItem(row, 3, state_item) + + # 检查设备是否已添加,如果已添加则标记 + # PyQt5 中使用 QColor 设置背景色 + if any(d.get("entity_id") == device["entity_id"] for d in self.added_devices): + for col in range(4): + item = self.device_table.item(row, col) + if item: # 确保 item 存在 + item.setBackground(QColor(Qt.lightGray)) # 使用 QColor + + def refresh_devices(self): + """刷新设备列表""" + current_text = self.domain_combo.currentText() + domain = self.domain_mapping.get(current_text, "all") + self.load_devices(domain) + + def filter_devices(self): + """根据搜索框过滤设备""" + search_text = self.search_input.text().lower() + + for row in range(self.device_table.rowCount()): + show_row = True + + if search_text: + prompt = self.device_table.item(row, 0).text().lower() # Prompt现在在第0列 + entity_id = self.device_table.item(row, 1).text().lower() # 设备ID现在在第1列 + + show_row = search_text in prompt or search_text in entity_id + + self.device_table.setRowHidden(row, not show_row) + + def add_selected_device(self): + """添加选中的设备""" + # QTableWidget 获取选中行的方式不同 + selected_indexes = self.device_table.selectedIndexes() + if not selected_indexes: + QMessageBox.warning(self, "警告", "请先选择一个设备") + return + + # 由于 selectionBehavior 是 SelectRows,同一行的所有列都会被选中 + # 我们只需要获取一次行号 + row = selected_indexes[0].row() + + # 检查是否为有效行(避免选中表头或空行等) + if row < 0 or row >= self.device_table.rowCount(): + self.logger.warning(f"无效的选中行: {row}") + return + + # 检查是否为加载中或无设备提示行 + if self.device_table.item(row, 1) is None: + self.logger.warning(f"选中的行不是有效的设备行: {row}") + QMessageBox.warning(self, "警告", "请选择一个有效的设备行") + return + + entity_id = self.device_table.item(row, 1).text() # 设备ID现在在第1列 + + # 检查设备是否已添加 + if any(d.get("entity_id") == entity_id for d in self.added_devices): + QMessageBox.information(self, "提示", f"设备 {entity_id} 已添加") + return + + # 使用标准的 QLineEdit 获取文本 + friendly_name = self.custom_name_input.text().strip() or self.device_table.item(row, 0).text() # Prompt现在在第0列 + + # 添加设备到配置 + self.save_device_to_config(entity_id, friendly_name) + + # 更新UI + # self.refresh_added_devices() # refresh_added_devices 会在切换页面时调用 + # self.refresh_devices() # 刷新设备列表以更新颜色标记, load_devices 会处理 + + # 切换到已添加设备页面以查看结果 (可选) + added_tab_index = self._nav_keys.index("added") + if added_tab_index is not None: + self.nav_segment.setCurrentIndex(added_tab_index) + # on_page_changed_by_index 会被触发,从而调用 refresh_added_devices + else: # 如果找不到 'added' key,手动刷新 + self.reload_config() + self.refresh_added_devices() + + # 刷新当前(可用设备)页面的颜色标记 + self.refresh_devices() + + # 清空自定义Prompt输入框 + self.custom_name_input.clear() + + def refresh_added_devices(self): + """刷新已添加设备表格""" + # 已在on_page_changed_by_index中调用了reload_config,这里直接使用self.added_devices + + # 暂时断开单元格变化信号,避免在填充数据时触发更新 + try: + self.added_device_table.cellChanged.disconnect(self.on_prompt_edited) + except: + pass # 如果信号未连接,忽略错误 + + # 清空表格 + self.added_device_table.setRowCount(0) + + # 如果没有设备,显示提示 + if not self.added_devices: + empty_row = self.added_device_table.rowCount() + self.added_device_table.insertRow(empty_row) + empty_item = QTableWidgetItem("未添加任何设备") + empty_item.setTextAlignment(Qt.AlignCenter) + self.added_device_table.setItem(empty_row, 0, empty_item) + self.added_device_table.setSpan(empty_row, 0, 1, 3) + # 重新连接单元格编辑完成信号 + self.added_device_table.cellChanged.connect(self.on_prompt_edited) + return + + # 填充表格 + for device in self.added_devices: + row = self.added_device_table.rowCount() + self.added_device_table.insertRow(row) + + # Prompt - 设置为可编辑状态 (第0列) + friendly_name = device.get("friendly_name", "") + friendly_name_item = QTableWidgetItem(friendly_name) + # friendly_name_item是默认可编辑的 + self.added_device_table.setItem(row, 0, friendly_name_item) + + # 设备ID (第1列) + entity_id = device.get("entity_id", "") + entity_id_item = QTableWidgetItem(entity_id) + entity_id_item.setFlags(entity_id_item.flags() & ~Qt.ItemIsEditable) # 设置为不可编辑 + self.added_device_table.setItem(row, 1, entity_id_item) + + # 删除按钮 (第2列) - 使用 QPushButton + delete_button = QPushButton("删除") + delete_button.clicked.connect(lambda checked, r=row: self.delete_device(r)) + self.added_device_table.setCellWidget(row, 2, delete_button) + + # 重新连接单元格编辑完成信号 + self.added_device_table.cellChanged.connect(self.on_prompt_edited) + + def delete_device(self, row): + """删除指定行的设备""" + entity_id = self.added_device_table.item(row, 1).text() # 设备ID现在在第1列 + + # 询问确认 + reply = QMessageBox.question( + self, + "确认删除", + f"确定要删除设备 {entity_id} 吗?", + QMessageBox.Yes | QMessageBox.No, + QMessageBox.No + ) + + if reply == QMessageBox.Yes: + # 从配置中删除 + success = self.delete_device_from_config(entity_id) + + if success: + # 重新从磁盘加载配置 + self.reload_config() + + # 更新UI + self.refresh_added_devices() + self.refresh_devices() # 刷新设备列表以更新颜色标记 + + def save_device_to_config(self, entity_id: str, friendly_name: Optional[str] = None) -> bool: + """将设备添加到配置文件中""" + try: + # 获取配置文件路径 + config_path = os.path.join(project_root, "config", "config.json") + + # 读取当前配置 + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + # 确保HOME_ASSISTANT和DEVICES存在 + if "HOME_ASSISTANT" not in config: + config["HOME_ASSISTANT"] = {} + + if "DEVICES" not in config["HOME_ASSISTANT"]: + config["HOME_ASSISTANT"]["DEVICES"] = [] + + # 检查设备是否已存在 + for device in config["HOME_ASSISTANT"]["DEVICES"]: + if device.get("entity_id") == entity_id: + # 如果提供了新的friendly_name,则更新 + if friendly_name and device.get("friendly_name") != friendly_name: + device["friendly_name"] = friendly_name + + # 写入配置 + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + + QMessageBox.information( + self, + "更新成功", + f"设备 {entity_id} 的Prompt已更新为: {friendly_name}" + ) + else: + QMessageBox.information( + self, + "提示", + f"设备 {entity_id} 已存在于配置中" + ) + + return True + + # 添加新设备 + new_device = { + "entity_id": entity_id + } + + if friendly_name: + new_device["friendly_name"] = friendly_name + + config["HOME_ASSISTANT"]["DEVICES"].append(new_device) + + # 写入配置 + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + + QMessageBox.information( + self, + "添加成功", + f"成功添加设备: {entity_id}" + (f" (Prompt: {friendly_name})" if friendly_name else "") + ) + + return True + + except Exception as e: + QMessageBox.critical( + self, + "错误", + f"保存配置失败: {e}" + ) + return False + + def delete_device_from_config(self, entity_id: str) -> bool: + """从配置文件中删除设备""" + try: + # 获取配置文件路径 + config_path = os.path.join(project_root, "config", "config.json") + + # 读取当前配置 + with open(config_path, "r", encoding="utf-8") as f: + config = json.load(f) + + # 检查HOME_ASSISTANT和DEVICES是否存在 + if ("HOME_ASSISTANT" not in config or + "DEVICES" not in config["HOME_ASSISTANT"]): + QMessageBox.warning( + self, + "警告", + "配置中不存在Home Assistant设备" + ) + return False + + # 搜索并删除设备 + devices = config["HOME_ASSISTANT"]["DEVICES"] + initial_count = len(devices) + + config["HOME_ASSISTANT"]["DEVICES"] = [ + device for device in devices + if device.get("entity_id") != entity_id + ] + + if len(config["HOME_ASSISTANT"]["DEVICES"]) < initial_count: + # 写入配置 + with open(config_path, "w", encoding="utf-8") as f: + json.dump(config, f, ensure_ascii=False, indent=2) + + QMessageBox.information( + self, + "删除成功", + f"成功删除设备: {entity_id}" + ) + return True + else: + QMessageBox.warning( + self, + "警告", + f"未找到设备: {entity_id}" + ) + return False + + except Exception as e: + QMessageBox.critical( + self, + "错误", + f"删除设备失败: {e}" + ) + return False + + def show_error(self, error_message): + """显示错误消息""" + # 线程完成后从线程列表中移除 + sender = self.sender() + if sender in self.threads: + self.threads.remove(sender) + + self.device_table.setRowCount(0) + error_row = self.device_table.rowCount() + self.device_table.insertRow(error_row) + error_item = QTableWidgetItem(f"加载失败: {error_message}") + error_item.setTextAlignment(Qt.AlignCenter) + self.device_table.setItem(error_row, 0, error_item) + self.device_table.setSpan(error_row, 0, 1, 4) + + QMessageBox.critical( + self, + "错误", + f"加载设备失败: {error_message}" + ) + + def on_prompt_edited(self, row, column): + """处理已添加设备Prompt编辑完成事件""" + # 只处理Prompt列(现在是列索引为0)的编辑 + if column != 0: + return + + entity_id = self.added_device_table.item(row, 1).text() # 设备ID现在在第1列 + new_prompt = self.added_device_table.item(row, 0).text() # Prompt现在在第0列 + + # 保存编辑后的Prompt + self.save_device_to_config(entity_id, new_prompt) + + def on_available_device_prompt_edited(self, row, column): + """处理可用设备Prompt编辑完成事件""" + # 只处理Prompt列(现在是列索引为0)的编辑 + if column != 0: + return + + # 获取编辑后的Prompt + new_prompt = self.device_table.item(row, 0).text() + + if row in [index.row() for index in self.device_table.selectedIndexes()]: + self.custom_name_input.setText(new_prompt) + self.logger.info(f"已更新自定义名称输入框: {new_prompt}") + +def main(): + """主函数""" + app = QApplication(sys.argv) + + # 创建并显示主窗口 + window = HomeAssistantDeviceManager() + # 设置最小尺寸,但允许放大 + window.setMinimumSize(800, 480) + # 设置初始大小 + window.resize(800, 480) + window.show() + + sys.exit(app.exec_()) + + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/scripts/ha_manage.ui b/scripts/ha_manage.ui new file mode 100644 index 0000000000000000000000000000000000000000..b612ed6332a29446907cf01e9805ad753817108e --- /dev/null +++ b/scripts/ha_manage.ui @@ -0,0 +1,292 @@ + + + HomeAssistantDeviceManager + + + + 0 + 0 + 800 + 480 + + + + + 800 + 480 + + + + Home Assistant设备管理器 + + + + + 6 + + + 12 + + + 12 + + + 12 + + + 12 + + + + + 0 + + + 0 + + + + + + 180 + 42 + + + + + + + + + + 0 + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + + + 120 + 32 + + + + + 16777215 + 32 + + + + 选择设备类型 + + + + + + + + 0 + 32 + + + + + 16777215 + 32 + + + + 搜索设备 (名称或ID) + + + + + + + + 80 + 32 + + + + + 16777215 + 32 + + + + 刷新 + + + + + + + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + + Prompt + + + + + 设备ID + + + + + 类型 + + + + + 状态 + + + + + + + + + + + 0 + 32 + + + + + 16777215 + 32 + + + + 自定义Prompt(可选) + + + + + + + + 100 + 32 + + + + + 16777215 + 32 + + + + 添加选中设备 + + + + + + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + QAbstractItemView::SingleSelection + + + QAbstractItemView::SelectRows + + + + Prompt + + + + + 设备ID + + + + + 操作 + + + + + + + + + + + + + + + + + QTabBar + QWidget +
qtabbar.h
+
+
+ + +
diff --git a/scripts/py_audio_scanner.py b/scripts/py_audio_scanner.py new file mode 100644 index 0000000000000000000000000000000000000000..b3162d3f0015e7f45fb1152a8dda99a56d0e39db --- /dev/null +++ b/scripts/py_audio_scanner.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +# 文件名: detect_audio_devices.py + +import pyaudio +import numpy as np +import time + + +def detect_audio_devices(): + """检测并列出所有音频设备""" + p = pyaudio.PyAudio() + + print("\n===== 音频设备检测 =====\n") + + # 存储找到的设备 + input_devices = [] + output_devices = [] + + # 列出所有设备 + for i in range(p.get_device_count()): + dev_info = p.get_device_info_by_index(i) + + # 打印设备信息 + print(f"设备 {i}: {dev_info['name']}") + print(f" - 输入通道: {dev_info['maxInputChannels']}") + print(f" - 输出通道: {dev_info['maxOutputChannels']}") + print(f" - 默认采样率: {dev_info['defaultSampleRate']}") + + # 识别输入设备(麦克风) + if dev_info['maxInputChannels'] > 0: + input_devices.append((i, dev_info['name'])) + if "USB" in dev_info['name']: + print(" - 可能是USB麦克风 ?") + + # 识别输出设备(扬声器) + if dev_info['maxOutputChannels'] > 0: + output_devices.append((i, dev_info['name'])) + if "bcm2835 Headphones" in dev_info['name']: + print(" - 可能是内置耳机输出 ?") + elif "USB" in dev_info['name'] and dev_info['maxOutputChannels'] > 0: + print(" - 可能是USB扬声器 ?") + + print("") + + # 总结找到的设备 + print("\n===== 设备总结 =====\n") + + print("找到的输入设备(麦克风):") + for idx, name in input_devices: + print(f" - 设备 {idx}: {name}") + + print("\n找到的输出设备(扬声器):") + for idx, name in output_devices: + print(f" - 设备 {idx}: {name}") + + # 推荐设备 + print("\n推荐设备配置:") + + # 推荐麦克风 + recommended_mic = None + for idx, name in input_devices: + if "USB" in name: + recommended_mic = (idx, name) + break + if recommended_mic is None and input_devices: + recommended_mic = input_devices[0] + + # 推荐扬声器 + recommended_speaker = None + for idx, name in output_devices: + if "bcm2835 Headphones" in name: + recommended_speaker = (idx, name) + break + if recommended_speaker is None and output_devices: + recommended_speaker = output_devices[0] + + if recommended_mic: + print(f" - 麦克风: 设备 {recommended_mic[0]} ({recommended_mic[1]})") + else: + print(" - 未找到可用麦克风") + + if recommended_speaker: + print(f" - 扬声器: 设备 {recommended_speaker[0]} ({recommended_speaker[1]})") + else: + print(" - 未找到可用扬声器") + + print("\n===== PyAudio配置示例 =====\n") + + if recommended_mic: + print(f"# 麦克风初始化代码") + print(f"input_device_index = {recommended_mic[0]} # {recommended_mic[1]}") + print(f"input_stream = p.open(") + print(f" format=pyaudio.paInt16,") + print(f" channels=1,") + print(f" rate=16000,") + print(f" input=True,") + print(f" frames_per_buffer=1024,") + print(f" input_device_index={recommended_mic[0]})") + + if recommended_speaker: + print(f"\n# 扬声器初始化代码") + print(f"output_device_index = {recommended_speaker[0]} # {recommended_speaker[1]}") + print(f"output_stream = p.open(") + print(f" format=pyaudio.paInt16,") + print(f" channels=1,") + print(f" rate=44100,") + print(f" output=True,") + print(f" frames_per_buffer=1024,") + print(f" output_device_index={recommended_speaker[0]})") + + p.terminate() + + return recommended_mic, recommended_speaker + + +if __name__ == "__main__": + try: + mic, speaker = detect_audio_devices() + print("\n检测完成!") + except Exception as e: + print(f"检测过程中出错: {e}") \ No newline at end of file diff --git a/src/application.py b/src/application.py new file mode 100644 index 0000000000000000000000000000000000000000..b0a8764d0c3956534fba51f126fbfe3b20c6210e --- /dev/null +++ b/src/application.py @@ -0,0 +1,1315 @@ +import asyncio +import json +import logging +import threading +import time +import sys +import traceback +from pathlib import Path + +from src.utils.logging_config import get_logger +# 在导入 opuslib 之前处理 opus 动态库 +from src.utils.system_info import setup_opus +from src.constants.constants import ( + DeviceState, EventType, AudioConfig, + AbortReason, ListeningMode +) +from src.display import gui_display, cli_display +from src.utils.config_manager import ConfigManager + +setup_opus() + +# 配置日志 +logger = get_logger(__name__) + +# 现在导入 opuslib +try: + import opuslib # noqa: F401 + from src.utils.tts_utility import TtsUtility +except Exception as e: + logger.critical("导入 opuslib 失败: %s", e, exc_info=True) + logger.critical("请确保 opus 动态库已正确安装或位于正确的位置") + sys.exit(1) + +from src.protocols.mqtt_protocol import MqttProtocol +from src.protocols.websocket_protocol import WebsocketProtocol + + +class Application: + _instance = None + + @classmethod + def get_instance(cls): + """获取单例实例""" + if cls._instance is None: + logger.debug("创建Application单例实例") + cls._instance = Application() + return cls._instance + + def __init__(self): + """初始化应用程序""" + # 确保单例模式 + if Application._instance is not None: + logger.error("尝试创建Application的多个实例") + raise Exception("Application是单例类,请使用get_instance()获取实例") + Application._instance = self + + logger.debug("初始化Application实例") + # 获取配置管理器实例 + self.config = ConfigManager.get_instance() + + # 状态变量 + self.device_state = DeviceState.IDLE + self.voice_detected = False + self.keep_listening = False + self.aborted = False + self.current_text = "" + self.current_emotion = "neutral" + + # 音频处理相关 + self.audio_codec = None # 将在 _initialize_audio 中初始化 + self._tts_lock = threading.Lock() + self.is_tts_playing = False # 因为Display的播放状态只是GUI使用,不方便Music_player使用,所以加了这个标志位表示是TTS在说话 + + # 事件循环和线程 + self.loop = asyncio.new_event_loop() + self.loop_thread = None + self.running = False + self.input_event_thread = None + self.output_event_thread = None + + # 任务队列和锁 + self.main_tasks = [] + self.mutex = threading.Lock() + + # 协议实例 + self.protocol = None + + # 回调函数 + self.on_state_changed_callbacks = [] + + # 初始化事件对象 + self.events = { + EventType.SCHEDULE_EVENT: threading.Event(), + EventType.AUDIO_INPUT_READY_EVENT: threading.Event(), + EventType.AUDIO_OUTPUT_READY_EVENT: threading.Event() + } + + # 创建显示界面 + self.display = None + + # 添加唤醒词检测器 + self.wake_word_detector = None + logger.debug("Application实例初始化完成") + + def run(self, **kwargs): + """启动应用程序""" + logger.info("启动应用程序,参数: %s", kwargs) + mode = kwargs.get('mode', 'gui') + protocol = kwargs.get('protocol', 'websocket') + + # 启动主循环线程 + logger.debug("启动主循环线程") + main_loop_thread = threading.Thread(target=self._main_loop) + main_loop_thread.daemon = True + main_loop_thread.start() + + # 初始化通信协议 + logger.debug("设置协议类型: %s", protocol) + self.set_protocol_type(protocol) + + # 创建并启动事件循环线程 + logger.debug("启动事件循环线程") + self.loop_thread = threading.Thread(target=self._run_event_loop) + self.loop_thread.daemon = True + self.loop_thread.start() + + # 等待事件循环准备就绪 + time.sleep(0.1) + + # 初始化应用程序(移除自动连接) + logger.debug("初始化应用程序组件") + asyncio.run_coroutine_threadsafe( + self._initialize_without_connect(), + self.loop + ) + + # 初始化物联网设备 + self._initialize_iot_devices() + + logger.debug("设置显示类型: %s", mode) + self.set_display_type(mode) + # 启动GUI + logger.debug("启动显示界面") + self.display.start() + + def _run_event_loop(self): + """运行事件循环的线程函数""" + logger.debug("设置并启动事件循环") + asyncio.set_event_loop(self.loop) + self.loop.run_forever() + + def set_is_tts_playing(self, value: bool): + with self._tts_lock: + self.is_tts_playing = value + + def get_is_tts_playing(self) -> bool: + with self._tts_lock: + return self.is_tts_playing + + async def _initialize_without_connect(self): + """初始化应用程序组件(不建立连接)""" + logger.info("正在初始化应用程序组件...") + + # 设置设备状态为待命 + logger.debug("设置初始设备状态为IDLE") + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + + # 初始化音频编解码器 + logger.debug("初始化音频编解码器") + self._initialize_audio() + + # 初始化并启动唤醒词检测 + self._initialize_wake_word_detector() + + # 设置联网协议回调(MQTT AND WEBSOCKET) + logger.debug("设置协议回调函数") + self.protocol.on_network_error = self._on_network_error + self.protocol.on_incoming_audio = self._on_incoming_audio + self.protocol.on_incoming_json = self._on_incoming_json + self.protocol.on_audio_channel_opened = self._on_audio_channel_opened + self.protocol.on_audio_channel_closed = self._on_audio_channel_closed + + logger.info("应用程序组件初始化完成") + + def _initialize_audio(self): + """初始化音频设备和编解码器""" + try: + logger.debug("开始初始化音频编解码器") + from src.audio_codecs.audio_codec import AudioCodec + self.audio_codec = AudioCodec() + logger.info("音频编解码器初始化成功") + + # 记录音量控制状态 + has_volume_control = ( + hasattr(self.display, 'volume_controller') and + self.display.volume_controller + ) + if has_volume_control: + logger.info("系统音量控制已启用") + else: + logger.info("系统音量控制未启用,将使用模拟音量控制") + + except Exception as e: + logger.error("初始化音频设备失败: %s", e, exc_info=True) + self.alert("错误", f"初始化音频设备失败: {e}") + + def set_protocol_type(self, protocol_type: str): + """设置协议类型""" + logger.debug("设置协议类型: %s", protocol_type) + if protocol_type == 'mqtt': + self.protocol = MqttProtocol(self.loop) + logger.debug("已创建MQTT协议实例") + else: # websocket + self.protocol = WebsocketProtocol() + logger.debug("已创建WebSocket协议实例") + + def set_display_type(self, mode: str): + """初始化显示界面""" + logger.debug("设置显示界面类型: %s", mode) + # 通过适配器的概念管理不同的显示模式 + if mode == 'gui': + self.display = gui_display.GuiDisplay() + logger.debug("已创建GUI显示界面") + self.display.set_callbacks( + press_callback=self.start_listening, + release_callback=self.stop_listening, + status_callback=self._get_status_text, + text_callback=self._get_current_text, + emotion_callback=self._get_current_emotion, + mode_callback=self._on_mode_changed, + auto_callback=self.toggle_chat_state, + abort_callback=lambda: self.abort_speaking( + AbortReason.WAKE_WORD_DETECTED + ), + send_text_callback=self._send_text_tts + ) + else: + self.display = cli_display.CliDisplay() + logger.debug("已创建CLI显示界面") + self.display.set_callbacks( + auto_callback=self.toggle_chat_state, + abort_callback=lambda: self.abort_speaking( + AbortReason.WAKE_WORD_DETECTED + ), + status_callback=self._get_status_text, + text_callback=self._get_current_text, + emotion_callback=self._get_current_emotion, + send_text_callback=self._send_text_tts + ) + logger.debug("显示界面回调函数设置完成") + + def _main_loop(self): + """应用程序主循环""" + logger.info("主循环已启动") + self.running = True + + while self.running: + # 等待事件 + for event_type, event in self.events.items(): + if event.is_set(): + event.clear() + logger.debug("处理事件: %s", event_type) + + if event_type == EventType.AUDIO_INPUT_READY_EVENT: + self._handle_input_audio() + elif event_type == EventType.AUDIO_OUTPUT_READY_EVENT: + self._handle_output_audio() + elif event_type == EventType.SCHEDULE_EVENT: + self._process_scheduled_tasks() + + # 短暂休眠以避免CPU占用过高 + time.sleep(0.01) + + def _process_scheduled_tasks(self): + """处理调度任务""" + with self.mutex: + tasks = self.main_tasks.copy() + self.main_tasks.clear() + + logger.debug("处理%d个调度任务", len(tasks)) + for task in tasks: + try: + task() + except Exception as e: + logger.error("执行调度任务时出错: %s", e, exc_info=True) + + def schedule(self, callback): + """调度任务到主循环""" + with self.mutex: + self.main_tasks.append(callback) + self.events[EventType.SCHEDULE_EVENT].set() + + def _handle_input_audio(self): + """处理音频输入""" + if self.device_state != DeviceState.LISTENING: + return + + # 读取并发送音频数据 + encoded_data = self.audio_codec.read_audio() + if (encoded_data and self.protocol and + self.protocol.is_audio_channel_opened()): + asyncio.run_coroutine_threadsafe( + self.protocol.send_audio(encoded_data), + self.loop + ) + + async def _send_text_tts(self, text): + """将文本转换为语音并发送""" + try: + tts_utility = TtsUtility(AudioConfig) + + # 生成 Opus 音频数据包 + opus_frames = await tts_utility.text_to_opus_audio(text) + + # 尝试打开音频通道 + if (not self.protocol.is_audio_channel_opened() and + DeviceState.IDLE == self.device_state): + # 打开音频通道 + success = await self.protocol.open_audio_channel() + if not success: + logger.error("打开音频通道失败") + return + + # 确认 opus 帧生成成功 + if opus_frames: + logger.info(f"生成了 {len(opus_frames)} 个 Opus 音频帧") + + # 设置状态为说话中 + self.schedule(lambda: self.set_device_state(DeviceState.SPEAKING)) + + # 发送音频数据 + for i, frame in enumerate(opus_frames): + await self.protocol.send_audio(frame) + await asyncio.sleep(0.06) + + # 设置聊天消息 + self.set_chat_message("user", text) + await self.protocol.send_text( + json.dumps({"session_id": "", "type": "listen", "state": "stop"})) + await self.protocol.send_text(b'') + + return True + else: + logger.error("生成音频失败") + return False + + except Exception as e: + logger.error(f"发送文本到TTS时出错: {e}") + logger.error(traceback.format_exc()) + return False + + def _handle_output_audio(self): + """处理音频输出""" + if self.device_state != DeviceState.SPEAKING: + return + self.set_is_tts_playing(True) # 开始播放 + self.audio_codec.play_audio() + + def _on_network_error(self, error_message=None): + """网络错误回调""" + if error_message: + logger.error(f"网络错误: {error_message}") + + self.keep_listening = False + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + # 恢复唤醒词检测 + if self.wake_word_detector and self.wake_word_detector.paused: + self.wake_word_detector.resume() + + if self.device_state != DeviceState.CONNECTING: + logger.info("检测到连接断开") + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + + # 关闭现有连接,但不关闭音频流 + if self.protocol: + asyncio.run_coroutine_threadsafe( + self.protocol.close_audio_channel(), + self.loop + ) + + def _on_incoming_audio(self, data): + """接收音频数据回调""" + if self.device_state == DeviceState.SPEAKING: + self.audio_codec.write_audio(data) + self.events[EventType.AUDIO_OUTPUT_READY_EVENT].set() + + def _on_incoming_json(self, json_data): + """接收JSON数据回调""" + try: + if not json_data: + return + + # 解析JSON数据 + if isinstance(json_data, str): + data = json.loads(json_data) + else: + data = json_data + # 处理不同类型的消息 + msg_type = data.get("type", "") + if msg_type == "tts": + self._handle_tts_message(data) + elif msg_type == "stt": + self._handle_stt_message(data) + elif msg_type == "llm": + self._handle_llm_message(data) + elif msg_type == "iot": + self._handle_iot_message(data) + else: + logger.warning(f"收到未知类型的消息: {msg_type}") + except Exception as e: + logger.error(f"处理JSON消息时出错: {e}") + + def _handle_tts_message(self, data): + """处理TTS消息""" + state = data.get("state", "") + if state == "start": + self.schedule(lambda: self._handle_tts_start()) + elif state == "stop": + self.schedule(lambda: self._handle_tts_stop()) + elif state == "sentence_start": + text = data.get("text", "") + if text: + logger.info(f"<< {text}") + self.schedule(lambda: self.set_chat_message("assistant", text)) + + # 检查是否包含验证码信息 + if "请登录到控制面板添加设备,输入验证码" in text: + self.schedule(lambda: self._handle_verification_code(text)) + + def _handle_tts_start(self): + """处理TTS开始事件""" + self.aborted = False + self.set_is_tts_playing(True) # 开始播放 + # 清空可能存在的旧音频数据 + self.audio_codec.clear_audio_queue() + + if self.device_state == DeviceState.IDLE or self.device_state == DeviceState.LISTENING: + self.schedule(lambda: self.set_device_state(DeviceState.SPEAKING)) + + # 注释掉恢复VAD检测器的代码 + # if hasattr(self, 'vad_detector') and self.vad_detector: + # self.vad_detector.resume() + + def _handle_tts_stop(self): + """处理TTS停止事件""" + if self.device_state == DeviceState.SPEAKING: + # 给音频播放一个缓冲时间,确保所有音频都播放完毕 + def delayed_state_change(): + # 等待音频队列清空 + # 增加等待重试次数,确保音频可以完全播放完毕 + max_wait_attempts = 30 # 增加等待尝试次数 + wait_interval = 0.1 # 每次等待的时间间隔 + attempts = 0 + + # 等待直到队列为空或超过最大尝试次数 + while (not self.audio_codec.audio_decode_queue.empty() and + attempts < max_wait_attempts): + time.sleep(wait_interval) + attempts += 1 + + # 确保所有数据都被播放出来 + # 再额外等待一点时间确保最后的数据被处理 + if self.get_is_tts_playing(): + time.sleep(0.5) + + # 设置TTS播放状态为False + self.set_is_tts_playing(False) + + # 状态转换 + if self.keep_listening: + asyncio.run_coroutine_threadsafe( + self.protocol.send_start_listening(ListeningMode.AUTO_STOP), + self.loop + ) + self.schedule(lambda: self.set_device_state(DeviceState.LISTENING)) + else: + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + + # 安排延迟执行 + # threading.Thread(target=delayed_state_change, daemon=True).start() + self.schedule(delayed_state_change) + + def _handle_stt_message(self, data): + """处理STT消息""" + text = data.get("text", "") + if text: + logger.info(f">> {text}") + self.schedule(lambda: self.set_chat_message("user", text)) + + def _handle_llm_message(self, data): + """处理LLM消息""" + emotion = data.get("emotion", "") + if emotion: + self.schedule(lambda: self.set_emotion(emotion)) + + async def _on_audio_channel_opened(self): + """音频通道打开回调""" + logger.info("音频通道已打开") + self.schedule(lambda: self._start_audio_streams()) + + # 发送物联网设备描述符 + from src.iot.thing_manager import ThingManager + thing_manager = ThingManager.get_instance() + asyncio.run_coroutine_threadsafe( + self.protocol.send_iot_descriptors(thing_manager.get_descriptors_json()), + self.loop + ) + self._update_iot_states(False) + + + def _start_audio_streams(self): + """启动音频流""" + try: + # 不再关闭和重新打开流,只确保它们处于活跃状态 + if self.audio_codec.input_stream and not self.audio_codec.input_stream.is_active(): + try: + self.audio_codec.input_stream.start_stream() + except Exception as e: + logger.warning(f"启动输入流时出错: {e}") + # 只有在出错时才重新初始化 + self.audio_codec._reinitialize_input_stream() + + if self.audio_codec.output_stream and not self.audio_codec.output_stream.is_active(): + try: + self.audio_codec.output_stream.start_stream() + except Exception as e: + logger.warning(f"启动输出流时出错: {e}") + # 只有在出错时才重新初始化 + self.audio_codec._reinitialize_output_stream() + + # 设置事件触发器 + if self.input_event_thread is None or not self.input_event_thread.is_alive(): + self.input_event_thread = threading.Thread( + target=self._audio_input_event_trigger, daemon=True) + self.input_event_thread.start() + logger.info("已启动输入事件触发线程") + + # 检查输出事件线程 + if self.output_event_thread is None or not self.output_event_thread.is_alive(): + self.output_event_thread = threading.Thread( + target=self._audio_output_event_trigger, daemon=True) + self.output_event_thread.start() + logger.info("已启动输出事件触发线程") + + logger.info("音频流已启动") + except Exception as e: + logger.error(f"启动音频流失败: {e}") + + def _audio_input_event_trigger(self): + """音频输入事件触发器""" + while self.running: + try: + # 只有在主动监听状态下才触发输入事件 + if self.device_state == DeviceState.LISTENING and self.audio_codec.input_stream: + self.events[EventType.AUDIO_INPUT_READY_EVENT].set() + except OSError as e: + logger.error(f"音频输入流错误: {e}") + # 不要退出循环,继续尝试 + time.sleep(0.5) + except Exception as e: + logger.error(f"音频输入事件触发器错误: {e}") + time.sleep(0.5) + + # 确保触发频率足够高,即使帧长度较大 + # 使用20ms作为最大触发间隔,确保即使帧长度为60ms也能有足够的采样率 + sleep_time = min(20, AudioConfig.FRAME_DURATION) / 1000 + time.sleep(sleep_time) # 按帧时长触发,但确保最小触发频率 + + def _audio_output_event_trigger(self): + """音频输出事件触发器""" + while self.running: + try: + # 确保输出流是活跃的 + if (self.device_state == DeviceState.SPEAKING and + self.audio_codec and + self.audio_codec.output_stream): + + # 如果输出流不活跃,尝试重新激活 + if not self.audio_codec.output_stream.is_active(): + try: + self.audio_codec.output_stream.start_stream() + except Exception as e: + logger.warning(f"启动输出流失败,尝试重新初始化: {e}") + self.audio_codec._reinitialize_output_stream() + + # 当队列中有数据时才触发事件 + if not self.audio_codec.audio_decode_queue.empty(): + self.events[EventType.AUDIO_OUTPUT_READY_EVENT].set() + except Exception as e: + logger.error(f"音频输出事件触发器错误: {e}") + + time.sleep(0.02) # 稍微延长检查间隔 + + async def _on_audio_channel_closed(self): + """音频通道关闭回调""" + logger.info("音频通道已关闭") + # 设置为空闲状态但不关闭音频流 + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + self.keep_listening = False + + # 确保唤醒词检测正常工作 + if self.wake_word_detector: + if not self.wake_word_detector.is_running(): + logger.info("在空闲状态下启动唤醒词检测") + # 直接使用AudioCodec实例,而不是尝试获取共享流 + if hasattr(self, 'audio_codec') and self.audio_codec: + self.wake_word_detector.start(self.audio_codec) + else: + self.wake_word_detector.start() + elif self.wake_word_detector.paused: + logger.info("在空闲状态下恢复唤醒词检测") + self.wake_word_detector.resume() + + def set_device_state(self, state): + """设置设备状态""" + if self.device_state == state: + return + + self.device_state = state + + # 根据状态执行相应操作 + if state == DeviceState.IDLE: + self.display.update_status("待命") + # self.display.update_emotion("😶") + self.set_emotion("neutral") + # 恢复唤醒词检测(添加安全检查) + if self.wake_word_detector and hasattr(self.wake_word_detector, 'paused') and self.wake_word_detector.paused: + self.wake_word_detector.resume() + logger.info("唤醒词检测已恢复") + # 恢复音频输入流 + if self.audio_codec and self.audio_codec.is_input_paused(): + self.audio_codec.resume_input() + elif state == DeviceState.CONNECTING: + self.display.update_status("连接中...") + elif state == DeviceState.LISTENING: + self.display.update_status("聆听中...") + self.set_emotion("neutral") + self._update_iot_states(True) + # 暂停唤醒词检测(添加安全检查) + if self.wake_word_detector and hasattr(self.wake_word_detector, 'is_running') and self.wake_word_detector.is_running(): + self.wake_word_detector.pause() + logger.info("唤醒词检测已暂停") + # 确保音频输入流活跃 + if self.audio_codec: + if self.audio_codec.is_input_paused(): + self.audio_codec.resume_input() + elif state == DeviceState.SPEAKING: + self.display.update_status("说话中...") + if self.wake_word_detector and hasattr(self.wake_word_detector, 'paused') and self.wake_word_detector.paused: + self.wake_word_detector.resume() + # 暂停唤醒词检测(添加安全检查) + # if self.wake_word_detector and hasattr(self.wake_word_detector, 'is_running') and self.wake_word_detector.is_running(): + # self.wake_word_detector.pause() + # logger.info("唤醒词检测已暂停") + # 暂停音频输入流以避免自我监听 + # if self.audio_codec and not self.audio_codec.is_input_paused(): + # self.audio_codec.pause_input() + + # 通知状态变化 + for callback in self.on_state_changed_callbacks: + try: + callback(state) + except Exception as e: + logger.error(f"执行状态变化回调时出错: {e}") + + def _get_status_text(self): + """获取当前状态文本""" + states = { + DeviceState.IDLE: "待命", + DeviceState.CONNECTING: "连接中...", + DeviceState.LISTENING: "聆听中...", + DeviceState.SPEAKING: "说话中..." + } + return states.get(self.device_state, "未知") + + def _get_current_text(self): + """获取当前显示文本""" + return self.current_text + + def _get_current_emotion(self): + """获取当前表情""" + # 如果表情没有变化,直接返回缓存的路径 + if hasattr(self, '_last_emotion') and self._last_emotion == self.current_emotion: + return self._last_emotion_path + + # 获取基础路径 + if getattr(sys, 'frozen', False): + # 打包环境 + if hasattr(sys, '_MEIPASS'): + base_path = Path(sys._MEIPASS) + else: + base_path = Path(sys.executable).parent + else: + # 开发环境 + base_path = Path(__file__).parent.parent + + emotion_dir = base_path / "assets" / "emojis" + + emotions = { + "neutral": str(emotion_dir / "neutral.gif"), + "happy": str(emotion_dir / "happy.gif"), + "laughing": str(emotion_dir / "laughing.gif"), + "funny": str(emotion_dir / "funny.gif"), + "sad": str(emotion_dir / "sad.gif"), + "angry": str(emotion_dir / "angry.gif"), + "crying": str(emotion_dir / "crying.gif"), + "loving": str(emotion_dir / "loving.gif"), + "embarrassed": str(emotion_dir / "embarrassed.gif"), + "surprised": str(emotion_dir / "surprised.gif"), + "shocked": str(emotion_dir / "shocked.gif"), + "thinking": str(emotion_dir / "thinking.gif"), + "winking": str(emotion_dir / "winking.gif"), + "cool": str(emotion_dir / "cool.gif"), + "relaxed": str(emotion_dir / "relaxed.gif"), + "delicious": str(emotion_dir / "delicious.gif"), + "kissy": str(emotion_dir / "kissy.gif"), + "confident": str(emotion_dir / "confident.gif"), + "sleepy": str(emotion_dir / "sleepy.gif"), + "silly": str(emotion_dir / "silly.gif"), + "confused": str(emotion_dir / "confused.gif") + } + + # 保存当前表情和对应的路径 + self._last_emotion = self.current_emotion + self._last_emotion_path = emotions.get(self.current_emotion, str(emotion_dir / "neutral.gif")) + + logger.debug(f"表情路径: {self._last_emotion_path}") + return self._last_emotion_path + + def set_chat_message(self, role, message): + """设置聊天消息""" + self.current_text = message + # 更新显示 + if self.display: + self.display.update_text(message) + + def set_emotion(self, emotion): + """设置表情""" + self.current_emotion = emotion + # 更新显示 + if self.display: + self.display.update_emotion(self._get_current_emotion()) + + def start_listening(self): + """开始监听""" + self.schedule(self._start_listening_impl) + + def _start_listening_impl(self): + """开始监听的实现""" + if not self.protocol: + logger.error("协议未初始化") + return + + self.keep_listening = False + + # 检查唤醒词检测器是否存在 + if self.wake_word_detector: + self.wake_word_detector.pause() + + if self.device_state == DeviceState.IDLE: + self.schedule(lambda: self.set_device_state(DeviceState.CONNECTING)) # 设置设备状态为连接中 + # 尝试打开音频通道 + if not self.protocol.is_audio_channel_opened(): + try: + # 等待异步操作完成 + future = asyncio.run_coroutine_threadsafe( + self.protocol.open_audio_channel(), + self.loop + ) + # 等待操作完成并获取结果 + success = future.result(timeout=10.0) # 添加超时时间 + + if not success: + self.alert("错误", "打开音频通道失败") # 弹出错误提示 + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + return + + except Exception as e: + logger.error(f"打开音频通道时发生错误: {e}") + self.alert("错误", f"打开音频通道失败: {str(e)}") + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + return + + # --- 强制重新初始化输入流 --- + try: + if self.audio_codec: + self.audio_codec._reinitialize_input_stream() # 调用重新初始化 + else: + logger.warning("Cannot force reinitialization, audio_codec is None.") + except Exception as force_reinit_e: + logger.error(f"Forced reinitialization failed: {force_reinit_e}", exc_info=True) + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + if self.wake_word_detector and self.wake_word_detector.paused: + self.wake_word_detector.resume() + return + # --- 强制重新初始化结束 --- + + asyncio.run_coroutine_threadsafe( + self.protocol.send_start_listening(ListeningMode.MANUAL), + self.loop + ) + self.schedule(lambda: self.set_device_state(DeviceState.LISTENING)) + elif self.device_state == DeviceState.SPEAKING: + if not self.aborted: + self.abort_speaking(AbortReason.WAKE_WORD_DETECTED) + + async def _open_audio_channel_and_start_manual_listening(self): + """打开音频通道并开始手动监听""" + if not await self.protocol.open_audio_channel(): + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + self.alert("错误", "打开音频通道失败") + return + + await self.protocol.send_start_listening(ListeningMode.MANUAL) + self.schedule(lambda: self.set_device_state(DeviceState.LISTENING)) + + def toggle_chat_state(self): + """切换聊天状态""" + # 检查唤醒词检测器是否存在 + if self.wake_word_detector: + self.wake_word_detector.pause() + self.schedule(self._toggle_chat_state_impl) + + def _toggle_chat_state_impl(self): + """切换聊天状态的具体实现""" + # 检查协议是否已初始化 + if not self.protocol: + logger.error("协议未初始化") + return + + # 如果设备当前处于空闲状态,尝试连接并开始监听 + if self.device_state == DeviceState.IDLE: + self.schedule(lambda: self.set_device_state(DeviceState.CONNECTING)) # 设置设备状态为连接中 + # 使用线程来处理连接操作,避免阻塞 + def connect_and_listen(): + # 尝试打开音频通道 + if not self.protocol.is_audio_channel_opened(): + try: + # 等待异步操作完成 + future = asyncio.run_coroutine_threadsafe( + self.protocol.open_audio_channel(), + self.loop + ) + # 等待操作完成并获取结果,使用较短的超时时间 + try: + success = future.result(timeout=5.0) + except asyncio.TimeoutError: + logger.error("打开音频通道超时") + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + self.alert("错误", "打开音频通道超时") + return + except Exception as e: + logger.error(f"打开音频通道时发生未知错误: {e}") + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + self.alert("错误", f"打开音频通道失败: {str(e)}") + return + + if not success: + self.alert("错误", "打开音频通道失败") # 弹出错误提示 + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + return + + except Exception as e: + logger.error(f"打开音频通道时发生错误: {e}") + self.alert("错误", f"打开音频通道失败: {str(e)}") + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + return + + self.keep_listening = True # 开始监听 + # 启动自动停止的监听模式 + try: + asyncio.run_coroutine_threadsafe( + self.protocol.send_start_listening(ListeningMode.AUTO_STOP), + self.loop + ) + self.schedule(lambda: self.set_device_state(DeviceState.LISTENING)) + except Exception as e: + logger.error(f"启动监听时发生错误: {e}") + self.set_device_state(DeviceState.IDLE) + self.alert("错误", f"启动监听失败: {str(e)}") + + # 启动连接线程 + threading.Thread(target=connect_and_listen, daemon=True).start() + + # 如果设备正在说话,停止当前说话 + elif self.device_state == DeviceState.SPEAKING: + self.abort_speaking(AbortReason.NONE) # 中止说话 + + # 如果设备正在监听,关闭音频通道 + elif self.device_state == DeviceState.LISTENING: + # 使用线程处理关闭操作,避免阻塞 + def close_audio_channel(): + try: + future = asyncio.run_coroutine_threadsafe( + self.protocol.close_audio_channel(), + self.loop + ) + future.result(timeout=3.0) # 使用较短的超时 + except Exception as e: + logger.error(f"关闭音频通道时发生错误: {e}") + + threading.Thread(target=close_audio_channel, daemon=True).start() + # 立即设置为空闲状态,不等待关闭完成 + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + + def stop_listening(self): + """停止监听""" + self.schedule(self._stop_listening_impl) + + def _stop_listening_impl(self): + """停止监听的实现""" + if self.device_state == DeviceState.LISTENING: + asyncio.run_coroutine_threadsafe( + self.protocol.send_stop_listening(), + self.loop + ) + self.set_device_state(DeviceState.IDLE) + + def abort_speaking(self, reason): + """中止语音输出""" + # 如果已经中止,不要重复处理 + if self.aborted: + logger.debug(f"已经中止,忽略重复的中止请求: {reason}") + return + + logger.info(f"中止语音输出,原因: {reason}") + self.aborted = True + + # 设置TTS播放状态为False + self.set_is_tts_playing(False) + + # 立即清空音频队列 + if self.audio_codec: + self.audio_codec.clear_audio_queue() + + # 注释掉确保VAD检测器暂停的代码 + # if hasattr(self, 'vad_detector') and self.vad_detector: + # self.vad_detector.pause() + + # 使用线程来处理状态变更和异步操作,避免阻塞主线程 + def process_abort(): + # 先发送中止指令 + try: + future = asyncio.run_coroutine_threadsafe( + self.protocol.send_abort_speaking(reason), + self.loop + ) + # 使用较短的超时确保不会长时间阻塞 + future.result(timeout=1.0) + except Exception as e: + logger.error(f"发送中止指令时出错: {e}") + + # 然后设置状态 + # self.set_device_state(DeviceState.IDLE) + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + # 如果是唤醒词触发的中止,并且启用了自动聆听,则自动进入录音模式 + if (reason == AbortReason.WAKE_WORD_DETECTED and + self.keep_listening and + self.protocol.is_audio_channel_opened()): + # 短暂延迟确保abort命令被处理 + time.sleep(0.1) # 缩短延迟时间 + self.schedule(lambda: self.toggle_chat_state()) + + # 启动处理线程 + threading.Thread(target=process_abort, daemon=True).start() + + def alert(self, title, message): + """显示警告信息""" + logger.warning(f"警告: {title}, {message}") + # 在GUI上显示警告 + if self.display: + self.display.update_text(f"{title}: {message}") + + def on_state_changed(self, callback): + """注册状态变化回调""" + self.on_state_changed_callbacks.append(callback) + + def shutdown(self): + """关闭应用程序""" + logger.info("正在关闭应用程序...") + self.running = False + + # 关闭音频编解码器 + if self.audio_codec: + self.audio_codec.close() + + # 关闭协议 + if self.protocol: + asyncio.run_coroutine_threadsafe( + self.protocol.close_audio_channel(), + self.loop + ) + + # 停止事件循环 + if self.loop and self.loop.is_running(): + self.loop.call_soon_threadsafe(self.loop.stop) + + # 等待事件循环线程结束 + if self.loop_thread and self.loop_thread.is_alive(): + self.loop_thread.join(timeout=1.0) + + # 停止唤醒词检测 + if self.wake_word_detector: + self.wake_word_detector.stop() + + # 关闭VAD检测器 + # if hasattr(self, 'vad_detector') and self.vad_detector: + # self.vad_detector.stop() + + logger.info("应用程序已关闭") + + def _handle_verification_code(self, text): + """处理验证码信息""" + try: + # 提取验证码 + import re + verification_code = re.search(r'验证码:(\d+)', text) + if verification_code: + code = verification_code.group(1) + + # 尝试复制到剪贴板 + try: + import pyperclip + pyperclip.copy(code) + logger.info(f"验证码 {code} 已复制到剪贴板") + except Exception as e: + logger.warning(f"无法复制验证码到剪贴板: {e}") + + # 尝试打开浏览器 + try: + import webbrowser + if webbrowser.open("https://xiaozhi.me/login"): + logger.info("已打开登录页面") + else: + logger.warning("无法打开浏览器") + except Exception as e: + logger.warning(f"打开浏览器时出错: {e}") + + # 无论如何都显示验证码 + self.alert("验证码", f"您的验证码是: {code}") + + except Exception as e: + logger.error(f"处理验证码时出错: {e}") + + def _on_mode_changed(self, auto_mode): + """处理对话模式变更""" + # 只有在IDLE状态下才允许切换模式 + if self.device_state != DeviceState.IDLE: + self.alert("提示", "只有在待命状态下才能切换对话模式") + return False + + self.keep_listening = auto_mode + logger.info(f"对话模式已切换为: {'自动' if auto_mode else '手动'}") + return True + + def _initialize_wake_word_detector(self): + """初始化唤醒词检测器""" + # 首先检查配置中是否启用了唤醒词功能 + if not self.config.get_config('WAKE_WORD_OPTIONS.USE_WAKE_WORD', False): + logger.info("唤醒词功能已在配置中禁用,跳过初始化") + self.wake_word_detector = None + return + + try: + from src.audio_processing.wake_word_detect import WakeWordDetector + + # 创建检测器实例 + self.wake_word_detector = WakeWordDetector() + + # 如果唤醒词检测器被禁用(内部故障),则更新配置 + if not getattr(self.wake_word_detector, 'enabled', True): + logger.warning("唤醒词检测器被禁用(内部故障)") + self.config.update_config("WAKE_WORD_OPTIONS.USE_WAKE_WORD", False) + self.wake_word_detector = None + return + + # 注册唤醒词检测回调和错误处理 + self.wake_word_detector.on_detected(self._on_wake_word_detected) + + # 使用lambda捕获self,而不是单独定义函数 + self.wake_word_detector.on_error = lambda error: ( + self._handle_wake_word_error(error) + ) + + logger.info("唤醒词检测器初始化成功") + + # 启动唤醒词检测器 + self._start_wake_word_detector() + + except Exception as e: + logger.error(f"初始化唤醒词检测器失败: {e}") + import traceback + logger.error(traceback.format_exc()) + + # 禁用唤醒词功能,但不影响程序其他功能 + self.config.update_config("WAKE_WORD_OPTIONS.USE_WAKE_WORD", False) + logger.info("由于初始化失败,唤醒词功能已禁用,但程序将继续运行") + self.wake_word_detector = None + + def _handle_wake_word_error(self, error): + """处理唤醒词检测器错误""" + logger.error(f"唤醒词检测错误: {error}") + # 尝试重新启动检测器 + if self.device_state == DeviceState.IDLE: + self.schedule(lambda: self._restart_wake_word_detector()) + + def _start_wake_word_detector(self): + """启动唤醒词检测器""" + if not self.wake_word_detector: + return + + # 确保音频编解码器已初始化 + if hasattr(self, 'audio_codec') and self.audio_codec: + logger.info("使用音频编解码器启动唤醒词检测器") + self.wake_word_detector.start(self.audio_codec) + else: + # 如果没有音频编解码器,使用独立模式 + logger.info("使用独立模式启动唤醒词检测器") + self.wake_word_detector.start() + + def _on_wake_word_detected(self, wake_word, full_text): + """唤醒词检测回调""" + logger.info(f"检测到唤醒词: {wake_word} (完整文本: {full_text})") + self.schedule(lambda: self._handle_wake_word_detected(wake_word)) + + def _handle_wake_word_detected(self, wake_word): + """处理唤醒词检测事件""" + if self.device_state == DeviceState.IDLE: + # 暂停唤醒词检测 + if self.wake_word_detector: + self.wake_word_detector.pause() + + # 开始连接并监听 + self.schedule(lambda: self.set_device_state(DeviceState.CONNECTING)) + # 尝试连接并打开音频通道 + asyncio.run_coroutine_threadsafe( + self._connect_and_start_listening(wake_word), + self.loop + ) + elif self.device_state == DeviceState.SPEAKING: + self.abort_speaking(AbortReason.WAKE_WORD_DETECTED) + + async def _connect_and_start_listening(self, wake_word): + """连接服务器并开始监听""" + # 首先尝试连接服务器 + if not await self.protocol.connect(): + logger.error("连接服务器失败") + self.alert("错误", "连接服务器失败") + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + # 恢复唤醒词检测 + if self.wake_word_detector: + self.wake_word_detector.resume() + return + + # 然后尝试打开音频通道 + if not await self.protocol.open_audio_channel(): + logger.error("打开音频通道失败") + self.schedule(lambda: self.set_device_state(DeviceState.IDLE)) + self.alert("错误", "打开音频通道失败") + # 恢复唤醒词检测 + if self.wake_word_detector: + self.wake_word_detector.resume() + return + + await self.protocol.send_wake_word_detected(wake_word) + # 设置为自动监听模式 + self.keep_listening = True + await self.protocol.send_start_listening(ListeningMode.AUTO_STOP) + self.schedule(lambda: self.set_device_state(DeviceState.LISTENING)) + + def _restart_wake_word_detector(self): + """重新启动唤醒词检测器""" + logger.info("尝试重新启动唤醒词检测器") + try: + # 停止现有的检测器 + if self.wake_word_detector: + self.wake_word_detector.stop() + time.sleep(0.5) # 给予一些时间让资源释放 + + # 直接使用音频编解码器 + if hasattr(self, 'audio_codec') and self.audio_codec: + self.wake_word_detector.start(self.audio_codec) + logger.info("使用音频编解码器重新启动唤醒词检测器") + else: + # 如果没有音频编解码器,使用独立模式 + self.wake_word_detector.start() + logger.info("使用独立模式重新启动唤醒词检测器") + + logger.info("唤醒词检测器重新启动成功") + except Exception as e: + logger.error(f"重新启动唤醒词检测器失败: {e}") + + def _initialize_iot_devices(self): + """初始化物联网设备""" + from src.iot.thing_manager import ThingManager + from src.iot.things.lamp import Lamp + from src.iot.things.speaker import Speaker + from src.iot.things.music_player import MusicPlayer + from src.iot.things.CameraVL.Camera import Camera + from src.iot.things.query_bridge_rag import QueryBridgeRAG + from src.iot.things.temperature_sensor import TemperatureSensor + # 导入Home Assistant设备控制类 + from src.iot.things.ha_control import HomeAssistantLight, HomeAssistantSwitch, HomeAssistantNumber, HomeAssistantButton + # 导入新的倒计时器设备 + from src.iot.things.countdown_timer import CountdownTimer + + # 获取物联网设备管理器实例 + thing_manager = ThingManager.get_instance() + + # 添加设备 + thing_manager.add_thing(Lamp()) + thing_manager.add_thing(Speaker()) + thing_manager.add_thing(MusicPlayer()) + # 默认不启用以下示例 + thing_manager.add_thing(Camera()) + # thing_manager.add_thing(QueryBridgeRAG()) + # thing_manager.add_thing(TemperatureSensor()) + + # 添加倒计时器设备 + thing_manager.add_thing(CountdownTimer()) + logger.info("已添加倒计时器设备,用于计时执行命令用") + + # 添加Home Assistant设备 + ha_devices = self.config.get_config("HOME_ASSISTANT.DEVICES", []) + for device in ha_devices: + entity_id = device.get("entity_id") + friendly_name = device.get("friendly_name") + if entity_id: + # 根据实体ID判断设备类型 + if entity_id.startswith("light."): + # 灯设备 + thing_manager.add_thing(HomeAssistantLight(entity_id, friendly_name)) + logger.info(f"已添加Home Assistant灯设备: {friendly_name or entity_id}") + elif entity_id.startswith("switch."): + # 开关设备 + thing_manager.add_thing(HomeAssistantSwitch(entity_id, friendly_name)) + logger.info(f"已添加Home Assistant开关设备: {friendly_name or entity_id}") + elif entity_id.startswith("number."): + # 数值设备(如音量控制) + thing_manager.add_thing(HomeAssistantNumber(entity_id, friendly_name)) + logger.info(f"已添加Home Assistant数值设备: {friendly_name or entity_id}") + elif entity_id.startswith("button."): + # 按钮设备 + thing_manager.add_thing(HomeAssistantButton(entity_id, friendly_name)) + logger.info(f"已添加Home Assistant按钮设备: {friendly_name or entity_id}") + else: + # 默认作为灯设备处理 + thing_manager.add_thing(HomeAssistantLight(entity_id, friendly_name)) + logger.info(f"已添加Home Assistant设备(默认作为灯处理): {friendly_name or entity_id}") + + logger.info("物联网设备初始化完成") + + def _handle_iot_message(self, data): + """处理物联网消息""" + from src.iot.thing_manager import ThingManager + thing_manager = ThingManager.get_instance() + + commands = data.get("commands", []) + for command in commands: + try: + result = thing_manager.invoke(command) + logger.info(f"执行物联网命令结果: {result}") + # self.schedule(lambda: self._update_iot_states()) + except Exception as e: + logger.error(f"执行物联网命令失败: {e}") + + def _update_iot_states(self, delta=None): + """ + 更新物联网设备状态 + + Args: + delta: 是否只发送变化的部分 + - None: 使用原始行为,总是发送所有状态 + - True: 只发送变化的部分 + - False: 发送所有状态并重置缓存 + """ + from src.iot.thing_manager import ThingManager + thing_manager = ThingManager.get_instance() + + # 处理向下兼容 + if delta is None: + # 保持原有行为:获取所有状态并发送 + states_json = thing_manager.get_states_json_str() # 调用旧方法 + + # 发送状态更新 + asyncio.run_coroutine_threadsafe( + self.protocol.send_iot_states(states_json), + self.loop + ) + logger.info("物联网设备状态已更新") + return + + # 使用新方法获取状态 + changed, states_json = thing_manager.get_states_json(delta=delta) + # delta=False总是发送,delta=True只在有变化时发送 + if not delta or changed: + asyncio.run_coroutine_threadsafe( + self.protocol.send_iot_states(states_json), + self.loop + ) + if delta: + logger.info("物联网设备状态已更新(增量)") + else: + logger.info("物联网设备状态已更新(完整)") + else: + logger.debug("物联网设备状态无变化,跳过更新") + + def _update_wake_word_detector_stream(self): + """更新唤醒词检测器的音频流""" + if self.wake_word_detector and self.audio_codec and self.wake_word_detector.is_running(): + # 直接引用AudioCodec实例中的输入流 + if self.audio_codec.input_stream and self.audio_codec.input_stream.is_active(): + self.wake_word_detector.stream = self.audio_codec.input_stream + self.wake_word_detector.external_stream = True + logger.info("已更新唤醒词检测器的音频流引用") diff --git a/src/audio_codecs/audio_codec.py b/src/audio_codecs/audio_codec.py new file mode 100644 index 0000000000000000000000000000000000000000..975788139d6673967e27aa3b04901ba932142cd1 --- /dev/null +++ b/src/audio_codecs/audio_codec.py @@ -0,0 +1,336 @@ +import queue +import numpy as np +import pyaudio +import opuslib +import time +import threading + +from src.constants.constants import AudioConfig +from src.utils.logging_config import get_logger + +logger = get_logger(__name__) + + +class AudioCodec: + """音频编解码器类,处理音频的录制和播放(严格兼容版)""" + + def __init__(self): + self.audio = None + self.input_stream = None + self.output_stream = None + self.opus_encoder = None + self.opus_decoder = None + self.audio_decode_queue = queue.Queue() + + # 状态管理(保留原始变量名) + self._is_closing = False + self._is_input_paused = False + self._input_paused_lock = threading.Lock() + self._stream_lock = threading.Lock() + + # 新增设备索引缓存 + self._cached_input_device = -1 + self._cached_output_device = -1 + + self._initialize_audio() + + def _initialize_audio(self): + try: + self.audio = pyaudio.PyAudio() + + # 缓存设备索引 + self._cached_input_device = self._get_default_or_first_available_device(True) + self._cached_output_device = self._get_default_or_first_available_device(False) + + # 初始化流(优化实现) + self.input_stream = self._create_stream(is_input=True) + self.output_stream = self._create_stream(is_input=False) + + # 编解码器初始化(保持原始参数) + self.opus_encoder = opuslib.Encoder( + AudioConfig.INPUT_SAMPLE_RATE, + AudioConfig.CHANNELS, + AudioConfig.OPUS_APPLICATION + ) + self.opus_decoder = opuslib.Decoder( + AudioConfig.OUTPUT_SAMPLE_RATE, + AudioConfig.CHANNELS + ) + + logger.info("音频设备和编解码器初始化成功") + except Exception as e: + logger.error(f"初始化音频设备失败: {e}") + self.close() + raise + + def _get_default_or_first_available_device(self, is_input=True): + """设备选择逻辑(优化异常处理)""" + try: + device = self.audio.get_default_input_device_info() if is_input else \ + self.audio.get_default_output_device_info() + logger.info(f"使用默认设备: {device['name']} (Index: {device['index']})") + return device["index"] + except OSError: + logger.warning("默认设备不可用,查找替代设备...") + for i in range(self.audio.get_device_count()): + dev = self.audio.get_device_info_by_index(i) + if is_input and dev["maxInputChannels"] > 0: + logger.info(f"使用替代输入设备: {dev['name']} (Index: {i})") + return i + if not is_input and dev["maxOutputChannels"] > 0: + logger.info(f"使用替代输出设备: {dev['name']} (Index: {i})") + return i + raise RuntimeError("没有可用的音频设备") + + def _create_stream(self, is_input=True): + """流创建逻辑(新增设备缓存)""" + params = { + "format": pyaudio.paInt16, + "channels": AudioConfig.CHANNELS, + "rate": AudioConfig.INPUT_SAMPLE_RATE if is_input else AudioConfig.OUTPUT_SAMPLE_RATE, + "input" if is_input else "output": True, + "frames_per_buffer": AudioConfig.INPUT_FRAME_SIZE if is_input else AudioConfig.OUTPUT_FRAME_SIZE, + "start": False + } + + # 使用缓存设备索引 + if is_input: + params["input_device_index"] = self._cached_input_device + else: + params["output_device_index"] = self._cached_output_device + + return self.audio.open(**params) + + def _reinitialize_input_stream(self): + """输入流重建(优化设备缓存)""" + if self._is_closing: + return + + try: + # 刷新设备缓存 + self._cached_input_device = self._get_default_or_first_available_device(True) + + if self.input_stream: + try: + self.input_stream.stop_stream() + self.input_stream.close() + except Exception: + pass + + self.input_stream = self._create_stream(is_input=True) + self.input_stream.start_stream() + logger.info("音频输入流重新初始化成功") + except Exception as e: + logger.error(f"输入流重建失败: {e}") + raise + + def _reinitialize_output_stream(self): + """输出流重建(优化设备缓存)""" + if self._is_closing: + return + + try: + # 刷新设备缓存 + self._cached_output_device = self._get_default_or_first_available_device(False) + + if self.output_stream: + try: + self.output_stream.stop_stream() + self.output_stream.close() + except Exception: + pass + + self.output_stream = self._create_stream(is_input=False) + self.output_stream.start_stream() + logger.info("音频输出流重新初始化成功") + except Exception as e: + logger.error(f"输出流重建失败: {e}") + raise + + def pause_input(self): + with self._input_paused_lock: + self._is_input_paused = True + logger.info("音频输入已暂停") + + def resume_input(self): + with self._input_paused_lock: + self._is_input_paused = False + logger.info("音频输入已恢复") + + def is_input_paused(self): + with self._input_paused_lock: + return self._is_input_paused + + def read_audio(self): + """(优化缓冲区管理)""" + if self.is_input_paused(): + return None + + try: + with self._stream_lock: + # 流状态检查优化 + if not self.input_stream or not self.input_stream.is_active(): + self._reinitialize_input_stream() + if not self.input_stream.is_active(): + return None + + # 动态缓冲区调整 - 实时性能优化 + available = self.input_stream.get_read_available() + if available > AudioConfig.INPUT_FRAME_SIZE * 2: # 降低阈值从3倍到2倍 + skip_samples = available - (AudioConfig.INPUT_FRAME_SIZE * 1.5) # 减少保留量 + if skip_samples > 0: # 增加安全检查 + self.input_stream.read( + int(skip_samples), # 确保整数 + exception_on_overflow=False + ) + logger.debug(f"跳过{skip_samples}个样本减少延迟") + + # 读取数据 + data = self.input_stream.read( + AudioConfig.INPUT_FRAME_SIZE, + exception_on_overflow=False + ) + + # 数据验证 + if len(data) != AudioConfig.INPUT_FRAME_SIZE * 2: + logger.warning("音频数据长度异常,重置输入流") + self._reinitialize_input_stream() + return None + + return self.opus_encoder.encode(data, AudioConfig.INPUT_FRAME_SIZE) + + except Exception as e: + logger.error(f"音频读取失败: {e}") + self._reinitialize_input_stream() + return None + + def play_audio(self): + """(优化批量处理)""" + try: + if self.audio_decode_queue.empty(): + return + + # 批量解码优化 + batch_size = min(10, self.audio_decode_queue.qsize()) + buffer = bytearray() + for _ in range(batch_size): + try: + opus_data = self.audio_decode_queue.get_nowait() + pcm = self.opus_decoder.decode(opus_data, AudioConfig.OUTPUT_FRAME_SIZE) + buffer.extend(pcm) + except queue.Empty: + break + except opuslib.OpusError as e: + logger.error(f"解码失败: {e}") + + if buffer: + # 优化写入流程 + with self._stream_lock: + if self.output_stream and self.output_stream.is_active(): + try: + self.output_stream.write(np.frombuffer(buffer, dtype=np.int16).tobytes()) + except OSError as e: + if "Stream closed" in str(e): + self._reinitialize_output_stream() + self.output_stream.write(buffer) + except Exception as e: + logger.error(f"播放失败: {e}") + self._reinitialize_output_stream() + + def close(self): + """(优化资源释放顺序和线程安全性)""" + if self._is_closing: + return + + self._is_closing = True + logger.info("开始关闭音频编解码器...") + + try: + # 清空队列先行处理 + self.clear_audio_queue() + + # 安全停止和关闭流 + with self._stream_lock: + # 先关闭输入流 + if self.input_stream: + try: + if hasattr(self.input_stream, 'is_active') and self.input_stream.is_active(): + self.input_stream.stop_stream() + self.input_stream.close() + except Exception as e: + logger.warning(f"关闭输入流失败: {e}") + finally: + self.input_stream = None + + # 再关闭输出流 + if self.output_stream: + try: + if hasattr(self.output_stream, 'is_active') and self.output_stream.is_active(): + self.output_stream.stop_stream() + self.output_stream.close() + except Exception as e: + logger.warning(f"关闭输出流失败: {e}") + finally: + self.output_stream = None + + # 最后释放PyAudio + if self.audio: + try: + self.audio.terminate() + except Exception as e: + logger.warning(f"释放PyAudio失败: {e}") + finally: + self.audio = None + + # 清理编解码器 + self.opus_encoder = None + self.opus_decoder = None + + logger.info("音频资源已完全释放") + except Exception as e: + logger.error(f"关闭音频编解码器过程中发生错误: {e}") + finally: + self._is_closing = False + + def write_audio(self, opus_data): + self.audio_decode_queue.put(opus_data) + + def has_pending_audio(self): + return not self.audio_decode_queue.empty() + + def wait_for_audio_complete(self, timeout=5.0): + start = time.time() + while self.has_pending_audio() and time.time() - start < timeout: + time.sleep(0.1) + + def clear_audio_queue(self): + with self._stream_lock: + while not self.audio_decode_queue.empty(): + try: + self.audio_decode_queue.get_nowait() + except queue.Empty: + break + + def start_streams(self): + for stream in [self.input_stream, self.output_stream]: + if stream and not stream.is_active(): + try: + stream.start_stream() + except OSError as e: + logger.error(f"启动失败: {e}") + + def stop_streams(self): + """安全停止流(优化错误处理)""" + with self._stream_lock: + for name, stream in [("输入", self.input_stream), ("输出", self.output_stream)]: + if stream: + try: + # 使用hasattr避免在流已关闭情况下调用is_active + if hasattr(stream, 'is_active') and stream.is_active(): + stream.stop_stream() + except Exception as e: + # 使用warning级别,因为这不是严重错误 + logger.warning(f"停止{name}流失败: {e}") + + def __del__(self): + self.close() \ No newline at end of file diff --git a/src/audio_processing/vad_detector.py b/src/audio_processing/vad_detector.py new file mode 100644 index 0000000000000000000000000000000000000000..3245147e54fe11ff92b6d6a8a5aa81222b62959c --- /dev/null +++ b/src/audio_processing/vad_detector.py @@ -0,0 +1,261 @@ +import webrtcvad +import numpy as np +import threading +import time +import logging +import pyaudio +from src.constants.constants import AbortReason, DeviceState + +# 配置日志 +logger = logging.getLogger("VADDetector") + +class VADDetector: + """基于WebRTC VAD的语音活动检测器,用于检测用户打断""" + + def __init__(self, audio_codec, protocol, app_instance, loop): + """初始化VAD检测器 + + 参数: + audio_codec: 音频编解码器实例 + protocol: 通信协议实例 + app_instance: 应用程序实例 + loop: 事件循环 + """ + self.audio_codec = audio_codec + self.protocol = protocol + self.app = app_instance + self.loop = loop + + # VAD设置 + self.vad = webrtcvad.Vad() + self.vad.set_mode(3) # 设置最高灵敏度 + + # 参数设置 + self.sample_rate = 16000 + self.frame_duration = 20 # 毫秒 + self.frame_size = int(self.sample_rate * self.frame_duration / 1000) + self.speech_window = 5 # 连续检测到多少帧语音才触发打断 + self.energy_threshold = 300 # 能量阈值 + + # 状态变量 + self.running = False + self.paused = False + self.thread = None + self.speech_count = 0 + self.silence_count = 0 + self.triggered = False + + # 创建独立的PyAudio实例和流,避免与主音频流冲突 + self.pa = None + self.stream = None + + def start(self): + """启动VAD检测器""" + if self.thread and self.thread.is_alive(): + logger.warning("VAD检测器已经在运行") + return + + self.running = True + self.paused = False + + # 初始化PyAudio和流 + self._initialize_audio_stream() + + # 启动检测线程 + self.thread = threading.Thread(target=self._detection_loop, daemon=True) + self.thread.start() + logger.info("VAD检测器已启动") + + def stop(self): + """停止VAD检测器""" + self.running = False + + # 关闭音频流 + self._close_audio_stream() + + if self.thread and self.thread.is_alive(): + self.thread.join(timeout=1.0) + + logger.info("VAD检测器已停止") + + def pause(self): + """暂停VAD检测""" + self.paused = True + logger.info("VAD检测器已暂停") + + def resume(self): + """恢复VAD检测""" + self.paused = False + # 重置状态 + self.speech_count = 0 + self.silence_count = 0 + self.triggered = False + logger.info("VAD检测器已恢复") + + def is_running(self): + """检查VAD检测器是否正在运行""" + return self.running and not self.paused + + def _initialize_audio_stream(self): + """初始化独立的音频流""" + try: + # 创建PyAudio实例 + self.pa = pyaudio.PyAudio() + + # 获取默认输入设备 + device_index = None + for i in range(self.pa.get_device_count()): + device_info = self.pa.get_device_info_by_index(i) + if device_info['maxInputChannels'] > 0: + device_index = i + break + + if device_index is None: + logger.error("找不到可用的输入设备") + return False + + # 创建输入流 + self.stream = self.pa.open( + format=pyaudio.paInt16, + channels=1, + rate=self.sample_rate, + input=True, + input_device_index=device_index, + frames_per_buffer=self.frame_size, + start=True + ) + + logger.info(f"VAD检测器音频流已初始化,使用设备索引: {device_index}") + return True + + except Exception as e: + logger.error(f"初始化VAD音频流失败: {e}") + return False + + def _close_audio_stream(self): + """关闭音频流""" + try: + if self.stream: + self.stream.stop_stream() + self.stream.close() + self.stream = None + + if self.pa: + self.pa.terminate() + self.pa = None + + logger.info("VAD检测器音频流已关闭") + except Exception as e: + logger.error(f"关闭VAD音频流失败: {e}") + + def _detection_loop(self): + """VAD检测主循环""" + logger.info("VAD检测循环已启动") + + while self.running: + # 如果暂停或者音频流未初始化,则跳过 + if self.paused or not self.stream: + time.sleep(0.1) + continue + + try: + # 只在说话状态下进行检测 + if self.app.device_state == DeviceState.SPEAKING: + # 读取音频帧 + frame = self._read_audio_frame() + if not frame: + time.sleep(0.01) + continue + + # 检测是否是语音 + is_speech = self._detect_speech(frame) + + # 如果检测到语音并且达到触发条件,处理打断 + if is_speech: + self._handle_speech_frame(frame) + else: + self._handle_silence_frame(frame) + else: + # 不在说话状态,重置状态 + self._reset_state() + + except Exception as e: + logger.error(f"VAD检测循环出错: {e}") + + time.sleep(0.01) # 小延迟,减少CPU使用 + + logger.info("VAD检测循环已结束") + + def _read_audio_frame(self): + """读取一帧音频数据""" + try: + if not self.stream or not self.stream.is_active(): + return None + + # 读取音频数据 + data = self.stream.read(self.frame_size, exception_on_overflow=False) + return data + except Exception as e: + logger.error(f"读取音频帧失败: {e}") + return None + + def _detect_speech(self, frame): + """检测是否是语音""" + try: + # 确保帧长度正确 + if len(frame) != self.frame_size * 2: # 16位音频,每个样本2字节 + return False + + # 使用VAD检测 + is_speech = self.vad.is_speech(frame, self.sample_rate) + + # 计算音频能量 + audio_data = np.frombuffer(frame, dtype=np.int16) + energy = np.mean(np.abs(audio_data)) + + # 结合VAD和能量阈值 + is_valid_speech = is_speech and energy > self.energy_threshold + + if is_valid_speech: + logger.debug(f'检测到语音 [能量: {energy:.2f}] [连续语音帧: {self.speech_count+1}]') + + return is_valid_speech + except Exception as e: + logger.error(f"检测语音失败: {e}") + return False + + def _handle_speech_frame(self, frame): + """处理语音帧""" + self.speech_count += 1 + self.silence_count = 0 + + # 检测到足够的连续语音帧,触发打断 + if self.speech_count >= self.speech_window and not self.triggered: + self.triggered = True + logger.info("检测到持续语音,触发打断!") + self._trigger_interrupt() + + # 立即暂停自己,防止重复触发 + self.paused = True + logger.info("VAD检测器已自动暂停以防止重复触发") + + # 重置状态 + self.speech_count = 0 + self.silence_count = 0 + self.triggered = False + + def _handle_silence_frame(self, frame): + """处理静音帧""" + self.silence_count += 1 + self.speech_count = 0 + + def _reset_state(self): + """重置状态""" + self.speech_count = 0 + self.silence_count = 0 + self.triggered = False + + def _trigger_interrupt(self): + """触发打断""" + # 通知应用程序中止当前语音输出 + self.app.schedule(lambda: self.app.abort_speaking(AbortReason.WAKE_WORD_DETECTED)) diff --git a/src/audio_processing/wake_word_detect.py b/src/audio_processing/wake_word_detect.py new file mode 100644 index 0000000000000000000000000000000000000000..00623d1386d91c67da9af8b1689fbcfc7e1c3e4f --- /dev/null +++ b/src/audio_processing/wake_word_detect.py @@ -0,0 +1,532 @@ +import json +import threading +import time +import os +import sys +from pathlib import Path +from vosk import Model, KaldiRecognizer, SetLogLevel +from pypinyin import lazy_pinyin +import pyaudio + +from src.constants.constants import AudioConfig +from src.utils.config_manager import ConfigManager +from src.utils.logging_config import get_logger + +logger = get_logger(__name__) + +class WakeWordDetector: + """唤醒词检测类(集成AudioCodec优化版)""" + + def __init__(self, + sample_rate=AudioConfig.INPUT_SAMPLE_RATE, + buffer_size=AudioConfig.INPUT_FRAME_SIZE, + audio_codec=None): + """ + 初始化唤醒词检测器 + + 参数: + audio_codec: AudioCodec实例(新增) + sample_rate: 音频采样率 + buffer_size: 音频缓冲区大小 + """ + # 初始化音频编解码器引用 + self.audio_codec = audio_codec + + # 初始化基本属性 + self.on_detected_callbacks = [] + self.running = False + self.detection_thread = None + self.paused = False + self.audio = None + self.stream = None + self.external_stream = False + self.stream_lock = threading.Lock() + self.on_error = None + + # 配置检查 + config = ConfigManager.get_instance() + if not config.get_config('WAKE_WORD_OPTIONS.USE_WAKE_WORD', False): + logger.info("唤醒词功能已禁用") + self.enabled = False + return + + # 基本参数初始化 + self.enabled = True + self.sample_rate = sample_rate + self.buffer_size = buffer_size + self.sensitivity = config.get_config("WAKE_WORD_OPTIONS.SENSITIVITY", 0.5) + + # 唤醒词配置 + self.wake_words = config.get_config('WAKE_WORD_OPTIONS.WAKE_WORDS', [ + "你好小明", "你好小智", "你好小天", "小爱同学", "贾维斯" + ]) + self.wake_words_pinyin = [''.join(lazy_pinyin(word)) for word in self.wake_words] + + # 模型初始化 + try: + model_path = self._get_model_path(config) + if not os.path.exists(model_path): + raise FileNotFoundError(f"模型路径不存在: {model_path}") + + logger.info(f"加载语音识别模型: {model_path}") + SetLogLevel(-1) + self.model = Model(model_path=model_path) + self.recognizer = KaldiRecognizer(self.model, self.sample_rate) + self.recognizer.SetWords(True) + logger.info("模型加载完成") + + # 调试日志 + logger.info(f"已配置 {len(self.wake_words)} 个唤醒词") + for idx, (word, pinyin) in enumerate(zip(self.wake_words, self.wake_words_pinyin)): + logger.debug(f"唤醒词 {idx+1}: {word.ljust(8)} => {pinyin}") + + except Exception as e: + logger.error(f"初始化失败: {e}", exc_info=True) + self.enabled = False + + def _get_model_path(self, config): + """获取模型路径(更智能的路径查找)""" + # 直接从配置中获取模型名称或路径 + model_name = config.get_config( + 'WAKE_WORD_OPTIONS.MODEL_PATH', + 'vosk-model-small-cn-0.22' + ) + + # 转换为Path对象 + model_path = Path(model_name) + + # 如果只有模型名称(没有父目录),则标准化为models子目录下的路径 + if len(model_path.parts) == 1: + model_path = Path('models') / model_path + + # 可能的基准路径 + possible_base_dirs = [ + Path(__file__).parent.parent.parent, # 项目根目录 + Path.cwd(), # 当前工作目录 + ] + + # 如果是打包后的环境,增加更多可能的基准路径 + if getattr(sys, 'frozen', False): + # 可执行文件所在目录 + exe_dir = Path(sys.executable).parent + possible_base_dirs.append(exe_dir) + + # PyInstaller的_MEIPASS路径(如果存在) + if hasattr(sys, '_MEIPASS'): + meipass_dir = Path(sys._MEIPASS) + possible_base_dirs.append(meipass_dir) + # 增加_MEIPASS的父目录(可能是应用根目录) + possible_base_dirs.append(meipass_dir.parent) + + # 增加可执行文件父目录(处理某些安装情况) + possible_base_dirs.append(exe_dir.parent) + + logger.debug(f"可执行文件目录: {exe_dir}") + if hasattr(sys, '_MEIPASS'): + logger.debug(f"PyInstaller临时目录: {meipass_dir}") + + # 查找模型文件 + model_file_path = None + + # 遍历所有可能的基准路径 + for base_dir in filter(None, possible_base_dirs): + # 1. 尝试标准的models目录下的模型 + path_to_check = base_dir / model_path + if path_to_check.exists(): + model_file_path = path_to_check + logger.info(f"找到模型文件: {model_file_path}") + break + + # 2. 尝试直接使用模型名称(不包含models前缀) + if len(model_path.parts) > 1 and model_path.parts[0] == 'models': + # 去掉models前缀 + alt_path = base_dir / Path(*model_path.parts[1:]) + if alt_path.exists(): + model_file_path = alt_path + logger.info(f"在替代位置找到模型: {model_file_path}") + break + + # 如果仍未找到,尝试一些特殊位置 + if model_file_path is None and getattr(sys, 'frozen', False): + # 1. 检查与可执行文件同级的特定目录 + special_paths = [ + # PyInstaller 6.0.0+ 的_internal目录 + Path(sys.executable).parent / "_internal" / model_path, + # 与可执行文件同级的models目录 + Path(sys.executable).parent / "models" / model_path.name, + # 可执行文件同级直接放置模型 + Path(sys.executable).parent / model_path.name + ] + + for path in special_paths: + if path.exists(): + model_file_path = path + logger.info(f"在特殊位置找到模型: {model_file_path}") + break + + # 如果找不到任何位置,使用配置的原始路径 + if model_file_path is None: + # 如果是绝对路径直接使用 + if model_path.is_absolute(): + model_file_path = model_path + else: + # 否则使用项目根目录+相对路径 + model_file_path = Path(__file__).parent.parent.parent / model_path + + logger.warning(f"未找到模型,将使用默认路径: {model_file_path}") + + # 转换为字符串返回 + model_path_str = str(model_file_path) + logger.debug(f"最终模型路径: {model_path_str}") + return model_path_str + + def start(self, audio_codec_or_stream=None): + """启动检测(支持音频编解码器或直接流传入)""" + if not self.enabled: + logger.warning("唤醒词功能未启用") + return False + + # 检查参数类型,区分音频编解码器和流对象 + if audio_codec_or_stream: + # 检查是否是流对象 + if hasattr(audio_codec_or_stream, 'read') and hasattr(audio_codec_or_stream, 'is_active'): + # 是流对象,使用直接流模式 + self.stream = audio_codec_or_stream + self.external_stream = True + return self._start_with_external_stream() + else: + # 是AudioCodec对象,使用AudioCodec模式 + self.audio_codec = audio_codec_or_stream + + # 优先使用audio_codec的流 + if self.audio_codec: + return self._start_with_audio_codec() + else: + return self._start_standalone() + + def _start_with_audio_codec(self): + """使用AudioCodec的输入流(直接访问)""" + try: + # 直接访问input_stream属性 + if not self.audio_codec or not self.audio_codec.input_stream: + logger.error("音频编解码器无效或输入流不可用") + return False + + # 直接使用AudioCodec的输入流 + self.stream = self.audio_codec.input_stream + self.external_stream = True # 标记为外部流,避免错误关闭 + + # 配置流参数 + self.sample_rate = AudioConfig.INPUT_SAMPLE_RATE + self.buffer_size = AudioConfig.INPUT_FRAME_SIZE + + # 启动检测线程 + self.running = True + self.paused = False + self.detection_thread = threading.Thread( + target=self._audio_codec_detection_loop, + daemon=True, + name="WakeWordDetector-AudioCodec" + ) + self.detection_thread.start() + + logger.info("唤醒词检测已启动(直接使用AudioCodec输入流)") + return True + except Exception as e: + logger.error(f"通过AudioCodec启动失败: {e}") + return False + + def _start_standalone(self): + """独立音频模式""" + try: + self.audio = pyaudio.PyAudio() + self.stream = self.audio.open( + format=pyaudio.paInt16, + channels=AudioConfig.CHANNELS, + rate=self.sample_rate, + input=True, + frames_per_buffer=self.buffer_size + ) + + self.running = True + self.paused = False + self.detection_thread = threading.Thread( + target=self._detection_loop, + daemon=True, + name="WakeWordDetector-Standalone" + ) + self.detection_thread.start() + + logger.info("唤醒词检测已启动(独立音频模式)") + return True + except Exception as e: + logger.error(f"独立模式启动失败: {e}") + return False + + def _start_with_external_stream(self): + """使用外部提供的音频流""" + try: + # 设置参数 + self.sample_rate = AudioConfig.INPUT_SAMPLE_RATE + self.buffer_size = AudioConfig.INPUT_FRAME_SIZE + + # 启动检测线程 + self.running = True + self.paused = False + self.detection_thread = threading.Thread( + target=self._detection_loop, + daemon=True, + name="WakeWordDetector-ExternalStream" + ) + self.detection_thread.start() + + logger.info("唤醒词检测已启动(使用外部音频流)") + return True + except Exception as e: + logger.error(f"使用外部流启动失败: {e}") + return False + + def _audio_codec_detection_loop(self): + """AudioCodec专用检测循环(优化直接访问)""" + logger.info("进入AudioCodec检测循环") + error_count = 0 + MAX_ERRORS = 5 + STREAM_TIMEOUT = 3.0 # 流等待超时时间 + + while self.running: + try: + if self.paused: + time.sleep(0.1) + continue + + # 直接访问AudioCodec的输入流 + if not self.audio_codec or not hasattr(self.audio_codec, 'input_stream'): + logger.warning("AudioCodec不可用,等待中...") + time.sleep(STREAM_TIMEOUT) + continue + + # 直接使用当前流引用 + stream = self.audio_codec.input_stream + if not stream or not stream.is_active(): + logger.debug("AudioCodec输入流不活跃,等待恢复...") + try: + # 尝试重新激活或等待AudioCodec恢复流 + if stream and hasattr(stream, 'start_stream'): + stream.start_stream() + else: + time.sleep(0.5) + continue + except Exception as e: + logger.warning(f"激活流失败: {e}") + time.sleep(0.5) + continue + + # 读取音频数据 + data = self._read_audio_data_direct(stream) + if not data: + continue + + # 处理数据 + self._process_audio_data(data) + error_count = 0 # 重置错误计数 + + except Exception as e: + error_count += 1 + logger.error(f"检测循环错误({error_count}/{MAX_ERRORS}): {str(e)}") + + if error_count >= MAX_ERRORS: + logger.critical("达到最大错误次数,停止检测") + self.stop() + time.sleep(0.5) + + def _read_audio_data_direct(self, stream): + """直接从流读取数据(简化版)""" + try: + with self.stream_lock: + # 检查可用数据 + if hasattr(stream, 'get_read_available'): + available = stream.get_read_available() + if available < self.buffer_size: + return None + + # 精确读取 + return stream.read(self.buffer_size, exception_on_overflow=False) + except OSError as e: + error_msg = str(e) + logger.warning(f"音频流错误: {error_msg}") + + # 关键错误处理 + critical_errors = ["Input overflowed", "Device unavailable"] + if any(msg in error_msg for msg in critical_errors) and self.audio_codec: + logger.info("触发音频流重置...") + try: + # 直接调用AudioCodec的重置方法 + self.audio_codec._reinitialize_input_stream() + except Exception as re: + logger.error(f"流重置失败: {re}") + + time.sleep(0.5) + return None + except Exception as e: + logger.error(f"读取音频数据异常: {e}") + return None + + def _detection_loop(self): + """标准检测循环(用于外部流或独立模式)""" + logger.info("进入标准检测循环") + error_count = 0 + MAX_ERRORS = 5 + + while self.running: + try: + if self.paused: + time.sleep(0.1) + continue + + # 读取音频数据(带锁保护) + try: + with self.stream_lock: + if not self.stream: + logger.warning("音频流不可用") + time.sleep(0.5) + continue + + # 确保流是活跃的 + if not self.stream.is_active(): + try: + self.stream.start_stream() + except Exception as e: + logger.error(f"启动音频流失败: {e}") + time.sleep(0.5) + continue + + # 读取数据 + data = self.stream.read( + self.buffer_size, + exception_on_overflow=False + ) + except Exception as e: + logger.error(f"读取音频数据失败: {e}") + time.sleep(0.5) + continue + + # 处理音频数据 + if data and len(data) > 0: + self._process_audio_data(data) + error_count = 0 # 重置错误计数 + + except Exception as e: + error_count += 1 + logger.error(f"检测循环错误({error_count}/{MAX_ERRORS}): {e}") + + if error_count >= MAX_ERRORS: + logger.critical("达到最大错误次数,停止检测") + self.stop() + time.sleep(0.5) + + def stop(self): + """停止检测(优化资源释放)""" + if self.running: + logger.info("正在停止唤醒词检测...") + self.running = False + + if self.detection_thread and self.detection_thread.is_alive(): + self.detection_thread.join(timeout=1.0) + + # 仅清理自有资源,不清理外部传入的流 + if not self.external_stream and not self.audio_codec and self.stream: + try: + if self.stream.is_active(): + self.stream.stop_stream() + self.stream.close() + except Exception as e: + logger.error(f"关闭音频流失败: {e}") + + # 清理PyAudio实例 + if self.audio: + try: + self.audio.terminate() + except Exception as e: + logger.error(f"终止音频设备失败: {e}") + + # 重置状态 + self.stream = None + self.audio = None + self.external_stream = False + logger.info("唤醒词检测已停止") + + def is_running(self): + """检查唤醒词检测是否正在运行""" + return self.running and not self.paused + + def update_stream(self, new_stream): + """更新唤醒词检测器使用的音频流""" + if not self.running: + logger.warning("唤醒词检测器未运行,无法更新流") + return False + + with self.stream_lock: + # 如果当前不是使用外部流或AudioCodec,先清理现有资源 + if not self.external_stream and not self.audio_codec and self.stream: + try: + if self.stream.is_active(): + self.stream.stop_stream() + self.stream.close() + except Exception as e: + logger.warning(f"关闭旧流时出错: {e}") + + # 更新为新的流 + self.stream = new_stream + self.external_stream = True + logger.info("已更新唤醒词检测器的音频流") + return True + + def _process_audio_data(self, data): + """处理音频数据(优化日志)""" + if self.recognizer.AcceptWaveform(data): + result = json.loads(self.recognizer.Result()) + if text := result.get('text', ''): + logger.debug(f"完整识别: {text}") + self._check_wake_word(text) + + partial = json.loads(self.recognizer.PartialResult()).get('partial', '') + if partial: + logger.debug(f"部分识别: {partial}") + self._check_wake_word(partial, is_partial=True) + + def _check_wake_word(self, text, is_partial=False): + """唤醒词检查(优化拼音匹配)""" + text_pinyin = ''.join(lazy_pinyin(text)).replace(' ', '') + for word, pinyin in zip(self.wake_words, self.wake_words_pinyin): + if pinyin in text_pinyin: + logger.info(f"检测到唤醒词 '{word}' (匹配拼音: {pinyin})") + self._trigger_callbacks(word, text) + self.recognizer.Reset() + return + + def pause(self): + """暂停检测""" + if self.running and not self.paused: + self.paused = True + logger.info("检测已暂停") + + def resume(self): + """恢复检测""" + if self.running and self.paused: + self.paused = False + logger.info("检测已恢复") + + def on_detected(self, callback): + """注册回调""" + self.on_detected_callbacks.append(callback) + + def _trigger_callbacks(self, wake_word, text): + """触发回调(带异常处理)""" + for cb in self.on_detected_callbacks: + try: + cb(wake_word, text) + except Exception as e: + logger.error(f"回调执行失败: {e}", exc_info=True) + + def __del__(self): + self.stop() \ No newline at end of file diff --git a/src/constants/constants.py b/src/constants/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..d4b2995b7053a53366296debd13f0207e947e764 --- /dev/null +++ b/src/constants/constants.py @@ -0,0 +1,89 @@ +from src.utils.config_manager import ConfigManager +import platform +config = ConfigManager.get_instance() + + +class ListeningMode: + """监听模式""" + ALWAYS_ON = "always_on" + AUTO_STOP = "auto_stop" + MANUAL = "manual" + +class AbortReason: + """中止原因""" + NONE = "none" + WAKE_WORD_DETECTED = "wake_word_detected" + USER_INTERRUPTION = "user_interruption" + +class DeviceState: + """设备状态""" + IDLE = "idle" + CONNECTING = "connecting" + LISTENING = "listening" + SPEAKING = "speaking" + +class EventType: + """事件类型""" + SCHEDULE_EVENT = "schedule_event" + AUDIO_INPUT_READY_EVENT = "audio_input_ready_event" + AUDIO_OUTPUT_READY_EVENT = "audio_output_ready_event" + + +def is_official_server(ws_addr: str) -> bool: + """判断是否为小智官方的服务器地址 + + Args: + ws_addr (str): WebSocket 地址 + + Returns: + bool: 是否为小智官方的服务器地址 + """ + return "api.tenclass.net" in ws_addr + +def get_frame_duration() -> int: + """ + 获取设备的帧长度 + + 返回: + int: 帧长度(毫秒) + """ + import pyaudio + try: + + if (platform.system() == "Linux" or + not is_official_server(config.get_config("SYSTEM_OPTIONS.NETWORK.WEBSOCKET_URL"))): + return 60 + + p = pyaudio.PyAudio() + # 获取默认输入设备信息 + device_info = p.get_default_input_device_info() + # 一些设备会提供建议的缓冲区大小 + default_rate = device_info.get('defaultSampleRate', 48000) + # 默认20ms的缓冲区 + suggested_buffer = device_info.get('defaultSampleRate', 0) / 50 + # 计算帧长度 + frame_duration = int(1000 * suggested_buffer / default_rate) + # 确保帧长度在合理范围内 (10ms-50ms) + frame_duration = max(10, min(50, frame_duration)) + p.terminate() + return frame_duration + except Exception: + return 20 # 如果获取失败,返回默认值20ms + +class AudioConfig: + """音频配置类""" + # 固定配置 + INPUT_SAMPLE_RATE = 16000 # 输入采样率16kHz + OUTPUT_SAMPLE_RATE = 24000 if is_official_server(config.get_config("SYSTEM_OPTIONS.NETWORK.WEBSOCKET_URL")) else 16000 # 输出采样率 + CHANNELS = 1 + + # 动态获取帧长度 + FRAME_DURATION = get_frame_duration() + + # 根据不同采样率计算帧大小 + INPUT_FRAME_SIZE = int(INPUT_SAMPLE_RATE * (FRAME_DURATION / 1000)) + OUTPUT_FRAME_SIZE = int(OUTPUT_SAMPLE_RATE * (FRAME_DURATION / 1000)) + + # Opus编码配置 + OPUS_APPLICATION = 2049 # OPUS_APPLICATION_AUDIO + OPUS_FRAME_SIZE = INPUT_FRAME_SIZE # 使用输入采样率的帧大小 diff --git a/src/display/base_display.py b/src/display/base_display.py new file mode 100644 index 0000000000000000000000000000000000000000..ee56a82ee32458e8f52084e2b9308987bcd81457 --- /dev/null +++ b/src/display/base_display.py @@ -0,0 +1,114 @@ +from abc import ABC, abstractmethod +from typing import Optional, Callable +import logging + +class BaseDisplay(ABC): + """显示接口的抽象基类""" + + def __init__(self): + self.logger = logging.getLogger(self.__class__.__name__) + self.current_volume = 70 # 默认音量值 + self.volume_controller = None + + # 检查音量控制依赖 + try: + from src.utils.volume_controller import VolumeController + if VolumeController.check_dependencies(): + self.volume_controller = VolumeController() + self.logger.info("音量控制器初始化成功") + # 读取系统当前音量 + try: + self.current_volume = self.volume_controller.get_volume() + self.logger.info(f"读取到系统音量: {self.current_volume}%") + except Exception as e: + self.logger.warning(f"获取初始系统音量失败: {e},将使用默认值 {self.current_volume}%") + else: + self.logger.warning("音量控制依赖不满足,将使用默认音量控制") + except Exception as e: + self.logger.warning(f"音量控制器初始化失败: {e},将使用模拟音量控制") + + @abstractmethod + def set_callbacks(self, + press_callback: Optional[Callable] = None, + release_callback: Optional[Callable] = None, + status_callback: Optional[Callable] = None, + text_callback: Optional[Callable] = None, + emotion_callback: Optional[Callable] = None, + mode_callback: Optional[Callable] = None, + auto_callback: Optional[Callable] = None, + abort_callback: Optional[Callable] = None, + send_text_callback: Optional[Callable] = None): # 添加打断回调参数 + """设置回调函数""" + pass + + @abstractmethod + def update_button_status(self, text: str): + """更新按钮状态""" + pass + + @abstractmethod + def update_status(self, status: str): + """更新状态文本""" + pass + + @abstractmethod + def update_text(self, text: str): + """更新TTS文本""" + pass + + @abstractmethod + def update_emotion(self, emotion: str): + """更新表情""" + pass + + def get_current_volume(self): + """获取当前音量""" + if self.volume_controller: + try: + # 从系统获取最新音量 + self.current_volume = self.volume_controller.get_volume() + # 获取成功,标记音量控制器正常工作 + if hasattr(self, 'volume_controller_failed'): + self.volume_controller_failed = False + except Exception as e: + self.logger.debug(f"获取系统音量失败: {e}") + # 标记音量控制器工作异常 + self.volume_controller_failed = True + return self.current_volume + + def update_volume(self, volume: int): + """更新系统音量""" + # 确保音量在有效范围内 + volume = max(0, min(100, volume)) + + # 更新内部音量值 + self.current_volume = volume + self.logger.info(f"设置音量: {volume}%") + + # 尝试更新系统音量 + if self.volume_controller: + try: + self.volume_controller.set_volume(volume) + self.logger.debug(f"系统音量已设置为: {volume}%") + except Exception as e: + self.logger.warning(f"设置系统音量失败: {e}") + + @abstractmethod + def start(self): + """启动显示""" + pass + + @abstractmethod + def on_close(self): + """关闭显示""" + pass + + @abstractmethod + def start_keyboard_listener(self): + """启动键盘监听""" + pass + + @abstractmethod + def stop_keyboard_listener(self): + """停止键盘监听""" + pass \ No newline at end of file diff --git a/src/display/cli_display.py b/src/display/cli_display.py new file mode 100644 index 0000000000000000000000000000000000000000..72fa8291f593874cfcc311f3216d0475cfb3537c --- /dev/null +++ b/src/display/cli_display.py @@ -0,0 +1,268 @@ +import asyncio +import threading +import time +import os +from typing import Optional, Callable + +from src.display.base_display import BaseDisplay +# 替换keyboard导入为pynput +from pynput import keyboard as pynput_keyboard + +from src.utils.logging_config import get_logger + +class CliDisplay(BaseDisplay): + def __init__(self): + super().__init__() # 调用父类初始化 + """初始化CLI显示""" + self.logger = get_logger(__name__) + self.running = True + + # 状态相关 + self.current_status = "未连接" + self.current_text = "待命" + self.current_emotion = "😊" + self.current_volume = 0 # 添加当前音量属性 + + # 回调函数 + self.auto_callback = None + self.status_callback = None + self.text_callback = None + self.emotion_callback = None + self.abort_callback = None + self.send_text_callback = None + # 按键状态 + self.is_r_pressed = False + + # 状态缓存 + self.last_status = None + self.last_text = None + self.last_emotion = None + self.last_volume = None + + # 键盘监听器 + self.keyboard_listener = None + + # 为异步操作添加事件循环 + self.loop = asyncio.new_event_loop() + + def set_callbacks(self, + press_callback: Optional[Callable] = None, + release_callback: Optional[Callable] = None, + status_callback: Optional[Callable] = None, + text_callback: Optional[Callable] = None, + emotion_callback: Optional[Callable] = None, + mode_callback: Optional[Callable] = None, + auto_callback: Optional[Callable] = None, + abort_callback: Optional[Callable] = None, + send_text_callback: Optional[Callable] = None): + """设置回调函数""" + self.status_callback = status_callback + self.text_callback = text_callback + self.emotion_callback = emotion_callback + self.auto_callback = auto_callback + self.abort_callback = abort_callback + self.send_text_callback = send_text_callback + + def update_button_status(self, text: str): + """更新按钮状态""" + print(f"按钮状态: {text}") + + def update_status(self, status: str): + """更新状态文本""" + if status != self.current_status: + self.current_status = status + self._print_current_status() + + def update_text(self, text: str): + """更新TTS文本""" + if text != self.current_text: + self.current_text = text + self._print_current_status() + + def update_emotion(self, emotion_path: str): + """更新表情 + emotion_path: GIF文件路径或表情字符串 + """ + if emotion_path != self.current_emotion: + # 如果是gif文件路径,提取文件名作为表情名 + if emotion_path.endswith(".gif"): + # 从路径中提取文件名,去掉.gif后缀 + emotion_name = os.path.basename(emotion_path).replace(".gif", "") + self.current_emotion = f"[{emotion_name}]" + else: + # 如果不是gif路径,则直接使用 + self.current_emotion = emotion_path + + self._print_current_status() + + def start_keyboard_listener(self): + """启动键盘监听""" + try: + def on_press(key): + try: + # F2 按键处理 - 自动对话 + if key == pynput_keyboard.Key.f2: + if self.auto_callback: + self.auto_callback() + # F3 按键处理 - 打断 + elif key == pynput_keyboard.Key.f3: + if self.abort_callback: + self.abort_callback() + except Exception as e: + self.logger.error(f"键盘事件处理错误: {e}") + + # 创建并启动监听器 + self.keyboard_listener = pynput_keyboard.Listener( + on_press=on_press + ) + self.keyboard_listener.start() + self.logger.info("键盘监听器初始化成功") + except Exception as e: + self.logger.error(f"键盘监听器初始化失败: {e}") + + def stop_keyboard_listener(self): + """停止键盘监听""" + if self.keyboard_listener: + try: + self.keyboard_listener.stop() + self.keyboard_listener = None + self.logger.info("键盘监听器已停止") + except Exception as e: + self.logger.error(f"停止键盘监听器失败: {e}") + + def start(self): + """启动CLI显示""" + self._print_help() + + # 启动状态更新线程 + self.start_update_threads() + + # 启动键盘监听线程 + keyboard_thread = threading.Thread(target=self._keyboard_listener) + keyboard_thread.daemon = True + keyboard_thread.start() + + # 启动键盘监听 + self.start_keyboard_listener() + + # 主循环 + try: + while self.running: + time.sleep(0.1) + except KeyboardInterrupt: + self.on_close() + + def on_close(self): + """关闭CLI显示""" + self.running = False + print("\n正在关闭应用...") + self.stop_keyboard_listener() + + def _print_help(self): + """打印帮助信息""" + print("\n=== 小智Ai命令行控制 ===") + print("可用命令:") + print(" r - 开始/停止对话") + print(" x - 打断当前对话") + print(" s - 显示当前状态") + print(" v 数字 - 设置音量(0-100)") + print(" q - 退出程序") + print(" h - 显示此帮助信息") + print("=====================\n") + + def _keyboard_listener(self): + """键盘监听线程""" + try: + while self.running: + cmd = input().lower().strip() + if cmd == 'q': + self.on_close() + break + elif cmd == 'h': + self._print_help() + elif cmd == 'r': + if self.auto_callback: + self.auto_callback() + elif cmd == 'x': + if self.abort_callback: + self.abort_callback() + elif cmd == 's': + self._print_current_status() + elif cmd.startswith('v '): # 添加音量命令处理 + try: + volume = int(cmd.split()[1]) # 获取音量值 + if 0 <= volume <= 100: + self.update_volume(volume) + print(f"音量已设置为: {volume}%") + else: + print("音量必须在0-100之间") + except (IndexError, ValueError): + print("无效的音量值,格式:v <0-100>") + else: + if self.send_text_callback: + # 获取应用程序的事件循环并在其中运行协程 + from src.application import Application + app = Application.get_instance() + if app and app.loop: + asyncio.run_coroutine_threadsafe( + self.send_text_callback(cmd), + app.loop + ) + else: + print("应用程序实例或事件循环不可用") + except Exception as e: + self.logger.error(f"键盘监听错误: {e}") + + def start_update_threads(self): + """启动更新线程""" + def update_loop(): + while self.running: + try: + # 更新状态 + if self.status_callback: + status = self.status_callback() + if status and status != self.current_status: + self.update_status(status) + + # 更新文本 + if self.text_callback: + text = self.text_callback() + if text and text != self.current_text: + self.update_text(text) + + # 更新表情 + if self.emotion_callback: + emotion = self.emotion_callback() + if emotion and emotion != self.current_emotion: + self.update_emotion(emotion) + + except Exception as e: + self.logger.error(f"状态更新错误: {e}") + time.sleep(0.1) + + # 启动更新线程 + threading.Thread(target=update_loop, daemon=True).start() + + def _print_current_status(self): + """打印当前状态""" + # 检查是否有状态变化 + status_changed = ( + self.current_status != self.last_status or + self.current_text != self.last_text or + self.current_emotion != self.last_emotion or + self.current_volume != self.last_volume + ) + + if status_changed: + print("\n=== 当前状态 ===") + print(f"状态: {self.current_status}") + print(f"文本: {self.current_text}") + print(f"表情: {self.current_emotion}") + print(f"音量: {self.current_volume}%") + print("===============\n") + + # 更新缓存 + self.last_status = self.current_status + self.last_text = self.current_text + self.last_emotion = self.current_emotion + self.last_volume = self.current_volume \ No newline at end of file diff --git a/src/display/gui_display.py b/src/display/gui_display.py new file mode 100644 index 0000000000000000000000000000000000000000..a34f8806a40a3e3b8fd0b146db9b0c4d56d53c4d --- /dev/null +++ b/src/display/gui_display.py @@ -0,0 +1,2006 @@ +import sys +import os +import logging +import threading +from pathlib import Path +from urllib.parse import urlparse + +from PyQt5.QtCore import ( + Qt, QTimer, QPropertyAnimation, QRect, + QEvent, QObject +) +from PyQt5.QtWidgets import ( + QApplication, QWidget, QVBoxLayout, + QHBoxLayout, QLabel, QPushButton, QSlider, QLineEdit, + QComboBox, QCheckBox, QMessageBox, QFrame, + QStackedWidget, QTabBar, QStyleOptionSlider, QStyle, + QGraphicsOpacityEffect, QSizePolicy, QScrollArea, QGridLayout +) +from PyQt5.QtGui import ( + QPainter, QColor, QFont, QMouseEvent, QMovie, QBrush, QPen, + QLinearGradient, QTransform, QPainterPath +) + +from src.utils.config_manager import ConfigManager +import queue +import time +import numpy as np +from typing import Optional, Callable +from pynput import keyboard as pynput_keyboard +from abc import ABCMeta +from src.display.base_display import BaseDisplay +import json + +# 定义配置文件路径 +CONFIG_PATH = Path(__file__).parent.parent.parent / "config" / "config.json" + + +def restart_program(): + """使用 os.execv 重启当前 Python 程序。""" + try: + python = sys.executable + print(f"Attempting to restart with: {python} {sys.argv}") + # 尝试关闭 Qt 应用,虽然 execv 会接管,但这样做更规范 + app = QApplication.instance() + if app: + app.quit() + # 替换当前进程 + os.execv(python, [python] + sys.argv) + except Exception as e: + print(f"重启程序失败: {e}") + logging.getLogger("Display").error(f"重启程序失败: {e}", exc_info=True) + # 如果重启失败,可以选择退出或通知用户 + sys.exit(1) # 或者弹出一个错误消息框 + + +# 创建兼容的元类 +class CombinedMeta(type(QObject), ABCMeta): + pass + + +class GuiDisplay(BaseDisplay, QObject, metaclass=CombinedMeta): + def __init__(self): + # 重要:调用 super() 处理多重继承 + super().__init__() + QObject.__init__(self) # 调用 QObject 初始化 + + # 初始化日志 + self.logger = logging.getLogger("Display") + + self.app = None + self.root = None + + # 一些提前初始化的变量 + self.status_label = None + self.emotion_label = None + self.tts_text_label = None + self.volume_scale = None + self.manual_btn = None + self.abort_btn = None + self.auto_btn = None + self.mode_btn = None + self.mute = None + self.stackedWidget = None + self.nav_tab_bar = None + + # 添加表情动画对象 + self.emotion_movie = None + # 新增表情动画特效相关变量 + self.emotion_effect = None # 表情透明度特效 + self.emotion_animation = None # 表情动画对象 + self.next_emotion_path = None # 下一个待显示的表情 + self.is_emotion_animating = False # 是否正在进行表情切换动画 + + # 音量控制相关 + self.volume_label = None # 音量百分比标签 + self.volume_control_available = False # 系统音量控制是否可用 + self.volume_controller_failed = False # 标记音量控制是否失败 + + # 麦克风可视化相关 + self.mic_visualizer = None # 麦克风可视化组件 + self.mic_timer = None # 麦克风音量更新定时器 + self.is_listening = False # 是否正在监听 + + # 设置页面控件 + self.wakeWordEnableSwitch = None + self.wakeWordsLineEdit = None + self.saveSettingsButton = None + # 新增网络和设备ID控件引用 + self.deviceIdLineEdit = None + self.wsProtocolComboBox = None + self.wsAddressLineEdit = None + self.wsTokenLineEdit = None + # 新增OTA地址控件引用 + self.otaProtocolComboBox = None + self.otaAddressLineEdit = None + # Home Assistant 控件引用 + self.haProtocolComboBox = None + self.ha_server = None + self.ha_port = None + self.ha_key = None + self.Add_ha_devices = None + + self.is_muted = False + self.pre_mute_volume = self.current_volume + + # 对话模式标志 + self.auto_mode = False + + # 回调函数 + self.button_press_callback = None + self.button_release_callback = None + self.status_update_callback = None + self.text_update_callback = None + self.emotion_update_callback = None + self.mode_callback = None + self.auto_callback = None + self.abort_callback = None + self.send_text_callback = None + + # 更新队列 + self.update_queue = queue.Queue() + + # 运行标志 + self._running = True + + # 键盘监听器 + self.keyboard_listener = None + + # 滑动手势相关 + self.last_mouse_pos = None + + # 保存定时器引用以避免被销毁 + self.update_timer = None + self.volume_update_timer = None + + # 动画相关 + self.current_effect = None + self.current_animation = None + self.animation = None + self.fade_widget = None + self.animated_widget = None + + # 检查系统音量控制是否可用 + self.volume_control_available = (hasattr(self, 'volume_controller') and + self.volume_controller is not None) + + # 尝试获取一次系统音量,检测音量控制是否正常工作 + self.get_current_volume() + + # 新增iotPage相关变量 + self.devices_list = [] + self.device_labels = {} + self.history_title = None + self.iot_card = None + self.ha_update_timer = None + self.device_states = {} + + def eventFilter(self, source, event): + if source == self.volume_scale and event.type() == QEvent.MouseButtonPress: + if event.button() == Qt.LeftButton: + slider = self.volume_scale + opt = QStyleOptionSlider() + slider.initStyleOption(opt) + + # 获取滑块手柄和轨道的矩形区域 + handle_rect = slider.style().subControlRect( + QStyle.CC_Slider, opt, QStyle.SC_SliderHandle, slider) + groove_rect = slider.style().subControlRect( + QStyle.CC_Slider, opt, QStyle.SC_SliderGroove, slider) + + # 如果点击在手柄上,则让默认处理器处理拖动 + if handle_rect.contains(event.pos()): + return False + + # 计算点击位置相对于轨道的位置 + if slider.orientation() == Qt.Horizontal: + # 确保点击在有效的轨道范围内 + if (event.pos().x() < groove_rect.left() or + event.pos().x() > groove_rect.right()): + return False # 点击在轨道外部 + pos = event.pos().x() - groove_rect.left() + max_pos = groove_rect.width() + else: + if (event.pos().y() < groove_rect.top() or + event.pos().y() > groove_rect.bottom()): + return False # 点击在轨道外部 + pos = groove_rect.bottom() - event.pos().y() + max_pos = groove_rect.height() + + if max_pos > 0: # 避免除以零 + value_range = slider.maximum() - slider.minimum() + # 根据点击位置计算新的值 + new_value = slider.minimum() + round( + (value_range * pos) / max_pos) + + # 直接设置滑块的值 + slider.setValue(int(new_value)) + + return True # 表示事件已处理 + + return super().eventFilter(source, event) + + def _setup_navigation(self): + """设置导航标签栏 (QTabBar)""" + # 使用 addTab 添加标签 + self.nav_tab_bar.addTab("聊天") # index 0 + self.nav_tab_bar.addTab("设备管理") # index 1 + self.nav_tab_bar.addTab("参数配置") # index 2 + + # 将 QTabBar 的 currentChanged 信号连接到处理函数 + self.nav_tab_bar.currentChanged.connect(self._on_navigation_index_changed) + + # 设置默认选中项 (通过索引) + self.nav_tab_bar.setCurrentIndex(0) # 默认选中第一个标签 + + def _on_navigation_index_changed(self, index: int): + """处理导航标签变化 (通过索引)""" + # 映射回 routeKey 以便复用动画和加载逻辑 + index_to_routeKey = {0: "mainInterface", 1: "iotInterface", 2: "settingInterface"} + routeKey = index_to_routeKey.get(index) + + if routeKey is None: + self.logger.warning(f"未知的导航索引: {index}") + return + + target_index = index # 直接使用索引 + if target_index == self.stackedWidget.currentIndex(): + return + + current_widget = self.stackedWidget.currentWidget() + self.stackedWidget.setCurrentIndex(target_index) + new_widget = self.stackedWidget.currentWidget() + + # 如果切换到设置页面,加载设置 + if routeKey == "settingInterface": + self._load_settings() + + # 如果切换到设备管理页面,加载设备 + if routeKey == "iotInterface": + self._load_iot_devices() + + def set_callbacks( + self, + press_callback: Optional[Callable] = None, + release_callback: Optional[Callable] = None, + status_callback: Optional[Callable] = None, + text_callback: Optional[Callable] = None, + emotion_callback: Optional[Callable] = None, + mode_callback: Optional[Callable] = None, + auto_callback: Optional[Callable] = None, + abort_callback: Optional[Callable] = None, + send_text_callback: Optional[Callable] = None, + ): + """设置回调函数""" + self.button_press_callback = press_callback + self.button_release_callback = release_callback + self.status_update_callback = status_callback + self.text_update_callback = text_callback + self.emotion_update_callback = emotion_callback + self.mode_callback = mode_callback + self.auto_callback = auto_callback + self.abort_callback = abort_callback + self.send_text_callback = send_text_callback + + def _process_updates(self): + """处理更新队列""" + if not self._running: + return + + try: + while True: + try: + # 非阻塞方式获取更新 + update_func = self.update_queue.get_nowait() + update_func() + self.update_queue.task_done() + except queue.Empty: + break + except Exception as e: + self.logger.error(f"处理更新队列时发生错误: {e}") + + def _on_manual_button_press(self): + """手动模式按钮按下事件处理""" + try: + # 更新按钮文本为"松开以停止" + if self.manual_btn and self.manual_btn.isVisible(): + self.manual_btn.setText("松开以停止") + + # 调用回调函数 + if self.button_press_callback: + self.button_press_callback() + except Exception as e: + self.logger.error(f"按钮按下回调执行失败: {e}") + + def _on_manual_button_release(self): + """手动模式按钮释放事件处理""" + try: + # 更新按钮文本为"按住后说话" + if self.manual_btn and self.manual_btn.isVisible(): + self.manual_btn.setText("按住后说话") + + # 调用回调函数 + if self.button_release_callback: + self.button_release_callback() + except Exception as e: + self.logger.error(f"按钮释放回调执行失败: {e}") + + def _on_auto_button_click(self): + """自动模式按钮点击事件处理""" + try: + if self.auto_callback: + self.auto_callback() + except Exception as e: + self.logger.error(f"自动模式按钮回调执行失败: {e}") + + def _on_abort_button_click(self): + """处理中止按钮点击事件""" + if self.abort_callback: + self.abort_callback() + + def _on_mode_button_click(self): + """对话模式切换按钮点击事件""" + try: + # 检查是否可以切换模式(通过回调函数询问应用程序当前状态) + if self.mode_callback: + # 如果回调函数返回False,表示当前不能切换模式 + if not self.mode_callback(not self.auto_mode): + return + + # 切换模式 + self.auto_mode = not self.auto_mode + + # 更新按钮显示 + if self.auto_mode: + # 切换到自动模式 + self.update_mode_button_status("自动对话") + + # 隐藏手动按钮,显示自动按钮 + self.update_queue.put(self._switch_to_auto_mode) + else: + # 切换到手动模式 + self.update_mode_button_status("手动对话") + + # 隐藏自动按钮,显示手动按钮 + self.update_queue.put(self._switch_to_manual_mode) + + except Exception as e: + self.logger.error(f"模式切换按钮回调执行失败: {e}") + + def _switch_to_auto_mode(self): + """切换到自动模式的UI更新""" + if self.manual_btn and self.auto_btn: + self.manual_btn.hide() + self.auto_btn.show() + + def _switch_to_manual_mode(self): + """切换到手动模式的UI更新""" + if self.manual_btn and self.auto_btn: + self.auto_btn.hide() + self.manual_btn.show() + + def update_status(self, status: str): + """更新状态文本 (只更新主状态)""" + full_status_text = f"状态: {status}" + self.update_queue.put(lambda: self._safe_update_label(self.status_label, full_status_text)) + + # 根据状态更新麦克风可视化 + if "聆听中" in status: + self.update_queue.put(self._start_mic_visualization) + elif "待命" in status or "说话中" in status: + self.update_queue.put(self._stop_mic_visualization) + + def update_text(self, text: str): + """更新TTS文本""" + self.update_queue.put(lambda: self._safe_update_label(self.tts_text_label, text)) + + def update_emotion(self, emotion_path: str): + """更新表情,使用GIF动画显示""" + # 确保使用绝对路径 + abs_path = os.path.abspath(emotion_path) + + # 检查是否与上次设置的表情相同,避免重复设置 + if hasattr(self, 'last_emotion_path') and self.last_emotion_path == abs_path: + return # 如果是相同的表情路径,直接返回不重复设置 + + # 更新缓存的路径 + self.last_emotion_path = abs_path + self.logger.info(f"设置表情GIF: {abs_path}") + self.update_queue.put(lambda: self._set_emotion_gif(self.emotion_label, abs_path)) + + def _set_emotion_gif(self, label, gif_path): + """设置GIF动画到标签,带淡入淡出效果""" + if not label or self.root.isHidden(): + return + + try: + # 检查文件是否存在 + if not os.path.exists(gif_path): + self.logger.error(f"GIF文件不存在: {gif_path}") + label.setText("😊") + return + + # 如果当前已经设置了相同路径的动画,且正在播放,则不重复设置 + if (self.emotion_movie and + getattr(self.emotion_movie, '_gif_path', None) == gif_path and + self.emotion_movie.state() == QMovie.Running): + return + + # 如果正在进行动画,则只记录下一个待显示的表情,等当前动画完成后再切换 + if self.is_emotion_animating: + self.next_emotion_path = gif_path + return + + self.logger.info(f"加载GIF文件: {gif_path}") + + # 标记正在进行动画 + self.is_emotion_animating = True + + # 如果已有动画在播放,先淡出当前动画 + if self.emotion_movie and label.movie() == self.emotion_movie: + # 创建透明度效果(如果尚未创建) + if not self.emotion_effect: + self.emotion_effect = QGraphicsOpacityEffect(label) + label.setGraphicsEffect(self.emotion_effect) + self.emotion_effect.setOpacity(1.0) + + # 创建淡出动画 + self.emotion_animation = QPropertyAnimation(self.emotion_effect, b"opacity") + self.emotion_animation.setDuration(180) # 设置动画持续时间(毫秒) + self.emotion_animation.setStartValue(1.0) + self.emotion_animation.setEndValue(0.25) + + # 当淡出完成后,设置新的GIF并开始淡入 + def on_fade_out_finished(): + try: + # 停止当前GIF + if self.emotion_movie: + self.emotion_movie.stop() + + # 设置新的GIF并淡入 + self._set_new_emotion_gif(label, gif_path) + except Exception as e: + self.logger.error(f"淡出动画完成后设置GIF失败: {e}") + self.is_emotion_animating = False + + # 连接淡出完成信号 + self.emotion_animation.finished.connect(on_fade_out_finished) + + # 开始淡出动画 + self.emotion_animation.start() + else: + # 如果没有之前的动画,直接设置新的GIF并淡入 + self._set_new_emotion_gif(label, gif_path) + + except Exception as e: + self.logger.error(f"更新表情GIF动画失败: {e}") + # 如果GIF加载失败,尝试显示默认表情 + try: + label.setText("😊") + except Exception: + pass + self.is_emotion_animating = False + + def _set_new_emotion_gif(self, label, gif_path): + """设置新的GIF动画并执行淡入效果""" + try: + # 创建动画对象 + movie = QMovie(gif_path) + if not movie.isValid(): + self.logger.error(f"无效的GIF文件: {gif_path}") + label.setText("😊") + self.is_emotion_animating = False + return + + # 配置动画 + movie.setCacheMode(QMovie.CacheAll) + + # 保存GIF路径到movie对象,用于比较 + movie._gif_path = gif_path + + # 连接信号 + movie.error.connect(lambda: self.logger.error(f"GIF播放错误: {movie.lastError()}")) + + # 保存新的动画对象 + self.emotion_movie = movie + + # 设置标签大小策略 + label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Expanding) + label.setAlignment(Qt.AlignCenter) + + # 设置动画到标签 + label.setMovie(movie) + + # 设置QMovie的速度为110,使动画更流畅(默认是100) + movie.setSpeed(105) + + # 确保不透明度是0(完全透明) + if self.emotion_effect: + self.emotion_effect.setOpacity(0.0) + else: + self.emotion_effect = QGraphicsOpacityEffect(label) + label.setGraphicsEffect(self.emotion_effect) + self.emotion_effect.setOpacity(0.0) + + # 开始播放动画 + movie.start() + + # 创建淡入动画 + self.emotion_animation = QPropertyAnimation(self.emotion_effect, b"opacity") + self.emotion_animation.setDuration(180) # 淡入时间(毫秒) + self.emotion_animation.setStartValue(0.25) + self.emotion_animation.setEndValue(1.0) + + # 淡入完成后检查是否有下一个待显示的表情 + def on_fade_in_finished(): + self.is_emotion_animating = False + # 如果有下一个待显示的表情,则继续切换 + if self.next_emotion_path: + next_path = self.next_emotion_path + self.next_emotion_path = None + self._set_emotion_gif(label, next_path) + + # 连接淡入完成信号 + self.emotion_animation.finished.connect(on_fade_in_finished) + + # 开始淡入动画 + self.emotion_animation.start() + + except Exception as e: + self.logger.error(f"设置新的GIF动画失败: {e}") + self.is_emotion_animating = False + # 如果设置失败,尝试显示默认表情 + try: + label.setText("😊") + except Exception: + pass + + def _safe_update_label(self, label, text): + """安全地更新标签文本""" + if label and not self.root.isHidden(): + try: + label.setText(text) + except RuntimeError as e: + self.logger.error(f"更新标签失败: {e}") + + def start_update_threads(self): + """启动更新线程""" + # 初始化表情缓存 + self.last_emotion_path = None + + def update_loop(): + while self._running: + try: + # 更新状态 + if self.status_update_callback: + status = self.status_update_callback() + if status: + self.update_status(status) + + # 更新文本 + if self.text_update_callback: + text = self.text_update_callback() + if text: + self.update_text(text) + + # 更新表情 - 只在表情变化时更新 + if self.emotion_update_callback: + emotion = self.emotion_update_callback() + if emotion: + # 直接调用update_emotion方法,它会处理重复检查 + self.update_emotion(emotion) + + except Exception as e: + self.logger.error(f"更新失败: {e}") + time.sleep(0.1) + + threading.Thread(target=update_loop, daemon=True).start() + + def on_close(self): + """关闭窗口处理""" + self._running = False + if self.update_timer: + self.update_timer.stop() + if self.mic_timer: + self.mic_timer.stop() + if self.root: + self.root.close() + self.stop_keyboard_listener() + + def start(self): + """启动GUI""" + try: + # 确保QApplication实例在主线程中创建 + self.app = QApplication.instance() + if self.app is None: + self.app = QApplication(sys.argv) + + # 设置UI默认字体 + default_font = QFont("ASLantTermuxFont Mono", 12) + self.app.setFont(default_font) + + # 加载UI文件 + from PyQt5 import uic + self.root = QWidget() + ui_path = Path(__file__).parent / "gui_display.ui" + if not ui_path.exists(): + self.logger.error(f"UI文件不存在: {ui_path}") + raise FileNotFoundError(f"UI文件不存在: {ui_path}") + + uic.loadUi(str(ui_path), self.root) + + # 获取UI中的控件 + self.status_label = self.root.findChild(QLabel, "status_label") + self.emotion_label = self.root.findChild(QLabel, "emotion_label") + self.tts_text_label = self.root.findChild(QLabel, "tts_text_label") + self.manual_btn = self.root.findChild(QPushButton, "manual_btn") + self.abort_btn = self.root.findChild(QPushButton, "abort_btn") + self.auto_btn = self.root.findChild(QPushButton, "auto_btn") + self.mode_btn = self.root.findChild(QPushButton, "mode_btn") + + # 获取IOT页面控件 + self.iot_card = self.root.findChild(QFrame, "iotPage") # 注意这里使用 "iotPage" 作为ID + if self.iot_card is None: + # 如果找不到 iotPage,尝试其他可能的名称 + self.iot_card = self.root.findChild(QFrame, "iot_card") + if self.iot_card is None: + # 如果还找不到,尝试在 stackedWidget 中获取第二个页面作为 iot_card + self.stackedWidget = self.root.findChild(QStackedWidget, "stackedWidget") + if self.stackedWidget and self.stackedWidget.count() > 1: + self.iot_card = self.stackedWidget.widget(1) # 索引1是第二个页面 + self.logger.info(f"使用 stackedWidget 的第2个页面作为 iot_card: {self.iot_card}") + else: + self.logger.warning("无法找到 iot_card,IOT设备功能将不可用") + else: + self.logger.info(f"找到 iot_card: {self.iot_card}") + + # 音频控制栈组件 + self.audio_control_stack = self.root.findChild(QStackedWidget, "audio_control_stack") + self.volume_page = self.root.findChild(QWidget, "volume_page") + self.mic_page = self.root.findChild(QWidget, "mic_page") + + # 音量控制组件 + self.volume_scale = self.root.findChild(QSlider, "volume_scale") + self.mute = self.root.findChild(QPushButton, "mute") + + if self.mute: + self.mute.setCheckable(True) + self.mute.clicked.connect(self._on_mute_click) + + # 获取或创建音量百分比标签 + self.volume_label = self.root.findChild(QLabel, "volume_label") + if not self.volume_label and self.volume_scale: + # 如果UI中没有音量标签,动态创建一个 + volume_layout = self.root.findChild(QHBoxLayout, "volume_layout") + if volume_layout: + self.volume_label = QLabel(f"{self.current_volume}%") + self.volume_label.setObjectName("volume_label") + self.volume_label.setMinimumWidth(40) + self.volume_label.setAlignment(Qt.AlignCenter) + volume_layout.addWidget(self.volume_label) + + # 初始化麦克风可视化组件 - 使用UI中定义的QFrame + self.mic_visualizer_card = self.root.findChild(QFrame, "mic_visualizer_card") + self.mic_visualizer_widget = self.root.findChild(QWidget, "mic_visualizer_widget") + + if self.mic_visualizer_widget: + # 创建可视化组件实例 + self.mic_visualizer = MicrophoneVisualizer(self.mic_visualizer_widget) + + # 设置布局以使可视化组件填充整个区域 + layout = QVBoxLayout(self.mic_visualizer_widget) + layout.setContentsMargins(0, 0, 0, 0) + layout.addWidget(self.mic_visualizer) + + # 创建更新定时器,但不启动 + self.mic_timer = QTimer() + self.mic_timer.timeout.connect(self._update_mic_visualizer) + + # 根据音量控制可用性设置组件状态 + volume_control_working = self.volume_control_available and not self.volume_controller_failed + if not volume_control_working: + self.logger.warning("系统不支持音量控制或控制失败,音量控制功能已禁用") + # 禁用音量相关控件 + if self.volume_scale: + self.volume_scale.setEnabled(False) + if self.mute: + self.mute.setEnabled(False) + if self.volume_label: + self.volume_label.setText("不可用") + else: + # 正常设置音量滑块初始值 + if self.volume_scale: + self.volume_scale.setRange(0, 100) + self.volume_scale.setValue(self.current_volume) + self.volume_scale.valueChanged.connect(self._on_volume_change) + self.volume_scale.installEventFilter(self) # 安装事件过滤器 + # 更新音量百分比显示 + if self.volume_label: + self.volume_label.setText(f"{self.current_volume}%") + + # 获取设置页面控件 + self.wakeWordEnableSwitch = self.root.findChild(QCheckBox, "wakeWordEnableSwitch") + self.wakeWordsLineEdit = self.root.findChild(QLineEdit, "wakeWordsLineEdit") + self.saveSettingsButton = self.root.findChild(QPushButton, "saveSettingsButton") + # 获取新增的控件 + # 使用 PyQt 标准控件替换 + self.deviceIdLineEdit = self.root.findChild(QLineEdit, "deviceIdLineEdit") + self.wsProtocolComboBox = self.root.findChild(QComboBox, "wsProtocolComboBox") + self.wsAddressLineEdit = self.root.findChild(QLineEdit, "wsAddressLineEdit") + self.wsTokenLineEdit = self.root.findChild(QLineEdit, "wsTokenLineEdit") + # Home Assistant 控件引用 + self.haProtocolComboBox = self.root.findChild(QComboBox, "haProtocolComboBox") + self.ha_server = self.root.findChild(QLineEdit, "ha_server") + self.ha_port = self.root.findChild(QLineEdit, "ha_port") + self.ha_key = self.root.findChild(QLineEdit, "ha_key") + self.Add_ha_devices = self.root.findChild(QPushButton, "Add_ha_devices") + + # 获取 OTA 相关控件 + self.otaProtocolComboBox = self.root.findChild(QComboBox, "otaProtocolComboBox") + self.otaAddressLineEdit = self.root.findChild(QLineEdit, "otaAddressLineEdit") + + # 显式添加 ComboBox 选项,以防 UI 文件加载问题 + if self.wsProtocolComboBox: + # 先清空,避免重复添加 (如果 .ui 文件也成功加载了选项) + self.wsProtocolComboBox.clear() + self.wsProtocolComboBox.addItems(["wss://", "ws://"]) + + # 显式添加OTA ComboBox选项 + if self.otaProtocolComboBox: + self.otaProtocolComboBox.clear() + self.otaProtocolComboBox.addItems(["https://", "http://"]) + + # 显式添加 Home Assistant 协议下拉框选项 + if self.haProtocolComboBox: + self.haProtocolComboBox.clear() + self.haProtocolComboBox.addItems(["http://", "https://"]) + + # 获取导航控件 + self.stackedWidget = self.root.findChild(QStackedWidget, "stackedWidget") + self.nav_tab_bar = self.root.findChild(QTabBar, "nav_tab_bar") + + # 初始化导航标签栏 + self._setup_navigation() + + # 连接按钮事件 + if self.manual_btn: + self.manual_btn.pressed.connect(self._on_manual_button_press) + self.manual_btn.released.connect(self._on_manual_button_release) + if self.abort_btn: + self.abort_btn.clicked.connect(self._on_abort_button_click) + if self.auto_btn: + self.auto_btn.clicked.connect(self._on_auto_button_click) + # 默认隐藏自动模式按钮 + self.auto_btn.hide() + if self.mode_btn: + self.mode_btn.clicked.connect(self._on_mode_button_click) + + # 初始化文本输入框和发送按钮 + self.text_input = self.root.findChild(QLineEdit, "text_input") + self.send_btn = self.root.findChild(QPushButton, "send_btn") + if self.text_input and self.send_btn: + self.send_btn.clicked.connect(self._on_send_button_click) + # 绑定Enter键发送文本 + self.text_input.returnPressed.connect(self._on_send_button_click) + + # 连接设置保存按钮事件 + if self.saveSettingsButton: + self.saveSettingsButton.clicked.connect(self._save_settings) + + # 连接Home Assistant设备导入按钮事件 + if self.Add_ha_devices: + self.Add_ha_devices.clicked.connect(self._on_add_ha_devices_click) + + # 设置鼠标事件 + self.root.mousePressEvent = self.mousePressEvent + self.root.mouseReleaseEvent = self.mouseReleaseEvent + + # 启动键盘监听 + self.start_keyboard_listener() + + # 启动更新线程 + self.start_update_threads() + + # 定时器处理更新队列 + self.update_timer = QTimer() + self.update_timer.timeout.connect(self._process_updates) + self.update_timer.start(100) + + # 在主线程中运行主循环 + self.logger.info("开始启动GUI主循环") + self.root.show() + # self.root.showFullScreen() # 全屏显示 + + except Exception as e: + self.logger.error(f"GUI启动失败: {e}", exc_info=True) + # 尝试回退到CLI模式 + print(f"GUI启动失败: {e},请尝试使用CLI模式") + raise + + def update_mode_button_status(self, text: str): + """更新模式按钮状态""" + self.update_queue.put(lambda: self._safe_update_button(self.mode_btn, text)) + + def update_button_status(self, text: str): + """更新按钮状态 - 保留此方法以满足抽象基类要求""" + # 根据当前模式更新相应的按钮 + if self.auto_mode: + self.update_queue.put(lambda: self._safe_update_button(self.auto_btn, text)) + else: + # 在手动模式下,不通过此方法更新按钮文本 + # 因为按钮文本由按下/释放事件直接控制 + pass + + def _safe_update_button(self, button, text): + """安全地更新按钮文本""" + if button and not self.root.isHidden(): + try: + button.setText(text) + except RuntimeError as e: + self.logger.error(f"更新按钮失败: {e}") + + def _on_volume_change(self, value): + """处理音量滑块变化,使用节流""" + + def update_volume(): + self.update_volume(value) + + # 取消之前的定时器 + if hasattr(self, "volume_update_timer") and self.volume_update_timer and self.volume_update_timer.isActive(): + self.volume_update_timer.stop() + + # 设置新的定时器,300ms 后更新音量 + self.volume_update_timer = QTimer() + self.volume_update_timer.setSingleShot(True) + self.volume_update_timer.timeout.connect(update_volume) + self.volume_update_timer.start(300) + + def update_volume(self, volume: int): + """重写父类的update_volume方法,确保UI同步更新""" + # 检查音量控制是否可用 + if not self.volume_control_available or self.volume_controller_failed: + return + + # 调用父类的update_volume方法更新系统音量 + super().update_volume(volume) + + # 更新UI音量滑块和标签 + if not self.root.isHidden(): + try: + if self.volume_scale: + self.volume_scale.setValue(volume) + if self.volume_label: + self.volume_label.setText(f"{volume}%") + except RuntimeError as e: + self.logger.error(f"更新音量UI失败: {e}") + + def start_keyboard_listener(self): + """启动键盘监听""" + try: + + def on_press(key): + try: + # F2 按键处理 - 在手动模式下处理 + if key == pynput_keyboard.Key.f2 and not self.auto_mode: + if self.button_press_callback: + self.button_press_callback() + if self.manual_btn: + self.update_queue.put(lambda: self._safe_update_button(self.manual_btn, "松开以停止")) + + # F3 按键处理 - 打断 + elif key == pynput_keyboard.Key.f3: + if self.abort_callback: + self.abort_callback() + except Exception as e: + self.logger.error(f"键盘事件处理错误: {e}") + + def on_release(key): + try: + # F2 释放处理 - 在手动模式下处理 + if key == pynput_keyboard.Key.f2 and not self.auto_mode: + if self.button_release_callback: + self.button_release_callback() + if self.manual_btn: + self.update_queue.put(lambda: self._safe_update_button(self.manual_btn, "按住后说话")) + except Exception as e: + self.logger.error(f"键盘事件处理错误: {e}") + + # 创建并启动监听器 + self.keyboard_listener = pynput_keyboard.Listener( + on_press=on_press, on_release=on_release + ) + self.keyboard_listener.start() + self.logger.info("键盘监听器初始化成功") + except Exception as e: + self.logger.error(f"键盘监听器初始化失败: {e}") + + def stop_keyboard_listener(self): + """停止键盘监听""" + if self.keyboard_listener: + try: + self.keyboard_listener.stop() + self.keyboard_listener = None + self.logger.info("键盘监听器已停止") + except Exception as e: + self.logger.error(f"停止键盘监听器失败: {e}") + + def mousePressEvent(self, event: QMouseEvent): + """鼠标按下事件处理""" + if event.button() == Qt.LeftButton: + self.last_mouse_pos = event.pos() + + def mouseReleaseEvent(self, event: QMouseEvent): + """鼠标释放事件处理 (修改为使用 QTabBar 索引)""" + if event.button() == Qt.LeftButton and self.last_mouse_pos is not None: + delta = event.pos().x() - self.last_mouse_pos.x() + self.last_mouse_pos = None + + if abs(delta) > 100: # 滑动阈值 + current_index = self.nav_tab_bar.currentIndex() if self.nav_tab_bar else 0 + tab_count = self.nav_tab_bar.count() if self.nav_tab_bar else 0 + + if delta > 0 and current_index > 0: # 右滑 + new_index = current_index - 1 + if self.nav_tab_bar: self.nav_tab_bar.setCurrentIndex(new_index) + elif delta < 0 and current_index < tab_count - 1: # 左滑 + new_index = current_index + 1 + if self.nav_tab_bar: self.nav_tab_bar.setCurrentIndex(new_index) + + def _on_mute_click(self): + """静音按钮点击事件处理 (使用 isChecked 状态)""" + try: + if not self.volume_control_available or self.volume_controller_failed or not self.mute: + return + + self.is_muted = self.mute.isChecked() # 获取按钮的选中状态 + + if self.is_muted: + # 保存当前音量并设置为0 + self.pre_mute_volume = self.current_volume + self.update_volume(0) + self.mute.setText("取消静音") # 更新文本 + if self.volume_label: + self.volume_label.setText("静音") # 或者 "0%" + else: + # 恢复之前的音量 + self.update_volume(self.pre_mute_volume) + self.mute.setText("点击静音") # 恢复文本 + if self.volume_label: + self.volume_label.setText(f"{self.pre_mute_volume}%") + + except Exception as e: + self.logger.error(f"静音按钮点击事件处理失败: {e}") + + def _load_settings(self): + """加载配置文件并更新设置页面UI (使用ConfigManager)""" + try: + # 使用ConfigManager获取配置 + config_manager = ConfigManager.get_instance() + + # 获取唤醒词配置 + use_wake_word = config_manager.get_config("WAKE_WORD_OPTIONS.USE_WAKE_WORD", False) + wake_words = config_manager.get_config("WAKE_WORD_OPTIONS.WAKE_WORDS", []) + + if self.wakeWordEnableSwitch: + self.wakeWordEnableSwitch.setChecked(use_wake_word) + + if self.wakeWordsLineEdit: + self.wakeWordsLineEdit.setText(", ".join(wake_words)) + + # 获取系统选项 + device_id = config_manager.get_config("SYSTEM_OPTIONS.DEVICE_ID", "") + websocket_url = config_manager.get_config("SYSTEM_OPTIONS.NETWORK.WEBSOCKET_URL", "") + websocket_token = config_manager.get_config("SYSTEM_OPTIONS.NETWORK.WEBSOCKET_ACCESS_TOKEN", "") + ota_url = config_manager.get_config("SYSTEM_OPTIONS.NETWORK.OTA_VERSION_URL", "") + + if self.deviceIdLineEdit: + self.deviceIdLineEdit.setText(device_id) + + # 解析 WebSocket URL 并设置协议和地址 + if websocket_url and self.wsProtocolComboBox and self.wsAddressLineEdit: + try: + parsed_url = urlparse(websocket_url) + protocol = parsed_url.scheme + + # 保留URL末尾的斜杠 + address = parsed_url.netloc + parsed_url.path + + # 确保地址不以协议开头 + if address.startswith(f"{protocol}://"): + address = address[len(f"{protocol}://"):] + + index = self.wsProtocolComboBox.findText(f"{protocol}://", Qt.MatchFixedString) + if index >= 0: + self.wsProtocolComboBox.setCurrentIndex(index) + else: + self.logger.warning(f"未知的 WebSocket 协议: {protocol}") + self.wsProtocolComboBox.setCurrentIndex(0) # 默认为 wss + + self.wsAddressLineEdit.setText(address) + except Exception as e: + self.logger.error(f"解析 WebSocket URL 时出错: {websocket_url} - {e}") + self.wsProtocolComboBox.setCurrentIndex(0) + self.wsAddressLineEdit.clear() + + if self.wsTokenLineEdit: + self.wsTokenLineEdit.setText(websocket_token) + + # 解析OTA URL并设置协议和地址 + if ota_url and self.otaProtocolComboBox and self.otaAddressLineEdit: + try: + parsed_url = urlparse(ota_url) + protocol = parsed_url.scheme + + # 保留URL末尾的斜杠 + address = parsed_url.netloc + parsed_url.path + + # 确保地址不以协议开头 + if address.startswith(f"{protocol}://"): + address = address[len(f"{protocol}://"):] + + if protocol == "https": + self.otaProtocolComboBox.setCurrentIndex(0) + elif protocol == "http": + self.otaProtocolComboBox.setCurrentIndex(1) + else: + self.logger.warning(f"未知的OTA协议: {protocol}") + self.otaProtocolComboBox.setCurrentIndex(0) # 默认为https + + self.otaAddressLineEdit.setText(address) + except Exception as e: + self.logger.error(f"解析OTA URL时出错: {ota_url} - {e}") + self.otaProtocolComboBox.setCurrentIndex(0) + self.otaAddressLineEdit.clear() + + # 加载Home Assistant配置 + ha_options = config_manager.get_config("HOME_ASSISTANT", {}) + ha_url = ha_options.get("URL", "") + ha_token = ha_options.get("TOKEN", "") + + # 解析Home Assistant URL并设置协议和地址 + if ha_url and self.haProtocolComboBox and self.ha_server: + try: + parsed_url = urlparse(ha_url) + protocol = parsed_url.scheme + port = parsed_url.port + # 地址部分不包含端口 + address = parsed_url.netloc + if ":" in address: # 如果地址中包含端口号 + address = address.split(":")[0] + + # 设置协议 + if protocol == "https": + self.haProtocolComboBox.setCurrentIndex(1) + else: # http或其他协议,默认http + self.haProtocolComboBox.setCurrentIndex(0) + + # 设置地址 + self.ha_server.setText(address) + + # 设置端口(如果有) + if port and self.ha_port: + self.ha_port.setText(str(port)) + except Exception as e: + self.logger.error(f"解析Home Assistant URL时出错: {ha_url} - {e}") + # 出错时使用默认值 + self.haProtocolComboBox.setCurrentIndex(0) # 默认为http + self.ha_server.clear() + + # 设置Home Assistant Token + if self.ha_key: + self.ha_key.setText(ha_token) + + except Exception as e: + self.logger.error(f"加载配置文件时出错: {e}", exc_info=True) + QMessageBox.critical(self.root, "错误", f"加载设置失败: {e}") + + def _save_settings(self): + """保存设置页面的更改到配置文件 (使用ConfigManager)""" + try: + # 使用ConfigManager获取实例 + config_manager = ConfigManager.get_instance() + + # 收集所有UI界面上的配置值 + # 唤醒词配置 + use_wake_word = self.wakeWordEnableSwitch.isChecked() if self.wakeWordEnableSwitch else False + wake_words_text = self.wakeWordsLineEdit.text() if self.wakeWordsLineEdit else "" + wake_words = [word.strip() for word in wake_words_text.split(',') if word.strip()] + + # 系统选项 + new_device_id = self.deviceIdLineEdit.text() if self.deviceIdLineEdit else "" + selected_protocol_text = self.wsProtocolComboBox.currentText() if self.wsProtocolComboBox else "wss://" + selected_protocol = selected_protocol_text.replace("://", "") + new_ws_address = self.wsAddressLineEdit.text() if self.wsAddressLineEdit else "" + new_ws_token = self.wsTokenLineEdit.text() if self.wsTokenLineEdit else "" + + # OTA地址配置 + selected_ota_protocol_text = self.otaProtocolComboBox.currentText() if self.otaProtocolComboBox else "https://" + selected_ota_protocol = selected_ota_protocol_text.replace("://", "") + new_ota_address = self.otaAddressLineEdit.text() if self.otaAddressLineEdit else "" + + # 确保地址不以 / 开头 + if new_ws_address.startswith('/'): + new_ws_address = new_ws_address[1:] + + # 构造WebSocket URL + new_websocket_url = f"{selected_protocol}://{new_ws_address}" + if new_websocket_url and not new_websocket_url.endswith('/'): + new_websocket_url += '/' + + # 构造OTA URL + new_ota_url = f"{selected_ota_protocol}://{new_ota_address}" + if new_ota_url and not new_ota_url.endswith('/'): + new_ota_url += '/' + + # Home Assistant配置 + ha_protocol = self.haProtocolComboBox.currentText().replace("://", "") if self.haProtocolComboBox else "http" + ha_server = self.ha_server.text() if self.ha_server else "" + ha_port = self.ha_port.text() if self.ha_port else "" + ha_key = self.ha_key.text() if self.ha_key else "" + + # 构建Home Assistant URL + if ha_server: + ha_url = f"{ha_protocol}://{ha_server}" + if ha_port: + ha_url += f":{ha_port}" + else: + ha_url = "" + + # 获取完整的当前配置 + current_config = config_manager._config.copy() + + # 直接从磁盘读取最新的config.json,获取最新的设备列表 + try: + import json + config_path = Path(__file__).parent.parent.parent / "config" / "config.json" + if config_path.exists(): + with open(config_path, 'r', encoding='utf-8') as f: + disk_config = json.load(f) + + # 获取磁盘上最新的设备列表 + if ("HOME_ASSISTANT" in disk_config and + "DEVICES" in disk_config["HOME_ASSISTANT"]): + # 使用磁盘上的设备列表 + latest_devices = disk_config["HOME_ASSISTANT"]["DEVICES"] + self.logger.info(f"从配置文件读取了 {len(latest_devices)} 个设备") + else: + latest_devices = [] + else: + latest_devices = [] + except Exception as e: + self.logger.error(f"读取配置文件中的设备列表失败: {e}") + # 如果读取失败,使用内存中的设备列表 + if "HOME_ASSISTANT" in current_config and "DEVICES" in current_config["HOME_ASSISTANT"]: + latest_devices = current_config["HOME_ASSISTANT"]["DEVICES"] + else: + latest_devices = [] + + # 更新配置对象(不写入文件) + # 1. 更新唤醒词配置 + if "WAKE_WORD_OPTIONS" not in current_config: + current_config["WAKE_WORD_OPTIONS"] = {} + current_config["WAKE_WORD_OPTIONS"]["USE_WAKE_WORD"] = use_wake_word + current_config["WAKE_WORD_OPTIONS"]["WAKE_WORDS"] = wake_words + + # 2. 更新系统选项 + if "SYSTEM_OPTIONS" not in current_config: + current_config["SYSTEM_OPTIONS"] = {} + current_config["SYSTEM_OPTIONS"]["DEVICE_ID"] = new_device_id + + if "NETWORK" not in current_config["SYSTEM_OPTIONS"]: + current_config["SYSTEM_OPTIONS"]["NETWORK"] = {} + current_config["SYSTEM_OPTIONS"]["NETWORK"]["WEBSOCKET_URL"] = new_websocket_url + current_config["SYSTEM_OPTIONS"]["NETWORK"]["WEBSOCKET_ACCESS_TOKEN"] = new_ws_token + current_config["SYSTEM_OPTIONS"]["NETWORK"]["OTA_VERSION_URL"] = new_ota_url + + # 3. 更新Home Assistant配置 + if "HOME_ASSISTANT" not in current_config: + current_config["HOME_ASSISTANT"] = {} + current_config["HOME_ASSISTANT"]["URL"] = ha_url + current_config["HOME_ASSISTANT"]["TOKEN"] = ha_key + + # 使用最新的设备列表 + current_config["HOME_ASSISTANT"]["DEVICES"] = latest_devices + + # 一次性保存整个配置 + save_success = config_manager._save_config(current_config) + + if save_success: + self.logger.info("设置已成功保存到 config.json") + reply = QMessageBox.question(self.root, "保存成功", + "设置已保存。\n部分设置需要重启应用程序才能生效。\n\n是否立即重启?", + QMessageBox.Yes | QMessageBox.No, QMessageBox.No) + + if reply == QMessageBox.Yes: + self.logger.info("用户选择重启应用程序。") + restart_program() + else: + raise Exception("保存配置文件失败") + + except Exception as e: + self.logger.error(f"保存设置时发生未知错误: {e}", exc_info=True) + QMessageBox.critical(self.root, "错误", f"保存设置失败: {e}") + + def _on_add_ha_devices_click(self): + """处理添加Home Assistant设备按钮点击事件""" + try: + self.logger.info("启动Home Assistant设备管理器...") + + # 获取当前文件所在目录 + current_dir = os.path.dirname(os.path.abspath(__file__)) + # 获取项目根目录(假设current_dir是src/display,上级目录就是src,再上级就是项目根目录) + project_root = os.path.dirname(os.path.dirname(current_dir)) + + # 获取脚本路径 + script_path = os.path.join(project_root, "scripts", "ha_device_manager_ui.py") + + if not os.path.exists(script_path): + self.logger.error(f"设备管理器脚本不存在: {script_path}") + QMessageBox.critical(self.root, "错误", "设备管理器脚本不存在") + return + + # 构建命令并执行 + cmd = [sys.executable, script_path] + + # 使用subprocess启动新进程 + import subprocess + subprocess.Popen(cmd) + + except Exception as e: + self.logger.error(f"启动Home Assistant设备管理器失败: {e}", exc_info=True) + QMessageBox.critical(self.root, "错误", f"启动设备管理器失败: {e}") + + def _update_mic_visualizer(self): + """更新麦克风可视化""" + if not self.is_listening or not self.mic_visualizer: + return + + try: + # 获取当前麦克风音量级别,范围0-1 + volume_level = self._get_current_mic_level() + + # 更新可视化组件 + self.mic_visualizer.set_volume(min(1.0, volume_level)) + except Exception as e: + self.logger.error(f"更新麦克风可视化失败: {e}") + + def _get_current_mic_level(self): + """获取当前麦克风音量级别""" + try: + from src.application import Application + app = Application.get_instance() + if app and hasattr(app, 'audio_codec') and app.audio_codec: + # 从音频编解码器获取原始音频数据 + if hasattr(app.audio_codec, 'input_stream') and app.audio_codec.input_stream: + # 读取音频数据并计算音量级别 + try: + # 获取输入流中可读取的数据量 + available = app.audio_codec.input_stream.get_read_available() + if available > 0: + # 读取一小块数据用于计算音量 + chunk_size = min(1024, available) + audio_data = app.audio_codec.input_stream.read( + chunk_size, + exception_on_overflow=False + ) + + # 将字节数据转换为numpy数组进行处理 + audio_array = np.frombuffer(audio_data, dtype=np.int16) + + # 计算音量级别 (0.0-1.0) + # 16位音频的最大值是32768,计算音量占最大值的比例 + # 使用均方根(RMS)值计算有效音量 + rms = np.sqrt(np.mean(np.square(audio_array.astype(np.float32)))) + # 标准化为0-1范围,32768是16位音频的最大值 + # 增加放大系数以提高灵敏度 + volume = min(1.0, rms / 32768 * 10) # 放大10倍使小音量更明显 + + # 应用平滑处理 + if hasattr(self, '_last_volume'): + # 平滑过渡,保留70%上次数值,增加30%新数值 + self._last_volume = self._last_volume * 0.7 + volume * 0.3 + else: + self._last_volume = volume + + return self._last_volume + except Exception as e: + self.logger.debug(f"读取麦克风数据失败: {e}") + except Exception as e: + self.logger.debug(f"获取麦克风音量失败: {e}") + + # 如果无法获取实际音量,返回上次的音量或默认值 + if hasattr(self, '_last_volume'): + # 缓慢衰减上次的音量 + self._last_volume *= 0.9 + return self._last_volume + else: + self._last_volume = 0.0 # 初始化为 0 + return self._last_volume + + def _start_mic_visualization(self): + """开始麦克风可视化""" + if self.mic_visualizer and self.mic_timer and self.audio_control_stack: + self.is_listening = True + + # 切换到麦克风可视化页面 + self.audio_control_stack.setCurrentWidget(self.mic_page) + + # 启动定时器更新可视化 + if not self.mic_timer.isActive(): + self.mic_timer.start(50) # 20fps + + def _stop_mic_visualization(self): + """停止麦克风可视化""" + self.is_listening = False + + # 停止定时器 + if self.mic_timer and self.mic_timer.isActive(): + self.mic_timer.stop() + # 重置可视化音量 + if self.mic_visualizer: + self.mic_visualizer.set_volume(0.0) + # 确保动画平滑过渡到0 + if hasattr(self, '_last_volume'): + self._last_volume = 0.0 + + # 切换回音量控制页面 + if self.audio_control_stack: + self.audio_control_stack.setCurrentWidget(self.volume_page) + + def _on_send_button_click(self): + """处理发送文本按钮点击事件""" + if not self.text_input or not self.send_text_callback: + return + + text = self.text_input.text().strip() + if not text: + return + + # 清空输入框 + self.text_input.clear() + + # 获取应用程序的事件循环并在其中运行协程 + from src.application import Application + app = Application.get_instance() + if app and app.loop: + import asyncio + asyncio.run_coroutine_threadsafe( + self.send_text_callback(text), + app.loop + ) + else: + self.logger.error("应用程序实例或事件循环不可用") + + def _load_iot_devices(self): + """加载并显示Home Assistant设备列表""" + try: + # 先清空现有设备列表 + if hasattr(self, 'devices_list') and self.devices_list: + for widget in self.devices_list: + widget.deleteLater() + self.devices_list = [] + + # 清空设备状态标签引用 + self.device_labels = {} + + # 获取设备布局 + if self.iot_card: + # 记录原来的标题文本,以便后面重新设置 + title_text = "" + if self.history_title: + title_text = self.history_title.text() + + # 设置self.history_title为None,以避免在清除旧布局时被删除导致引用错误 + self.history_title = None + + # 获取原布局并删除所有子控件 + old_layout = self.iot_card.layout() + if old_layout: + # 清空布局中的所有控件 + while old_layout.count(): + item = old_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + + # 在现有布局中重新添加控件,而不是创建新布局 + new_layout = old_layout + else: + # 如果没有现有布局,则创建一个新的 + new_layout = QVBoxLayout() + self.iot_card.setLayout(new_layout) + + # 重置布局属性 + new_layout.setContentsMargins(2, 2, 2, 2) # 进一步减小外边距 + new_layout.setSpacing(2) # 进一步减小控件间距 + + # 创建标题 + self.history_title = QLabel(title_text) + self.history_title.setFont(QFont(self.app.font().family(), 12)) # 字体缩小 + self.history_title.setAlignment(Qt.AlignCenter) # 居中对齐 + self.history_title.setContentsMargins(5, 2, 0, 2) # 设置标题的边距 + self.history_title.setMaximumHeight(25) # 减小标题高度 + new_layout.addWidget(self.history_title) + + # 尝试从配置文件加载设备列表 + try: + with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + devices = config_data.get("HOME_ASSISTANT", {}).get("DEVICES", []) + + # 更新标题 + self.history_title.setText(f"已连接设备 ({len(devices)})") + + # 创建滚动区域 + scroll_area = QScrollArea() + scroll_area.setWidgetResizable(True) + scroll_area.setFrameShape(QFrame.NoFrame) # 移除边框 + scroll_area.setStyleSheet("background: transparent;") # 透明背景 + + # 创建滚动区域的内容容器 + container = QWidget() + container.setStyleSheet("background: transparent;") # 透明背景 + + # 创建网格布局,设置顶部对齐 + grid_layout = QGridLayout(container) + grid_layout.setContentsMargins(3, 3, 3, 3) # 增加外边距 + grid_layout.setSpacing(8) # 增加网格间距 + grid_layout.setAlignment(Qt.AlignTop) # 设置顶部对齐 + + # 设置网格每行显示的卡片数量 + cards_per_row = 3 # 每行显示3个设备卡片 + + # 遍历设备并添加到网格布局 + for i, device in enumerate(devices): + entity_id = device.get('entity_id', '') + friendly_name = device.get('friendly_name', '') + + # 解析friendly_name - 提取位置和设备名称 + location = friendly_name + device_name = "" + if ',' in friendly_name: + parts = friendly_name.split(',', 1) + location = parts[0].strip() + device_name = parts[1].strip() + + # 创建设备卡片 (使用QFrame替代CardWidget) + device_card = QFrame() + device_card.setMinimumHeight(90) # 增加最小高度 + device_card.setMaximumHeight(150) # 增加最大高度以适应换行文本 + device_card.setMinimumWidth(200) # 增加宽度 + device_card.setProperty("entity_id", entity_id) # 存储entity_id + # 设置卡片样式 - 轻微背景色,圆角,阴影效果 + device_card.setStyleSheet(""" + QFrame { + border-radius: 5px; + background-color: rgba(255, 255, 255, 0.7); + border: none; + } + """) + + card_layout = QVBoxLayout(device_card) + card_layout.setContentsMargins(10, 8, 10, 8) # 内边距 + card_layout.setSpacing(2) # 控件间距 + + # 设备名称 - 显示在第一行(加粗)并允许换行 + device_name_label = QLabel(f"{device_name}") + device_name_label.setFont(QFont(self.app.font().family(), 14)) + device_name_label.setWordWrap(True) # 启用自动换行 + device_name_label.setMinimumHeight(20) # 设置最小高度 + device_name_label.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Minimum) # 水平扩展,垂直最小 + card_layout.addWidget(device_name_label) + + # 设备位置 - 显示在第二行(不加粗) + location_label = QLabel(f"{location}") + location_label.setFont(QFont(self.app.font().family(), 12)) + location_label.setStyleSheet("color: #666666;") + card_layout.addWidget(location_label) + + # 添加分隔线 + line = QFrame() + line.setFrameShape(QFrame.HLine) + line.setFrameShadow(QFrame.Sunken) + line.setStyleSheet("background-color: #E0E0E0;") + line.setMaximumHeight(1) + card_layout.addWidget(line) + + # 设备状态 - 根据设备类型设置不同的默认状态 + state_text = "未知" + if "light" in entity_id: + state_text = "关闭" + status_display = f"状态: {state_text}" + elif "sensor" in entity_id: + if "temperature" in entity_id: + state_text = "0℃" + status_display = state_text + elif "humidity" in entity_id: + state_text = "0%" + status_display = state_text + else: + state_text = "正常" + status_display = f"状态: {state_text}" + elif "switch" in entity_id: + state_text = "关闭" + status_display = f"状态: {state_text}" + elif "button" in entity_id: + state_text = "可用" + status_display = f"状态: {state_text}" + else: + status_display = state_text + + # 直接显示状态值 + state_label = QLabel(status_display) + state_label.setFont(QFont(self.app.font().family(), 14)) + state_label.setStyleSheet("color: #2196F3; border: none;") # 添加无边框样式 + card_layout.addWidget(state_label) + + # 保存状态标签引用 + self.device_labels[entity_id] = state_label + + # 计算行列位置 + row = i // cards_per_row + col = i % cards_per_row + + # 将卡片添加到网格布局 + grid_layout.addWidget(device_card, row, col) + + # 保存引用以便后续清理 + self.devices_list.append(device_card) + + # 设置滚动区域内容 + container.setLayout(grid_layout) + scroll_area.setWidget(container) + + # 将滚动区域添加到主布局 + new_layout.addWidget(scroll_area) + + # 设置滚动区域样式 + scroll_area.setStyleSheet(""" + QScrollArea { + border: none; + background-color: transparent; + } + QScrollBar:vertical { + border: none; + background-color: #F5F5F5; + width: 8px; + border-radius: 4px; + } + QScrollBar::handle:vertical { + background-color: #BDBDBD; + border-radius: 4px; + } + QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { + height: 0px; + } + """) + + # 停止现有的更新定时器(如果存在) + if self.ha_update_timer and self.ha_update_timer.isActive(): + self.ha_update_timer.stop() + + # 创建并启动一个定时器,每1秒更新一次设备状态 + self.ha_update_timer = QTimer() + self.ha_update_timer.timeout.connect(self._update_device_states) + self.ha_update_timer.start(1000) # 1秒更新一次 + + # 立即执行一次更新 + self._update_device_states() + + except Exception as e: + # 如果加载设备失败,创建一个错误提示布局 + self.logger.error(f"读取设备配置失败: {e}") + self.history_title = QLabel("加载设备配置失败") + self.history_title.setFont(QFont(self.app.font().family(), 14, QFont.Bold)) + self.history_title.setAlignment(Qt.AlignCenter) + new_layout.addWidget(self.history_title) + + error_label = QLabel(f"错误信息: {str(e)}") + error_label.setWordWrap(True) + error_label.setStyleSheet("color: red;") + new_layout.addWidget(error_label) + + except Exception as e: + self.logger.error(f"加载IOT设备失败: {e}", exc_info=True) + try: + # 在发生错误时尝试恢复界面 + old_layout = self.iot_card.layout() + + # 如果已有布局,清空它 + if old_layout: + while old_layout.count(): + item = old_layout.takeAt(0) + widget = item.widget() + if widget: + widget.deleteLater() + + # 使用现有布局 + new_layout = old_layout + else: + # 创建新布局 + new_layout = QVBoxLayout() + self.iot_card.setLayout(new_layout) + + self.history_title = QLabel("加载设备失败") + self.history_title.setFont(QFont(self.app.font().family(), 14, QFont.Bold)) + self.history_title.setAlignment(Qt.AlignCenter) + new_layout.addWidget(self.history_title) + + error_label = QLabel(f"错误信息: {str(e)}") + error_label.setWordWrap(True) + error_label.setStyleSheet("color: red;") + new_layout.addWidget(error_label) + + except Exception as e2: + self.logger.error(f"恢复界面失败: {e2}", exc_info=True) + + def _update_device_states(self): + """更新Home Assistant设备状态""" + # 检查当前是否在IOT界面 + if not self.stackedWidget or self.stackedWidget.currentIndex() != 1: + return + + # 读取配置文件获取Home Assistant连接信息 + try: + with open(CONFIG_PATH, 'r', encoding='utf-8') as f: + config_data = json.load(f) + + ha_options = config_data.get("HOME_ASSISTANT", {}) + ha_url = ha_options.get("URL", "") + ha_token = ha_options.get("TOKEN", "") + + if not ha_url or not ha_token: + self.logger.warning("Home Assistant URL或Token未配置,无法更新设备状态") + return + + # 为每个设备查询状态 + for entity_id, label in self.device_labels.items(): + threading.Thread( + target=self._fetch_device_state, + args=(ha_url, ha_token, entity_id, label), + daemon=True + ).start() + + except Exception as e: + self.logger.error(f"更新Home Assistant设备状态失败: {e}", exc_info=True) + + def _fetch_device_state(self, ha_url, ha_token, entity_id, label): + """获取单个设备的状态""" + import requests + + try: + # 构造API请求URL + api_url = f"{ha_url}/api/states/{entity_id}" + headers = { + "Authorization": f"Bearer {ha_token}", + "Content-Type": "application/json" + } + + # 发送请求 + response = requests.get(api_url, headers=headers, timeout=5) + + if response.status_code == 200: + state_data = response.json() + state = state_data.get("state", "unknown") + + # 更新设备状态 + self.device_states[entity_id] = state + + # 更新UI + self._update_device_ui(entity_id, state, label) + else: + self.logger.warning(f"获取设备状态失败: {entity_id}, 状态码: {response.status_code}") + + except requests.RequestException as e: + self.logger.error(f"请求Home Assistant API失败: {e}") + except Exception as e: + self.logger.error(f"处理设备状态时出错: {e}") + + def _update_device_ui(self, entity_id, state, label): + """更新设备UI显示""" + # 在主线程中执行UI更新 + self.update_queue.put(lambda: self._safe_update_device_label(entity_id, state, label)) + + def _safe_update_device_label(self, entity_id, state, label): + """安全地更新设备状态标签""" + if not label or self.root.isHidden(): + return + + try: + display_state = state # 默认显示原始状态 + + # 根据设备类型格式化状态显示 + if "light" in entity_id or "switch" in entity_id: + if state == "on": + display_state = "状态: 开启" + label.setStyleSheet("color: #4CAF50; border: none;") # 绿色表示开启,无边框 + else: + display_state = "状态: 关闭" + label.setStyleSheet("color: #9E9E9E; border: none;") # 灰色表示关闭,无边框 + elif "temperature" in entity_id: + try: + temp = float(state) + display_state = f"{temp:.1f}℃" + label.setStyleSheet("color: #FF9800; border: none;") # 橙色表示温度,无边框 + except ValueError: + display_state = state + elif "humidity" in entity_id: + try: + humidity = float(state) + display_state = f"{humidity:.0f}%" + label.setStyleSheet("color: #03A9F4; border: none;") # 浅蓝色表示湿度,无边框 + except ValueError: + display_state = state + elif "battery" in entity_id: + try: + battery = float(state) + display_state = f"{battery:.0f}%" + # 根据电池电量设置不同颜色 + if battery < 20: + label.setStyleSheet("color: #F44336; border: none;") # 红色表示低电量,无边框 + else: + label.setStyleSheet("color: #4CAF50; border: none;") # 绿色表示正常电量,无边框 + except ValueError: + display_state = state + else: + display_state = f"状态: {state}" + label.setStyleSheet("color: #2196F3; border: none;") # 默认颜色,无边框 + + # 显示状态值 + label.setText(f"{display_state}") + except RuntimeError as e: + self.logger.error(f"更新设备状态标签失败: {e}") + +class MicrophoneVisualizer(QFrame): + """麦克风音量可视化组件 - 波形显示版""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(50) + self.setFrameShape(QFrame.NoFrame) + + # 初始化音量数据 + self.current_volume = 0.0 + self.target_volume = 0.0 + + # 波形历史数据(用于绘制波形图)- 增加历史点数使波形更平滑 + self.history_max = 30 # 增加历史数据点数量 + self.volume_history = [0.0] * self.history_max + + # 创建平滑动画效果 + self.animation_timer = QTimer() + self.animation_timer.timeout.connect(self._update_animation) + self.animation_timer.start(16) # 约60fps + + # 颜色设置 + self.min_color = QColor(80, 150, 255) # 低音量时的颜色 (蓝色) + self.max_color = QColor(255, 100, 100) # 高音量时的颜色 (红色) + self.current_color = self.min_color.name() + + # 添加状态持续时间计数器,防止状态频繁变化 + self.current_status = "安静" # 当前显示的状态 + self.target_status = "安静" # 目标状态 + self.status_hold_count = 0 # 状态保持计数器 + self.status_threshold = 5 # 状态变化阈值(必须连续5帧达到阈值才切换状态) + + # 透明背景 + self.setStyleSheet("background-color: transparent;") + + def set_volume(self, volume): + """设置当前音量,范围0.0-1.0""" + # 确保音量在有效范围内 + volume = max(0.0, min(1.0, volume)) + self.target_volume = volume + + # 更新历史数据(添加新值并移除最旧的值) + self.volume_history.append(volume) + if len(self.volume_history) > self.history_max: + self.volume_history.pop(0) + + # 更新状态文本(带状态变化滞后) + volume_percent = int(volume * 100) + + # 根据音量级别确定目标状态 + if volume_percent < 5: + new_status = "静音" + elif volume_percent < 20: + new_status = "安静" + elif volume_percent < 50: + new_status = "正常" + elif volume_percent < 75: + new_status = "较大" + else: + new_status = "很大" + + # 状态切换逻辑(带滞后性) + if new_status == self.target_status: + # 相同状态,增加计数 + self.status_hold_count += 1 + else: + # 不同状态,重置为新状态 + self.target_status = new_status + self.status_hold_count = 0 + + # 只有当状态持续一定时间后才切换显示状态 + if self.status_hold_count >= self.status_threshold: + self.current_status = self.target_status + + self.update() # 触发重绘 + + def _update_animation(self): + """更新动画效果""" + # 平滑过渡到目标音量 - 提高响应性 + self.current_volume += (self.target_volume - self.current_volume) * 0.3 + + # 计算颜色过渡 + r = int(self.min_color.red() + (self.max_color.red() - self.min_color.red()) * self.current_volume) + g = int(self.min_color.green() + (self.max_color.green() - self.min_color.green()) * self.current_volume) + b = int(self.min_color.blue() + (self.max_color.blue() - self.min_color.blue()) * self.current_volume) + self.current_color = QColor(r, g, b).name() + + self.update() + + def paintEvent(self, event): + """绘制事件""" + super().paintEvent(event) + + painter = QPainter(self) + painter.setRenderHint(QPainter.Antialiasing) + + try: + # 获取绘制区域 + rect = self.rect() + + # 绘制波形图 + self._draw_waveform(painter, rect) + + # 添加音量状态文本 + small_font = painter.font() + small_font.setPointSize(10) + painter.setFont(small_font) + painter.setPen(QColor(100, 100, 100)) + + # 在底部显示状态文本 + status_rect = QRect(rect.left(), rect.bottom() - 20, rect.width(), 20) + + # 使用稳定的状态文本显示 + status_text = f"声音: {self.current_status}" + + painter.drawText(status_rect, Qt.AlignCenter, status_text) + except Exception as e: + self.logger.error(f"绘制波形图失败: {e}") if hasattr(self, 'logger') else None + finally: + painter.end() + + def _draw_waveform(self, painter, rect): + """绘制波形图""" + # 如果没有足够的历史数据,返回 + if len(self.volume_history) < 2: + return + + # 波形图区域 - 扩大为几乎整个控件 + wave_rect = QRect(rect.left() + 10, rect.top() + 10, + rect.width() - 20, rect.height() - 40) + + # 设置半透明背景 + bg_color = QColor(240, 240, 240, 30) + painter.setPen(Qt.NoPen) + painter.setBrush(QBrush(bg_color)) + painter.drawRoundedRect(wave_rect, 5, 5) + + # 设置波形图线条样式 + wave_pen = QPen(QColor(self.current_color)) + wave_pen.setWidth(2) + painter.setPen(wave_pen) + + # 计算波形图点 + history_len = len(self.volume_history) + point_interval = wave_rect.width() / (history_len - 1) + + # 创建波形图路径 + path = QPainterPath() + + # 波形图起点(从左下角开始) + start_x = wave_rect.left() + mid_y = wave_rect.top() + wave_rect.height() / 2 + + # 平滑波形显示 - 减小振幅变化,让无声和小声时波形更平稳 + amplitude_factor = 0.8 # 振幅因子 + min_amplitude = 0.1 # 最小振幅(确保有轻微波动) + + # 计算第一个点的y坐标 + vol = self.volume_history[0] + amp = max(min_amplitude, vol) * amplitude_factor # 确保最小振幅 + start_y = mid_y - amp * wave_rect.height() / 2 + + path.moveTo(start_x, start_y) + + # 添加波形点 + for i in range(1, history_len): + x = start_x + i * point_interval + + # 获取当前音量 + vol = self.volume_history[i] + + # 计算振幅(上下波动),确保最小振幅以保持波形的可见性 + amp = max(min_amplitude, vol) * amplitude_factor + + # 添加正弦波动,使波形更自然 + wave_phase = i / 2.0 # 波相位 + sine_factor = 0.08 * amp # 正弦波因子随音量变化 + sine_wave = sine_factor * np.sin(wave_phase) + + y = mid_y - (amp * wave_rect.height() / 2 + sine_wave * wave_rect.height()) + + # 使用曲线连接点,使波形更平滑 + if i > 1: + # 使用二次贝塞尔曲线,需要一个控制点 + ctrl_x = start_x + (i - 0.5) * point_interval + prev_vol = self.volume_history[i-1] + prev_amp = max(min_amplitude, prev_vol) * amplitude_factor + prev_sine = sine_factor * np.sin((i-1) / 2.0) + ctrl_y = mid_y - (prev_amp * wave_rect.height() / 2 + prev_sine * wave_rect.height()) + path.quadTo(ctrl_x, ctrl_y, x, y) + else: + # 第一个点直接连接 + path.lineTo(x, y) + + # 绘制波形路径 + painter.drawPath(path) + + # 添加渐变效果 + # 创建从波形底部到顶部的渐变 + gradient = QLinearGradient( + wave_rect.left(), wave_rect.top() + wave_rect.height(), + wave_rect.left(), wave_rect.top() + ) + + # 根据当前音量设置渐变颜色 + gradient.setColorAt(0, QColor(self.current_color).lighter(140)) + gradient.setColorAt(0.5, QColor(self.current_color)) + gradient.setColorAt(1, QColor(self.current_color).darker(140)) + + # 保存画家状态 + painter.save() + + # 创建反射路径(波形的镜像) + reflect_path = QPainterPath(path) + # 将路径向下移动,创建反射效果 + transform = QTransform() + transform.translate(0, wave_rect.height() / 4) + reflect_path = transform.map(reflect_path) + + # 设置半透明画笔绘制反射 + reflect_pen = QPen() + reflect_pen.setWidth(1) + reflect_pen.setColor(QColor(self.current_color).lighter(160)) + painter.setPen(reflect_pen) + + # 设置透明度 + painter.setOpacity(0.3) + + # 绘制反射波形 + painter.drawPath(reflect_path) + + # 恢复画家状态 + painter.restore() + + # 添加音量百分比小浮标 + if self.current_volume > 0.1: # 只有当音量足够大时才显示 + percent_text = f"{int(self.current_volume * 100)}%" + painter.setPen(QColor(self.current_color).darker(120)) + + # 字体大小随音量变化 + font = painter.font() + font.setPointSize(8 + int(self.current_volume * 4)) # 8-12pt + font.setBold(True) + painter.setFont(font) + + # 在波形最右侧显示百分比 + right_edge = wave_rect.right() - 40 + y_position = mid_y - amp * wave_rect.height() / 3 # 根据当前振幅定位 + + # 使用QPoint而不是单独的x,y坐标,或者将浮点数转为整数 + # Windows下QPainter.drawText对坐标类型要求更严格 + painter.drawText(int(right_edge), int(y_position), percent_text) \ No newline at end of file diff --git a/src/display/gui_display.ui b/src/display/gui_display.ui new file mode 100644 index 0000000000000000000000000000000000000000..18d7d4d79e02b56224657bd82a64fa54db8bb564 --- /dev/null +++ b/src/display/gui_display.ui @@ -0,0 +1,887 @@ + + + MainWindow + + + + 0 + 0 + 800 + 518 + + + + 小智Ai客户端 + + + + + + 0 + + + 0 + + + + + +QTabBar::tab { + background-color: #f0f0f0; /* Light gray background */ + color: #333; /* Dark text */ + padding: 8px 20px; /* Padding around text */ + border-top-left-radius: 8px; /* Rounded top corners */ + border-top-right-radius: 8px; + border: 1px solid #ddd; /* Light border */ + margin-right: 2px; /* Space between tabs */ + min-width: 100px; /* Ensure tabs aren't too small */ +} + +QTabBar::tab:selected { + background-color: #ffffff; /* White background for selected tab */ + color: #007bff; /* Blue text for selected tab */ + border: 1px solid #ccc; + border-bottom: 2px solid #007bff; /* Blue underline for selected tab */ +} + +QTabBar::tab:hover { + background-color: #e9e9e9; /* Slightly darker on hover */ +} + +QTabBar { + /* Optional: Remove the default frame if needed */ + /* border: none; */ +} + + + + + + + + + + 0 + + + + QWidget#mainPage { + background-color: rgba(255, 255, 255, 140); + border-radius: 8px; +} + + + + 0 + + + 0 + + + + + + 0 + 0 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + Microsoft YaHei UI + 14 + 75 + true + + + + color: #2196F3; + padding: 10px; + background-color: #E3F2FD; + border-radius: 8px; + + + 状态: 未连接 + + + Qt::AlignCenter + + + + + + + + 24 + + + + margin: 15px; + + + 😊 + + + Qt::AlignCenter + + + + + + + + 12 + + + + + + + 待命 + + + Qt::AlignCenter + + + true + + + + + + + + + + + 0 + 70 + + + + + 16777215 + 72 + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 0 + + + 10 + + + 5 + + + 10 + + + 5 + + + + + 0 + + + + + 5 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 72 + 34 + + + + 点击静音 + + + true + + + + + + + Qt::Horizontal + + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 50 + + + + border-radius: 8px; + + + + + + + + + + + + + + 8 + + + 10 + + + 0 + + + 10 + + + 6 + + + + + + 0 + 36 + + + + 按住后说话 + + + + + + + + 0 + 36 + + + + 打断对话 + + + + + + + 4 + + + + + + 0 + 36 + + + + 输入文字... + + + + + + + + 60 + 36 + + + + 发送 + + + + + + + + + + 0 + 36 + + + + 开始对话 + + + + + + + + 0 + 36 + + + + 手动对话 + + + + + + + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + + + + 14 + 75 + true + + + + 暂无IOT设备 +暂未实现 + + + + Qt::AlignCenter + + + + + + + + + + + + + + QFrame::StyledPanel + + + QFrame::Raised + + + + 5 + + + 16 + + + 10 + + + 16 + + + 10 + + + + + Qt::Vertical + + + + 20 + 40 + + + + + + + + + 18 + 75 + true + + + + config.json文件配置 + + + Qt::AlignCenter + + + 10 + + + + + + + Qt::Vertical + + + QSizePolicy::Expanding + + + + 20 + 40 + + + + + + + + + + 启用唤醒词唤醒: + + + + + + + + 20 + 20 + + + + + + + + + + + Qt::Horizontal + + + + 40 + 20 + + + + + + + + + + 10 + + + 10 + + + + + 唤醒词 (多个唤醒词请用英文逗号 ',' 分隔): + + + + + + + + + + + + + 14 + 75 + true + + + + Qt::LeftToRight + + + API 服务配置 + + + Qt::AlignCenter + + + + + + + 6 + + + 12 + + + + + Device ID: + + + + + + + + + + OTA地址: + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + https:// + + + + + http:// + + + + + + + + + 1 + 0 + + + + + + + + + + + API接口地址: + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + wss:// + + + + + ws:// + + + + + + + + + 1 + 0 + + + + + + + + + + + Access Token: + + + + + + + + + + + + Qt::Vertical + + + QSizePolicy::Fixed + + + + 20 + 15 + + + + + + + + + 14 + 75 + true + + + + Home Assistant 服务配置 + + + Qt::AlignCenter + + + + + + + + + HA-Server: + + + Qt::AlignCenter + + + + + + + + 0 + + + 0 + + + 0 + + + 0 + + + + + + 0 + 0 + + + + + https:// + + + + + http:// + + + + + + + + + 1 + 0 + + + + Home Assistant服务地址,例如:ha.aslant.top + + + + + + + + + + Port: + + + Qt::AlignCenter + + + + + + + 默认端口:8123 + + + + + + + 长期访问令牌: + + + Qt::AlignCenter + + + + + + + QLineEdit::Password + + + 获取方法:账户 - 安全 - 创建令牌 + + + + + + + + + + 0 + 30 + + + + 导入Home Assistant设备 + + + + + + + 保存设置 + + + + + + + + + + + + + + + QTabBar + QWidget +
qtabbar.h
+
+
+ + +
diff --git a/src/iot/thing.py b/src/iot/thing.py new file mode 100644 index 0000000000000000000000000000000000000000..b238fd0a4a9868ea6ff7d151002fab611c19c8e7 --- /dev/null +++ b/src/iot/thing.py @@ -0,0 +1,124 @@ +import json +from typing import Dict, List, Callable, Any, Optional, Union + + +class ValueType: + BOOLEAN = "boolean" + NUMBER = "number" + STRING = "string" + + +class Property: + def __init__(self, name: str, description: str, getter: Callable): + self.name = name + self.description = description + self.getter = getter + + # 根据 getter 返回值类型确定属性类型 + test_value = getter() + if isinstance(test_value, bool): + self.type = ValueType.BOOLEAN + elif isinstance(test_value, (int, float)): + self.type = ValueType.NUMBER + elif isinstance(test_value, str): + self.type = ValueType.STRING + else: + raise TypeError(f"不支持的属性类型: {type(test_value)}") + + def get_descriptor_json(self) -> Dict: + return { + "description": self.description, + "type": self.type + } + + def get_state_value(self): + return self.getter() + + +class Parameter: + def __init__(self, name: str, description: str, type_: str, required: bool = True): + self.name = name + self.description = description + self.type = type_ + self.required = required + self.value = None + + def get_descriptor_json(self) -> Dict: + return { + "description": self.description, + "type": self.type + } + + def set_value(self, value: Any): + self.value = value + + def get_value(self) -> Any: + return self.value + + +class Method: + def __init__(self, name: str, description: str, parameters: List[Parameter], callback: Callable): + self.name = name + self.description = description + self.parameters = {param.name: param for param in parameters} + self.callback = callback + + def get_descriptor_json(self) -> Dict: + return { + "description": self.description, + "parameters": {name: param.get_descriptor_json() + for name, param in self.parameters.items()} + } + + def invoke(self, params: Dict[str, Any]) -> Any: + # 设置参数值 + for name, value in params.items(): + if name in self.parameters: + self.parameters[name].set_value(value) + + # 检查必需参数 + for name, param in self.parameters.items(): + if param.required and param.get_value() is None: + raise ValueError(f"缺少必需参数: {name}") + + # 调用回调函数 + return self.callback(self.parameters) + + +class Thing: + def __init__(self, name: str, description: str): + self.name = name + self.description = description + self.properties = {} + self.methods = {} + + def add_property(self, name: str, description: str, getter: Callable) -> None: + self.properties[name] = Property(name, description, getter) + + def add_method(self, name: str, description: str, parameters: List[Parameter], callback: Callable) -> None: + self.methods[name] = Method(name, description, parameters, callback) + + def get_descriptor_json(self) -> Dict: + return { + "name": self.name, + "description": self.description, + "properties": {name: prop.get_descriptor_json() + for name, prop in self.properties.items()}, + "methods": {name: method.get_descriptor_json() + for name, method in self.methods.items()} + } + + def get_state_json(self) -> Dict: + return { + "name": self.name, + "state": {name: prop.get_state_value() + for name, prop in self.properties.items()} + } + + def invoke(self, command: Dict) -> Any: + method_name = command.get("method") + if method_name not in self.methods: + raise ValueError(f"方法不存在: {method_name}") + + parameters = command.get("parameters", {}) + return self.methods[method_name].invoke(parameters) \ No newline at end of file diff --git a/src/iot/thing_manager.py b/src/iot/thing_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..2978d0594aa6ab913b5d7ee6c2d2844b30b2e98f --- /dev/null +++ b/src/iot/thing_manager.py @@ -0,0 +1,88 @@ +import json +import logging +from typing import Any, Dict, Tuple, Optional + +from src.iot.thing import Thing + + +class ThingManager: + _instance = None + + @classmethod + def get_instance(cls): + if cls._instance is None: + cls._instance = ThingManager() + return cls._instance + + def __init__(self): + self.things = [] + self.last_states = {} # 添加状态缓存字典,存储上一次的状态 + + def add_thing(self, thing: Thing) -> None: + self.things.append(thing) + + def get_descriptors_json(self) -> str: + descriptors = [thing.get_descriptor_json() for thing in self.things] + return json.dumps(descriptors) + + def get_states_json(self, delta=False) -> Tuple[bool, str]: + """ + 获取所有设备的状态JSON + + Args: + delta: 是否只返回变化的部分,True表示只返回变化的部分 + + Returns: + Tuple[str, bool]: 返回JSON字符串和是否有状态变化的布尔值 + """ + if not delta: + self.last_states.clear() + + changed = False + states = [] + + for thing in self.things: + state_json = thing.get_state_json() + + if delta: + # 检查状态是否变化 + is_same = (thing.name in self.last_states and + self.last_states[thing.name] == state_json) + if is_same: + continue + changed = True + self.last_states[thing.name] = state_json + + # 检查state_json是否已经是字典对象 + if isinstance(state_json, dict): + states.append(state_json) + else: + states.append(json.loads(state_json)) # 转换JSON字符串为字典 + + return changed, json.dumps(states) + + def get_states_json_str(self) -> str: + """ + 为了兼容旧代码,保留原来的方法名和返回值类型 + """ + _, json_str = self.get_states_json(delta=False) + return json_str + + def invoke(self, command: Dict) -> Optional[Any]: + """ + 调用设备方法 + + Args: + command: 包含name和method等信息的命令字典 + + Returns: + Optional[Any]: 如果找到设备并调用成功,返回调用结果;否则抛出异常 + """ + thing_name = command.get("name") + for thing in self.things: + if thing.name == thing_name: + return thing.invoke(command) + + # 记录错误日志 + logging.error(f"设备不存在: {thing_name}") + raise ValueError(f"设备不存在: {thing_name}") \ No newline at end of file diff --git a/src/iot/things/CameraVL/Camera.py b/src/iot/things/CameraVL/Camera.py new file mode 100644 index 0000000000000000000000000000000000000000..d1ad1b6db8dfad1dc24f99bcc2a7e6ac10cd3e82 --- /dev/null +++ b/src/iot/things/CameraVL/Camera.py @@ -0,0 +1,132 @@ +import asyncio + +import cv2 +import base64 +import logging +import threading + +from src.application import Application +from src.constants.constants import DeviceState +from src.iot.thing import Thing +from src.iot.things.CameraVL import VL + +logger = logging.getLogger("Camera") + + +class Camera(Thing): + def __init__(self): + super().__init__("Camera", "摄像头管理") + self.app = None + """初始化摄像头管理器""" + if hasattr(self, '_initialized'): + return + self._initialized = True + # 加载配置 + self.cap = None + self.is_running = False + self.camera_thread = None + self.result="" + from src.utils.config_manager import ConfigManager + self.config = ConfigManager.get_instance() + # 摄像头控制器 + VL.ImageAnalyzer.get_instance().init(self.config.get_config('CAMERA.VLapi_key'), self.config.get_config('CAMERA.Loacl_VL_url'),self.config.get_config('CAMERA.models')) + self.VL= VL.ImageAnalyzer.get_instance() + print(f"[虚拟设备] 摄像头设备初始化完成") + + self.add_property_and_method()#定义设备方法与状态属性 + + def add_property_and_method(self): + # 定义属性 + self.add_property("power", "摄像头是否打开", lambda: self.is_running ) + self.add_property("result", "识别画面的内容", lambda: self.result ) + # 定义方法 + self.add_method("start_camera", "打开摄像头", [], + lambda params: self.start_camera()) + + self.add_method("stop_camera", "关闭摄像头", [], + lambda params: self.stop_camera()) + + self.add_method("capture_frame_to_base64", "识别画面", [], + lambda params: self.capture_frame_to_base64()) + + + def _camera_loop(self): + """摄像头线程的主循环""" + camera_index = self.config.get_config('CAMERA.camera_index') + self.cap = cv2.VideoCapture(camera_index) + + if not self.cap.isOpened(): + logger.error("无法打开摄像头") + return + + # 设置摄像头参数 + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, self.config.get_config('CAMERA.frame_width')) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, self.config.get_config('CAMERA.frame_height')) + self.cap.set(cv2.CAP_PROP_FPS, self.config.get_config('CAMERA.fps')) + + self.is_running = True + while self.is_running: + ret, frame = self.cap.read() + if not ret: + logger.error("无法读取画面") + break + + # 显示画面 + cv2.imshow('Camera', frame) + + # 按下 'q' 键退出 + if cv2.waitKey(1) & 0xFF == ord('q'): + self.is_running = False + + # 释放摄像头并关闭窗口 + self.cap.release() + cv2.destroyAllWindows() + + def start_camera(self): + """启动摄像头线程""" + if self.camera_thread is not None and self.camera_thread.is_alive(): + logger.warning("摄像头线程已在运行") + return + + self.camera_thread = threading.Thread(target=self._camera_loop, daemon=True) + self.camera_thread.start() + logger.info("摄像头线程已启动") + print(f"[虚拟设备] 摄像头线程已启动") + return {"status": "success", "message": "摄像头线程已打开"} + + def capture_frame_to_base64(self): + """截取当前画面并转换为 Base64 编码""" + if not self.cap or not self.cap.isOpened(): + logger.error("摄像头未打开") + return None + + ret, frame = self.cap.read() + if not ret: + logger.error("无法读取画面") + return None + + # 将帧转换为 JPEG 格式 + _, buffer = cv2.imencode('.jpg', frame) + + # 将 JPEG 图像转换为 Base64 编码 + frame_base64 = base64.b64encode(buffer).decode('utf-8') + self.result=str(self.VL.analyze_image(frame_base64)) + print(self.result) + # 获取应用程序实例 + self.app = Application.get_instance() + logger.info("画面已经识别到啦") + print(f"[虚拟设备] 画面已经识别完成") + self.app.set_device_state(DeviceState.LISTENING) + asyncio.create_task(self.app.protocol.send_wake_word_detected("播报识别结果")) + return {"status": 'success', "message": "识别成功","result":self.result} + def stop_camera(self): + """停止摄像头线程""" + self.is_running = False + if self.camera_thread is not None: + self.camera_thread.join() # 等待线程结束 + self.camera_thread = None + logger.info("摄像头线程已停止") + print(f"[虚拟设备] 摄像头线程已停止") + return {"status": "success", "message": "摄像头线程已停止"} + + diff --git a/src/iot/things/CameraVL/VL.py b/src/iot/things/CameraVL/VL.py new file mode 100644 index 0000000000000000000000000000000000000000..df17142ef2904f12ed7828b8b4802784ef56831c --- /dev/null +++ b/src/iot/things/CameraVL/VL.py @@ -0,0 +1,62 @@ +import os +import base64 +from openai import OpenAI +import threading +class ImageAnalyzer: + _instance = None + _lock = threading.Lock() + client=None + + def __init__(self): + self.model = None + + def __new__(cls): + """确保单例模式""" + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + def init(self, api_key,base_url="https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions",models="qwen-omni-turbo"): + self.client = OpenAI( + api_key=api_key, + base_url=base_url, + ) + self.models=models + + @classmethod + def get_instance(cls): + """获取摄像头管理器实例(线程安全)""" + with cls._lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + def analyze_image(self, base64_image, prompt="图中描绘的是什么景象,请详细描述,因为用户可能是盲人")->str: + """分析图片并返回结果""" + completion = self.client.chat.completions.create( + model=self.models, + messages=[ + { + "role": "system", + "content": "You are a helpful assistant.", # 直接使用字符串,而不是列表 + }, + { + "role": "user", + "content": [ + { + "type": "image_url", + "image_url": {"url": f"data:image/png;base64,{base64_image}"}, + }, + {"type": "text", "text": prompt}, + ], + }, + ], + modalities=["text"], + stream=True, + stream_options={"include_usage": True}, + ) + mesag="" + for chunk in completion: + if chunk.choices: + mesag+=chunk.choices[0].delta.content + else: + pass + return mesag diff --git a/src/iot/things/countdown_timer.py b/src/iot/things/countdown_timer.py new file mode 100644 index 0000000000000000000000000000000000000000..3346698e15702808392196e88e0707465cadc353 --- /dev/null +++ b/src/iot/things/countdown_timer.py @@ -0,0 +1,153 @@ +import threading +import time +import logging +import json +from src.iot.thing import Thing, Parameter +from src.iot.thing_manager import ThingManager + +logger = logging.getLogger(__name__) + +class CountdownTimer(Thing): + """ + 一个用于延迟执行命令的倒计时器设备。 + """ + DEFAULT_DELAY = 5 # seconds + + def __init__(self): + super().__init__("CountdownTimer", "一个用于延迟执行命令的倒计时器") + # 使用字典存储活动的计时器,键是 timer_id,值是 threading.Timer 对象 + self._timers = {} + self._next_timer_id = 0 + # 使用锁来保护对 _timers 和 _next_timer_id 的访问,确保线程安全 + self._lock = threading.Lock() + + print(f"[虚拟设备] 倒计时器设备初始化完成") + + # 定义方法 - 使用 Parameter 对象 + self.add_method( + "StartCountdown", + "启动一个倒计时,结束后执行指定命令", + [ + Parameter("command", "要执行的IoT命令 (JSON格式字符串)", "string", required=True), + Parameter("delay", "延迟时间(秒),默认为5秒", "integer", required=False) # 使用 required=False 标记可选参数 + ], + lambda params: self._start_countdown(params) + ) + self.add_method( + "CancelCountdown", + "取消指定的倒计时", + [Parameter("timer_id", "要取消的计时器ID", "integer", required=True)], + lambda params: self._cancel_countdown(params) + ) + + def _execute_command(self, timer_id, command_str): + """计时器到期时执行的回调函数。""" + # 首先从活动计时器列表中移除自己 + with self._lock: + if timer_id not in self._timers: + # 可能已经被取消 + logger.info(f"倒计时 {timer_id} 在执行前已被取消或不存在。") + return + del self._timers[timer_id] + + logger.info(f"倒计时 {timer_id} 结束,准备执行命令: {command_str}") + + try: + # 命令应该是 JSON 格式的字符串,代表一个命令字典 + command_dict = json.loads(command_str) + # 获取 ThingManager 单例并执行命令 + thing_manager = ThingManager.get_instance() + result = thing_manager.invoke(command_dict) + logger.info(f"倒计时 {timer_id} 执行命令 '{command_str}' 结果: {result}") + except json.JSONDecodeError: + logger.error(f"倒计时 {timer_id}: 命令 '{command_str}' 格式错误,无法解析JSON。") + # 可以选择返回错误状态给调用者,但这在后台线程中较难实现 + except Exception as e: + logger.error(f"倒计时 {timer_id} 执行命令 '{command_str}' 时出错: {e}", exc_info=True) + # 同上 + + def _start_countdown(self, params_dict): + """处理 StartCountdown 方法调用。注意: params 现在是 Parameter 对象的字典""" + # 从 Parameter 对象字典中获取值 + command_param = params_dict.get("command") + delay_param = params_dict.get("delay") + + command_str = command_param.get_value() if command_param else None + # 处理可选参数 delay + delay = delay_param.get_value() if delay_param and delay_param.get_value() is not None else self.DEFAULT_DELAY + + if not command_str: + logger.error("启动倒计时失败:缺少 'command' 参数值。") + return {"status": "error", "message": "缺少 'command' 参数值"} + + # 验证延迟时间 + try: + # 确保 delay 是整数类型 + if not isinstance(delay, int): + delay = int(delay) + + if delay <= 0: + logger.warning(f"提供的延迟时间 {delay} 无效,使用默认值 {self.DEFAULT_DELAY} 秒。") + delay = self.DEFAULT_DELAY + except (ValueError, TypeError): + logger.warning(f"提供的延迟时间 '{delay}' 无效,使用默认值 {self.DEFAULT_DELAY} 秒。") + delay = self.DEFAULT_DELAY + + # 尝试解析命令字符串以进行早期验证 + try: + json.loads(command_str) + except json.JSONDecodeError: + logger.error(f"启动倒计时失败:命令格式错误,无法解析JSON: {command_str}") + return {"status": "error", "message": f"命令格式错误,无法解析JSON: {command_str}"} + + with self._lock: + timer_id = self._next_timer_id + self._next_timer_id += 1 + timer = threading.Timer(delay, self._execute_command, args=[timer_id, command_str]) + self._timers[timer_id] = timer + timer.start() + + logger.info(f"启动倒计时 {timer_id},将在 {delay} 秒后执行命令: {command_str}") + return {"status": "success", "message": f"倒计时 {timer_id} 已启动,将在 {delay} 秒后执行。", "timer_id": timer_id} + + def _cancel_countdown(self, params_dict): + """处理 CancelCountdown 方法调用。注意: params 现在是 Parameter 对象的字典""" + timer_id_param = params_dict.get("timer_id") + timer_id = timer_id_param.get_value() if timer_id_param else None + + if timer_id is None: + logger.error("取消倒计时失败:缺少 'timer_id' 参数值。") + return {"status": "error", "message": "缺少 'timer_id' 参数值"} + + try: + # 确保 timer_id 是整数 + if not isinstance(timer_id, int): + timer_id = int(timer_id) + except (ValueError, TypeError): + logger.error(f"取消倒计时失败:无效的 'timer_id' {timer_id}。") + return {"status": "error", "message": f"无效的 'timer_id': {timer_id}"} + + with self._lock: + if timer_id in self._timers: + timer = self._timers.pop(timer_id) + timer.cancel() + logger.info(f"倒计时 {timer_id} 已成功取消。") + return {"status": "success", "message": f"倒计时 {timer_id} 已取消"} + else: + logger.warning(f"尝试取消不存在或已完成的倒计时 {timer_id}。") + return {"status": "error", "message": f"找不到ID为 {timer_id} 的活动倒计时"} + + def cleanup(self): + """在应用程序关闭时清理所有活动的计时器。""" + logger.info("正在清理倒计时器...") + with self._lock: + active_timer_ids = list(self._timers.keys()) # 创建键的副本以安全迭代 + for timer_id in active_timer_ids: + if timer_id in self._timers: + timer = self._timers.pop(timer_id) + timer.cancel() + logger.info(f"已取消后台计时器 {timer_id}") + logger.info("倒计时器清理完成。") + +# 注意:这个 cleanup 方法需要在应用程序关闭时被显式调用。 +# ThingManager 或 Application 类可以负责在 shutdown 过程中调用其管理的 Things 的 cleanup 方法。 \ No newline at end of file diff --git a/src/iot/things/ha_control.py b/src/iot/things/ha_control.py new file mode 100644 index 0000000000000000000000000000000000000000..bf5920a24a77360e4dfead7b730386ae9c0a3c1a --- /dev/null +++ b/src/iot/things/ha_control.py @@ -0,0 +1,397 @@ +import json +import time +import requests +from src.iot.thing import Thing, Parameter, ValueType +from src.utils.logging_config import get_logger +from src.utils.config_manager import ConfigManager + +logger = get_logger(__name__) + +class HomeAssistantDevice(Thing): + """ + Home Assistant设备基类 + + 提供所有Home Assistant设备的通用功能 + """ + + def __init__(self, entity_id, friendly_name=None, device_type="设备"): + """ + 初始化Home Assistant设备 + + 参数: + entity_id: Home Assistant中的实体ID + friendly_name: 显示名称,如不提供则使用entity_id + device_type: 设备类型描述,用于日志和显示 + """ + self.entity_id = entity_id + name = friendly_name or entity_id.replace(".", "_") + super().__init__(name, f"Home Assistant{device_type}: {friendly_name or entity_id}") + + # 设备状态 + self.state = "off" # 默认关闭状态 + self.last_update = int(time.time()) # 当前时间戳 + + # HA API配置 + config = ConfigManager.get_instance() + self.ha_config = { + "url": config.get_config("HOME_ASSISTANT.URL", "http://123.60.32.150:8123"), + "token": config.get_config("HOME_ASSISTANT.TOKEN", ""), + } + + # HA API请求头 + self.headers = { + "Authorization": f"Bearer {self.ha_config['token']}", + "Content-Type": "application/json", + } + + # 注册基本属性 + self.add_property("state", "设备状态 (on/off)", lambda: self.state) + self.add_property("last_update", "最后更新时间戳", lambda: self.last_update) + + # 注册基本方法 + self.add_method( + "TurnOn", + "打开设备", + [], + lambda params: self._turn_on() + ) + + self.add_method( + "TurnOff", + "关闭设备", + [], + lambda params: self._turn_off() + ) + + def _update_state(self): + """获取设备当前状态""" + try: + url = f"{self.ha_config['url']}/api/states/{self.entity_id}" + response = requests.get(url, headers=self.headers, timeout=5) + + if response.status_code == 200: + data = response.json() + self.state = data.get("state", "off") + self.last_update = int(time.time()) + + # 子类可以覆盖此方法以处理额外的属性 + self._process_attributes(data.get("attributes", {})) + + logger.info(f"设备 {self.entity_id} 状态已更新: state={self.state}") + return True + else: + logger.error(f"获取设备状态失败, 状态码: {response.status_code}, 响应: {response.text}") + return False + except Exception as e: + logger.error(f"获取设备状态出错: {e}") + return False + + def _process_attributes(self, attributes): + """处理设备属性,由子类覆盖以处理特定属性""" + pass + + def _call_service(self, service_domain, service_action, payload): + """ + 调用Home Assistant服务 + + 参数: + service_domain: 服务域,例如 'light'、'switch' + service_action: 服务动作,例如 'turn_on'、'turn_off' + payload: 请求参数 + """ + try: + url = f"{self.ha_config['url']}/api/services/{service_domain}/{service_action}" + response = requests.post(url, headers=self.headers, json=payload, timeout=5) + + if response.status_code in [200, 201]: + # 更新本地状态 + if service_action == "turn_on": + self.state = "on" + elif service_action == "turn_off": + self.state = "off" + + self.last_update = int(time.time()) + logger.info(f"发送命令: {service_action} 到 {self.entity_id}") + + # 延迟更新状态以获取设备最新状态 + time.sleep(1) + self._update_state() + + return {"status": "success", "message": f"已发送{service_action}命令到 {self.entity_id}"} + else: + logger.error(f"发送{service_action}命令失败, 状态码: {response.status_code}, 响应: {response.text}") + return {"status": "error", "message": f"发送命令失败, HTTP状态码: {response.status_code}"} + except Exception as e: + logger.error(f"发送{service_action}命令出错: {e}") + return {"status": "error", "message": f"发送命令失败: {e}"} + + def _turn_on(self): + """打开设备,子类需要实现此方法""" + raise NotImplementedError("子类必须实现_turn_on方法") + + def _turn_off(self): + """关闭设备,子类需要实现此方法""" + raise NotImplementedError("子类必须实现_turn_off方法") + + +class HomeAssistantLight(HomeAssistantDevice): + """ + 通过HTTP API控制Home Assistant中的灯设备 + + 支持开关和亮度调节功能 + """ + + def __init__(self, entity_id, friendly_name=None): + """ + 初始化Home Assistant灯设备 + + 参数: + entity_id: Home Assistant中的实体ID,例如 'light.living_room' + friendly_name: 显示名称,如不提供则使用entity_id + """ + super().__init__(entity_id, friendly_name, device_type="灯设备") + + # 灯特有属性 + self.brightness = 0 # 默认亮度值为0 + + # 注册灯特有属性 + self.add_property("brightness", "灯的亮度 (0-100)", lambda: self.brightness) + + # 注册灯特有方法 + self.add_method( + "SetBrightness", + "设置灯的亮度", + [Parameter("brightness", "亮度值 (0-100)", ValueType.NUMBER, True)], + lambda params: self._set_brightness(params["brightness"].get_value()) + ) + + # 初始化时更新状态 + try: + self._update_state() + except Exception as e: + logger.error(f"初始化时更新设备状态失败: {e}") + + logger.info(f"Home Assistant灯设备初始化完成: {self.entity_id}") + + def _process_attributes(self, attributes): + """处理灯特有的属性""" + if "brightness" in attributes and attributes["brightness"] is not None: + self.brightness = min(int(attributes["brightness"] * 100 / 255), 100) + else: + # 如果没有亮度属性,设置默认值 + self.brightness = 100 if self.state == "on" else 0 + + def _turn_on(self): + """打开灯""" + return self._call_service("light", "turn_on", {"entity_id": self.entity_id}) + + def _turn_off(self): + """关闭灯""" + return self._call_service("light", "turn_off", {"entity_id": self.entity_id}) + + def _set_brightness(self, brightness_percent): + """ + 设置灯的亮度 + + 参数: + brightness_percent: 亮度百分比 (0-100) + """ + try: + # 验证输入 + if not 0 <= brightness_percent <= 100: + return {"status": "error", "message": "亮度必须在0-100之间"} + + # 将百分比转换为Home Assistant使用的0-255范围 + brightness = int(brightness_percent * 255 / 100) + + payload = { + "entity_id": self.entity_id, + "brightness": brightness + } + + # 调用服务 + result = self._call_service("light", "turn_on", payload) + + if result["status"] == "success": + self.brightness = brightness_percent + return { + "status": "success", + "message": f"已将 {self.entity_id} 亮度设置为 {brightness_percent}%" + } + + return result + + except Exception as e: + logger.error(f"设置亮度出错: {e}") + return {"status": "error", "message": f"设置亮度失败: {e}"} + + +class HomeAssistantSwitch(HomeAssistantDevice): + """ + 通过HTTP API控制Home Assistant中的开关设备 + + 支持开关功能 + """ + + def __init__(self, entity_id, friendly_name=None): + """ + 初始化Home Assistant开关设备 + + 参数: + entity_id: Home Assistant中的实体ID,例如 'switch.bedroom_switch' + friendly_name: 显示名称,如不提供则使用entity_id + """ + super().__init__(entity_id, friendly_name, device_type="开关设备") + + # 初始化时更新状态 + try: + self._update_state() + except Exception as e: + logger.error(f"初始化时更新设备状态失败: {e}") + + logger.info(f"Home Assistant开关设备初始化完成: {self.entity_id}") + + def _turn_on(self): + """打开开关""" + return self._call_service("switch", "turn_on", {"entity_id": self.entity_id}) + + def _turn_off(self): + """关闭开关""" + return self._call_service("switch", "turn_off", {"entity_id": self.entity_id}) + + +class HomeAssistantNumber(HomeAssistantDevice): + """通过HTTP API控制Home Assistant中的数值型设备(如音量)""" + + def __init__(self, entity_id, friendly_name=None): + super().__init__(entity_id, friendly_name, device_type="数值设备") + + # 数值设备特有属性 + self.value = 0 + self.min = 0 + self.max = 100 + self.step = 1 + + # 注册特有属性 + self.add_property("value", "当前值", lambda: self.value) + + # 注册特有方法 + self.add_method( + "SetValue", + "设置数值", + [Parameter("value", "设置值", ValueType.NUMBER, True)], + lambda params: self._set_value(params["value"].get_value()) + ) + + try: + self._update_state() + except Exception as e: + logger.error(f"初始化时更新设备状态失败: {e}") + + logger.info(f"Home Assistant数值设备初始化完成: {self.entity_id}") + + def _process_attributes(self, attributes): + self.min = attributes.get("min", 0) + self.max = attributes.get("max", 100) + self.step = attributes.get("step", 1) + if "value" in attributes: + self.value = attributes["value"] + + def _turn_on(self): + """数值设备不支持直接开关""" + return {"status": "error", "message": "数值设备不支持直接开关操作"} + + def _turn_off(self): + """数值设备不支持直接开关""" + return {"status": "error", "message": "数值设备不支持直接开关操作"} + + def _set_value(self, value): + """设置数值""" + try: + # 值校验 + if value < self.min or value > self.max: + return { + "status": "error", + "message": f"值必须在{self.min}-{self.max}范围内" + } + + payload = { + "entity_id": self.entity_id, + "value": value + } + + # 调用服务 + result = self._call_service("number", "set_value", payload) + + if result["status"] == "success": + self.value = value + return { + "status": "success", + "message": f"已将 {self.entity_id} 的值设置为 {value}" + } + + return result + + except Exception as e: + logger.error(f"设置值出错: {e}") + return {"status": "error", "message": f"设置值失败: {e}"} + + +class HomeAssistantButton(HomeAssistantDevice): + """通过HTTP API控制Home Assistant中的按钮设备""" + + def __init__(self, entity_id, friendly_name=None): + super().__init__(entity_id, friendly_name, device_type="按钮设备") + + # 按钮设备只有按下的动作,没有状态 + self.last_pressed = 0 # 上次按下的时间戳 + + # 注册按钮特有属性 + self.add_property("last_pressed", "上次按下时间", lambda: self.last_pressed) + + # 注册按钮特有方法 + self.add_method( + "Press", + "按下按钮", + [], + lambda params: self._press() + ) + + try: + self._update_state() + except Exception as e: + logger.error(f"初始化时更新设备状态失败: {e}") + + logger.info(f"Home Assistant按钮设备初始化完成: {self.entity_id}") + + def _turn_on(self): + """按钮设备使用Press替代TurnOn""" + return self._press() + + def _turn_off(self): + """按钮设备不支持关闭操作""" + return {"status": "error", "message": "按钮设备不支持关闭操作"} + + def _press(self): + """按下按钮""" + try: + # 在Home Assistant中,按下按钮是调用press服务 + payload = { + "entity_id": self.entity_id + } + + # 调用服务 + result = self._call_service("button", "press", payload) + + if result["status"] == "success": + self.last_pressed = int(time.time()) + return { + "status": "success", + "message": f"已按下 {self.entity_id} 按钮" + } + + return result + + except Exception as e: + logger.error(f"按下按钮出错: {e}") + return {"status": "error", "message": f"按下按钮失败: {e}"} \ No newline at end of file diff --git a/src/iot/things/lamp.py b/src/iot/things/lamp.py new file mode 100644 index 0000000000000000000000000000000000000000..6f98641361fc284f3c7f47899ac6f20a9daca5b5 --- /dev/null +++ b/src/iot/things/lamp.py @@ -0,0 +1,29 @@ +from src.iot.thing import Thing + + +class Lamp(Thing): + def __init__(self): + super().__init__("Lamp", "一个测试用的灯") + self.power = False + + print(f"[虚拟设备] 灯设备初始化完成") + + # 定义属性 + self.add_property("power", "灯是否打开", lambda: self.power) + + # 定义方法 + self.add_method("TurnOn", "打开灯", [], + lambda params: self._turn_on()) + + self.add_method("TurnOff", "关闭灯", [], + lambda params: self._turn_off()) + + def _turn_on(self): + self.power = True + print(f"[虚拟设备] 灯已打开") + return {"status": "success", "message": "灯已打开"} + + def _turn_off(self): + self.power = False + print(f"[虚拟设备] 灯已关闭") + return {"status": "success", "message": "灯已关闭"} \ No newline at end of file diff --git a/src/iot/things/music_player.py b/src/iot/things/music_player.py new file mode 100644 index 0000000000000000000000000000000000000000..f93f064a53e757963364ba4c5174719e0dfebc12 --- /dev/null +++ b/src/iot/things/music_player.py @@ -0,0 +1,1235 @@ +from src.application import Application +from src.constants.constants import DeviceState, AudioConfig +from src.iot.thing import Thing, Parameter, ValueType +import os +import requests +import pygame +import time +import threading +from typing import Dict, Any, Tuple, List, Optional +from src.utils.logging_config import get_logger + +logger = get_logger(__name__) + + +class MusicPlayer(Thing): + """ + 音乐播放器组件 + + 提供在线音乐搜索、播放、暂停等功能,支持歌词显示和播放进度跟踪。 + 使用pygame播放引擎实现音频播放功能。 + """ + + def __init__(self): + """初始化音乐播放器组件""" + super().__init__( + "MusicPlayer", + "在线音乐播放器,播放音乐时优先使用iot的音乐播放器,支持本地缓存、暂停、进度跳转" + ) + + # 初始化pygame mixer + pygame.mixer.init(frequency=AudioConfig.OUTPUT_SAMPLE_RATE, + channels=AudioConfig.CHANNELS) + + # 搜索结果相关属性 + self.current_song = "" # 当前歌曲名称 + self.current_url = "" # 当前歌曲播放链接 + self.song_id = "" # 当前歌曲ID + self.total_duration = 0 # 歌曲总时长(秒) + + # 播放控制相关属性 + self.is_playing = False # 是否正在播放 + self.paused = False # 是否暂停 + self.current_position = 0 # 当前播放位置(秒) + self.start_play_time = 0 # 开始播放的时间点 + + # TTS相关属性 + self.paused_for_tts = False # 是否因为TTS而暂停 + self.pause_start_time = 0 # 暂停开始时间 + self.total_pause_time = 0 # 总暂停时间 + self._last_tts_playing = None + + # 歌词相关 + self.lyrics = [] # 歌词列表,格式为 [(时间, 文本), ...] + self.current_lyric_index = -1 # 当前歌词索引 + + # 线程控制 + self.progress_thread = None # 进度更新线程 + self.stop_progress = threading.Event() # 用于停止进度更新线程 + + # 缓存相关 + cache_root = os.path.dirname(os.path.dirname(os.path.dirname( + os.path.dirname(__file__)))) + self.cache_dir = os.path.join(cache_root, "cache", "music") + self._ensure_cache_dir() + + # 当前正在使用的临时文件 + self.current_temp_file = None + + # 获取应用程序实例 + self.app = Application.get_instance() + + # 加载配置文件 + self.config = self._load_config() + + # 清空临时缓存 + self._clear_temp_cache() + + # 清理遗留的临时文件 + self._cleanup_temp_files() + + logger.info("音乐播放器初始化完成") + + # 注册属性和方法 + self._register_properties() + self._register_methods() + + def _register_properties(self): + """注册播放器属性""" + self.add_property("current_song", "当前歌曲", lambda: self.current_song) + self.add_property("is_playing", "是否正在播放", lambda: self.is_playing) + self.add_property("paused", "是否暂停", lambda: self.paused) + self.add_property("total_duration", "歌曲总时长(秒)", + lambda: self.total_duration) + self.add_property("current_position", "当前播放位置(秒)", + lambda: self._get_current_position()) + self.add_property("progress", "播放进度(百分比)", + lambda: self._get_progress()) + + def _register_methods(self): + """注册播放器方法""" + self.add_method( + "SearchPlay", + "搜索并播放指定歌曲", + [Parameter("song_name", "输入歌曲名称", ValueType.STRING, True)], + lambda params: self.search_play(params["song_name"].get_value()) + ) + + self.add_method( + "SearchSong", + "仅搜索歌曲不播放", + [Parameter("song_name", "输入歌曲名称", ValueType.STRING, True)], + lambda params: self._search_song(params["song_name"].get_value()) + ) + + self.add_method( + "PlayPause", + "播放/暂停切换", + [], + lambda params: self.play_pause() + ) + + self.add_method( + "Stop", + "停止播放", + [], + lambda params: self.stop() + ) + + self.add_method( + "Seek", + "跳转到指定位置", + [Parameter("position_seconds", "跳转位置(秒)", ValueType.NUMBER, True)], + lambda params: self.seek(params["position_seconds"].get_value()) + ) + + self.add_method( + "GetLyrics", + "获取当前歌曲歌词", + [], + lambda params: self._get_lyrics_text() + ) + + def _load_config(self) -> Dict[str, Any]: + """ + 加载配置文件 + + 返回: + Dict[str, Any]: 音乐播放器配置 + """ + return { + "API": { + "SEARCH_URL": "http://search.kuwo.cn/r.s", + "PLAY_URL": "http://api.xiaodaokg.com/kuwo.php", + "LYRIC_URL": "http://m.kuwo.cn/newh5/singles/songinfoandlrc" + }, + "HEADERS": { + "User-Agent": "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + "AppleWebKit/537.36 (KHTML, like Gecko) " + "Chrome/91.0.4472.124 Safari/537.36", + "Accept": "*/*", + "Accept-Encoding": "identity", + "Connection": "keep-alive", + "Referer": "https://y.kuwo.cn/", + "Cookie": "" + } + } + + def _search_song(self, song_name: str) -> Dict[str, Any]: + """ + 搜索指定歌曲 + + 参数: + song_name: 歌曲名称 + + 返回: + Dict[str, Any]: 搜索结果 + """ + # 重置搜索状态 + self.current_song = song_name + self.current_url = "" + self.song_id = "" + self.total_duration = 0 + self.lyrics = [] + + # 通过API搜索获取歌曲信息 + try: + # 获取歌曲ID和播放URL + song_id, url = self._get_song_info(song_name) + if not song_id or not url: + return { + "status": "error", + "message": f"未找到歌曲 '{song_name}' 或无法获取播放链接" + } + + # 保存歌曲信息 + self.current_url = url + self.song_id = song_id + + logger.info(f"搜索成功: {song_name}, URL: {url}") + + # 返回搜索结果 + return { + "status": "success", + "message": f"已找到歌曲: {self.current_song}", + "song_id": song_id, + "url": url, + "duration": self.total_duration, + "lyrics_count": len(self.lyrics) + } + + except Exception as e: + logger.error(f"搜索歌曲失败: {str(e)}") + return {"status": "error", "message": f"搜索歌曲失败: {str(e)}"} + + def _get_song_info(self, song_name: str) -> Tuple[str, str]: + """ + 获取歌曲信息(ID和播放URL) + + 参数: + song_name: 歌曲名称 + + 返回: + Tuple[str, str]: (歌曲ID, 播放URL) + """ + # 从配置中获取请求头和API URL + headers = self.config.get("HEADERS", {}) + search_url = self.config.get("API", {}).get( + "SEARCH_URL", "http://search.kuwo.cn/r.s") + play_url = self.config.get("API", {}).get( + "PLAY_URL", "http://api.xiaodaokg.com/kuwo.php") + + # 1. 搜索歌曲获取ID + search_params = { + "all": song_name, + "ft": "music", + "newsearch": "1", + "alflac": "1", + "itemset": "web_2013", + "client": "kt", + "cluster": "0", + "pn": "0", + "rn": "1", + "vermerge": "1", + "rformat": "json", + "encoding": "utf8", + "show_copyright_off": "1", + "pcmp4": "1", + "ver": "mbox", + "vipver": "MUSIC_8.7.6.0.BCS31", + "plat": "pc", + "devid": "0" + } + + logger.info(f"搜索歌曲: {song_name}") + + try: + response = requests.get( + search_url, params=search_params, headers=headers, timeout=10) + response.raise_for_status() + + # 记录响应内容到日志(调试用) + logger.debug(f"搜索API响应内容: {response.text[:200]}...") + + # 处理响应文本 + response_text = response.text.replace("'", '"') # 替换单引号为双引号 + + # 提取歌曲ID + song_id = "" + dc_targetid_pos = response_text.find('"DC_TARGETID":"') + if dc_targetid_pos != -1: + start_pos = dc_targetid_pos + len('"DC_TARGETID":"') + end_pos = response_text.find('"', start_pos) + if end_pos != -1: + song_id = response_text[start_pos:end_pos] + logger.info(f"提取到歌曲ID: {song_id}") + + # 如果没有找到歌曲ID,返回失败 + if not song_id: + logger.warning(f"未找到歌曲 '{song_name}' 的ID") + return "", "" + + # 提取歌曲时长 + duration = 0 + duration_pos = response_text.find('"DURATION":"') + if duration_pos != -1: + start_pos = duration_pos + len('"DURATION":"') + end_pos = response_text.find('"', start_pos) + if end_pos != -1: + try: + duration = int(response_text[start_pos:end_pos]) + self.total_duration = duration + logger.info(f"提取到歌曲时长: {duration}秒") + except ValueError: + logger.warning( + f"歌曲时长解析失败: {response_text[start_pos:end_pos]}") + + # 提取艺术家 + artist = "" + artist_pos = response_text.find('"ARTIST":"') + if artist_pos != -1: + start_pos = artist_pos + len('"ARTIST":"') + end_pos = response_text.find('"', start_pos) + if end_pos != -1: + artist = response_text[start_pos:end_pos] + + # 提取歌曲名 + title = song_name + name_pos = response_text.find('"NAME":"') + if name_pos != -1: + start_pos = name_pos + len('"NAME":"') + end_pos = response_text.find('"', start_pos) + if end_pos != -1: + title = response_text[start_pos:end_pos] + + # 提取专辑名 + album = "" + album_pos = response_text.find('"ALBUM":"') + if album_pos != -1: + start_pos = album_pos + len('"ALBUM":"') + end_pos = response_text.find('"', start_pos) + if end_pos != -1: + album = response_text[start_pos:end_pos] + + # 更新当前歌曲信息 + display_name = title + if artist: + display_name = f"{title} - {artist}" + if album: + display_name += f" ({album})" + self.current_song = display_name + + logger.info( + f"获取到歌曲: {self.current_song}, ID: {song_id}, 时长: {duration}秒") + + # 2. 获取歌曲播放链接 + play_api_url = f"{play_url}?ID={song_id}" + logger.info(f"获取歌曲播放链接: {play_api_url}") + + for attempt in range(3): + try: + url_response = requests.get( + play_api_url, headers=headers, timeout=10) + url_response.raise_for_status() + + # 获取播放链接(直接返回的文本) + play_url_text = url_response.text.strip() + + # 检查URL是否有效 + if play_url_text and play_url_text.startswith("http"): + logger.info(f"获取到有效的歌曲URL: {play_url_text[:60]}...") + + # 3. 获取歌词 + self._fetch_lyrics(song_id) + + return song_id, play_url_text + else: + logger.warning( + f"返回的播放链接格式不正确: {play_url_text[:100]}") + if attempt < 2: + logger.info(f"尝试重新获取播放链接 ({attempt+1}/3)") + time.sleep(1) + else: + return song_id, "" + except Exception as e: + logger.error(f"获取播放链接时出错: {str(e)}") + if attempt < 2: + logger.info(f"尝试重新获取播放链接 ({attempt+1}/3)") + time.sleep(1) + else: + return song_id, "" + + return song_id, "" + except Exception as e: + logger.error(f"获取歌曲信息失败: {str(e)}") + return "", "" + + def _fetch_lyrics(self, song_id: str): + """ + 获取歌词 + + 参数: + song_id: 歌曲ID + """ + try: + # 从配置中获取请求头和API URL + headers = self.config.get("HEADERS", {}) + lyric_url = self.config.get("API", {}).get( + "LYRIC_URL", "http://m.kuwo.cn/newh5/singles/songinfoandlrc") + + # 构建歌词API请求 + lyric_api_url = f"{lyric_url}?musicId={song_id}" + logger.info(f"获取歌词URL: {lyric_api_url}") + + response = requests.get(lyric_api_url, headers=headers, timeout=10) + response.raise_for_status() + + # 添加错误处理 + try: + # 尝试解析JSON + data = response.json() + + # 解析歌词 + if (data.get("status") == 200 and data.get("data") and + data["data"].get("lrclist")): + lrc_list = data["data"]["lrclist"] + self.lyrics = [] + + for lrc in lrc_list: + time_sec = float(lrc.get("time", "0")) + text = lrc.get("lineLyric", "").strip() + + # 跳过空歌词和元信息歌词 + if (text and not text.startswith("作词") and + not text.startswith("作曲") and + not text.startswith("编曲")): + self.lyrics.append((time_sec, text)) + + logger.info(f"成功获取歌词,共 {len(self.lyrics)} 行") + else: + logger.warning( + f"未获取到歌词或歌词格式错误: {data.get('msg', '')}") + except ValueError as e: + logger.warning(f"歌词API返回非JSON格式数据: {str(e)}") + # 记录部分响应内容 + if hasattr(response, 'text') and response.text: + sample = (response.text[:100] + "..." + if len(response.text) > 100 else response.text) + logger.warning(f"歌词API响应内容: {sample}") + except Exception as e: + logger.error(f"获取歌词失败: {str(e)}") + + def _update_lyrics(self): + """ + 根据当前播放位置更新歌词显示 + """ + # 如果没有歌词或应用程序正在说话,不更新歌词 + if not self.lyrics or self.app.get_is_tts_playing(): + return + + current_time = self.current_position + + # 查找当前时间对应的歌词 + current_index = self._find_current_lyric_index(current_time) + + # 如果歌词索引变化了,更新显示 + if current_index != self.current_lyric_index: + self._display_current_lyric(current_index) + + def _find_current_lyric_index(self, current_time: float) -> int: + """ + 查找当前时间对应的歌词索引 + + 参数: + current_time: 当前播放时间(秒) + + 返回: + int: 当前歌词索引 + """ + # 查找下一句歌词 + next_lyric_index = None + for i, (time_sec, _) in enumerate(self.lyrics): + # 添加一个小的偏移量(0.5秒),使歌词显示更准确 + if time_sec > current_time - 0.5: + next_lyric_index = i + break + + # 确定当前歌词索引 + if next_lyric_index is not None and next_lyric_index > 0: + # 如果找到下一句歌词,当前歌词就是它的前一句 + return next_lyric_index - 1 + elif next_lyric_index is None and self.lyrics: + # 如果没找到下一句,说明已经到最后一句 + return len(self.lyrics) - 1 + else: + # 其他情况(如播放刚开始) + return 0 + + def _display_current_lyric(self, current_index: int): + """ + 显示当前歌词 + + 参数: + current_index: 当前歌词索引 + """ + self.current_lyric_index = current_index + + if current_index < len(self.lyrics): + time_sec, text = self.lyrics[current_index] + + # 只在应用程序不在说话时更新UI + # 创建歌词文本副本,避免引用可能变化的变量 + lyric_text = text + + # 在歌词前添加时间和进度信息 + position_str = self._format_time(self.current_position) + duration_str = self._format_time(self.total_duration) + display_text = f"[{position_str}/{duration_str}] {lyric_text}" + + # 使用schedule方法安全地更新UI + if self.app: + self.app.schedule(lambda: self.app.set_chat_message( + "assistant", display_text)) + logger.debug(f"显示歌词: {lyric_text}") + + def _get_lyrics_text(self) -> Dict[str, Any]: + """ + 获取当前歌曲歌词文本 + + 返回: + Dict[str, Any]: 歌词信息 + """ + if not self.lyrics: + return {"status": "info", "message": "当前歌曲没有歌词", "lyrics": []} + + # 提取歌词文本,转换为列表 + lyrics_text = [] + for time_sec, text in self.lyrics: + time_str = self._format_time(time_sec) + lyrics_text.append(f"[{time_str}] {text}") + + return { + "status": "success", + "message": f"获取到 {len(self.lyrics)} 行歌词", + "lyrics": lyrics_text + } + + def _download_file(self, url: str, file_path: str) -> bool: + """ + 下载音乐文件到指定路径(同步方法) + + 参数: + url: 音乐URL + file_path: 保存路径 + + 返回: + bool: 是否下载成功 + """ + try: + # 使用配置中的请求头 + headers = self.config.get("HEADERS", {}).copy() + headers.update({ + 'Accept-Encoding': 'gzip, deflate, br', + 'Referer': 'https://music.163.com/' + }) + + # 创建唯一的临时文件路径,避免冲突 + temp_path = f"{file_path}.{int(time.time())}.tmp" + + # 下载文件 + with requests.get(url, stream=True, headers=headers, + timeout=30) as response: + response.raise_for_status() + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + + with open(temp_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=32768): + if chunk: + f.write(chunk) + downloaded += len(chunk) + # 每下载25%更新一次日志 + if (total_size > 0 and + downloaded % (total_size // 4) < 32768): + progress = downloaded * 100 // total_size + logger.info(f"下载进度: {progress}%") + + # 下载完成后,将临时文件重命名为正式文件 + if downloaded == total_size: + # 如果目标文件已存在,先尝试删除 + if os.path.exists(file_path): + try: + os.remove(file_path) + except Exception as e: + logger.warning(f"删除已存在的文件失败: {str(e)}") + # 如果无法删除,使用新名称 + file_path = f"{file_path}.new" + + try: + os.replace(temp_path, file_path) + logger.info(f"音乐文件下载完成: {file_path}") + return True + except Exception as e: + logger.error(f"重命名临时文件失败: {str(e)}") + return False + else: + # 如果下载不完整,删除临时文件 + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except Exception: + pass + logger.warning("音乐文件下载不完整") + return False + + except Exception as e: + logger.error(f"下载音乐文件失败: {str(e)}") + # 清理临时文件 + try: + if 'temp_path' in locals() and os.path.exists(temp_path): + os.remove(temp_path) + except Exception: + pass + return False + + def _download_mp3(self, url: str, cache_path: str): + """ + 下载完整的MP3文件到缓存目录 + + 参数: + url: 音频URL + cache_path: 缓存文件路径 + """ + try: + # 使用配置中的请求头 + headers = self.config.get("HEADERS", {}).copy() + headers.update({ + 'Accept-Encoding': 'gzip, deflate, br', + 'Referer': 'https://music.163.com/' + }) + + # 创建唯一的临时文件路径,避免冲突 + temp_path = f"{cache_path}.{int(time.time())}.temp" + + with requests.get(url, stream=True, headers=headers, timeout=30) as response: + response.raise_for_status() + total_size = int(response.headers.get('content-length', 0)) + downloaded = 0 + + with open(temp_path, 'wb') as f: + for chunk in response.iter_content(chunk_size=32768): + if not self.is_playing: + logger.info("缓存下载被中止") + break + if chunk: + f.write(chunk) + downloaded += len(chunk) + # 每下载10%更新一次日志 + if total_size > 0 and downloaded % (total_size // 10) < 32768: + progress = downloaded * 100 // total_size + logger.info(f"缓存下载进度: {progress}%") + + # 下载完成后,将临时文件重命名为正式文件 + if downloaded == total_size and self.is_playing: + # 如果目标文件已存在且正在使用,不覆盖 + if os.path.exists(cache_path): + try: + # 尝试删除已存在的文件 + os.remove(cache_path) + os.replace(temp_path, cache_path) + logger.info("MP3文件已缓存到本地") + except Exception as e: + logger.warning(f"替换缓存文件失败,可能正在使用中: {str(e)}") + # 保留临时文件,不删除 + logger.info(f"保留临时缓存文件: {temp_path}") + else: + try: + os.replace(temp_path, cache_path) + logger.info("MP3文件已缓存到本地") + except Exception as e: + logger.error(f"缓存MP3文件失败: {str(e)}") + if os.path.exists(temp_path): + try: + os.remove(temp_path) + except Exception: + pass + else: + # 如果下载不完整或播放已停止,删除临时文件 + if os.path.exists(temp_path): + try: + os.remove(temp_path) + logger.info("已清理临时文件") + except Exception as e: + logger.error(f"清理临时文件失败: {str(e)}") + + except Exception as e: + logger.error(f"下载MP3文件失败: {str(e)}") + # 清理临时文件 + try: + if 'temp_path' in locals() and os.path.exists(temp_path): + os.remove(temp_path) + logger.info("已清理临时文件") + except Exception as e: + logger.error(f"清理临时文件失败: {str(e)}") + + def _ensure_cache_dir(self): + """确保缓存目录存在""" + try: + if not os.path.exists(self.cache_dir): + os.makedirs(self.cache_dir) + logger.info(f"创建音乐缓存目录: {self.cache_dir}") + except Exception as e: + logger.error(f"创建缓存目录失败: {str(e)}") + + def _get_cache_path(self, song_id: str) -> str: + """获取歌曲缓存文件路径""" + return os.path.join(self.cache_dir, f"{song_id}.mp3") + + def _is_song_cached(self, song_id: str) -> bool: + """检查歌曲是否已缓存""" + cache_path = self._get_cache_path(song_id) + return os.path.exists(cache_path) + + def _get_current_position(self) -> float: + """ + 获取当前播放位置 + + 返回: + float: 当前播放位置(秒) + """ + if not self.is_playing: + return self.current_position + + if self.paused: + return self.current_position + + # 如果正在播放,计算当前位置 + current_pos = time.time() - self.start_play_time + return min(self.total_duration, current_pos) + + def _get_progress(self) -> float: + """ + 获取播放进度百分比 + + 返回: + float: 播放进度(0-100) + """ + if self.total_duration <= 0: + return 0 + return round(self._get_current_position() * 100 / self.total_duration, 1) + + def search_play(self, song_name: str) -> Dict[str, Any]: + """ + 搜索并播放指定歌曲 + + 参数: + song_name: 歌曲名称 + + 返回: + Dict[str, Any]: 播放结果 + """ + # 先搜索歌曲 + result = self._search_song(song_name) + + # 如果搜索成功且有URL,则播放 + if result.get("status") == "success" and self.current_url: + self._play_url(self.current_url) + return { + "status": "success", + "message": f"正在播放: {self.current_song}", + "duration": self.total_duration + } + + # 搜索失败直接返回搜索结果 + return result + + def _cleanup_temp_files(self, max_keep=1): + """ + 清理临时文件夹中的旧文件,只保留最新的几个 + + 参数: + max_keep: 保留的最新文件数量 + """ + try: + temp_dir = os.path.join(self.cache_dir, "temp") + if not os.path.exists(temp_dir): + return + + # 获取所有临时文件 + files = [] + for f in os.listdir(temp_dir): + if f.startswith("playing_") and f.endswith(".mp3"): + file_path = os.path.join(temp_dir, f) + files.append((file_path, os.path.getmtime(file_path))) + + # 按修改时间排序 + files.sort(key=lambda x: x[1], reverse=True) + + # 保留最新的几个,删除其余的 + for file_path, _ in files[max_keep:]: + try: + if file_path != self.current_temp_file: + os.remove(file_path) + logger.info(f"已清理旧的临时文件: {file_path}") + except Exception as e: + logger.warning(f"清理临时文件失败: {str(e)}") + + except Exception as e: + logger.warning(f"清理临时文件操作失败: {str(e)}") + + def _clear_temp_cache(self): + """ + 清空临时缓存目录 + """ + try: + temp_dir = os.path.join(self.cache_dir, "temp") + if not os.path.exists(temp_dir): + return + + cleared = 0 + for f in os.listdir(temp_dir): + if f.endswith(".mp3") or f.endswith(".tmp") or f.endswith(".temp"): + try: + os.remove(os.path.join(temp_dir, f)) + cleared += 1 + except Exception as e: + logger.warning(f"删除临时文件失败: {str(e)}") + + if cleared > 0: + logger.info(f"启动时清理了 {cleared} 个临时缓存文件") + + except Exception as e: + logger.warning(f"清理临时缓存失败: {str(e)}") + + def _handle_tts_priority(self): + """处理TTS优先级逻辑""" + current_time = time.time() + + if not self.app: + return + + tts_playing = self.app.get_is_tts_playing() + + # 检查是否有打断请求 + if hasattr(self.app, 'aborted') and self.app.aborted: + if not self.paused: + logger.info("检测到打断请求,暂停音乐播放") + pygame.mixer.music.pause() + self.paused = True + self.current_position = time.time() - self.start_play_time + return + + # 捕获 TTS状态变化(从True到False) + if self._last_tts_playing is None: + # 初始化 + self._last_tts_playing = tts_playing + + if tts_playing != self._last_tts_playing: + # 状态变化了! + if self._last_tts_playing and not tts_playing: + # 从 正在播放 -> 播放结束 + if self.paused_for_tts: + logger.info("TTS播放结束,恢复音乐播放") + pygame.mixer.music.unpause() + self.paused = False + self.paused_for_tts = False + self.total_pause_time += (current_time - self.pause_start_time) + self.start_play_time = time.time() - self.current_position + + elif not self._last_tts_playing and tts_playing: + # 从 不播放 -> 开始播放 + if not self.paused and not self.paused_for_tts: + logger.info("TTS正在播放,暂停音乐播放") + pygame.mixer.music.pause() + self.paused = True + self.paused_for_tts = True + self.pause_start_time = current_time + self.current_position = time.time() - self.start_play_time + + # 更新上一次的状态 + self._last_tts_playing = tts_playing + + def _play_url(self, url: str) -> bool: + """ + 播放指定URL的音乐 + + 参数: + url: 音乐URL + + 返回: + bool: 是否成功开始播放 + """ + # 如果当前有歌曲在播放,先停止 + if self.is_playing: + self.stop() + + try: + # 创建临时文件路径 + temp_dir = os.path.join(self.cache_dir, "temp") + if not os.path.exists(temp_dir): + os.makedirs(temp_dir) + + temp_file = os.path.join(temp_dir, "current_playing.mp3") + + # 检查是否有缓存 + cache_path = None + use_cache = False + + if self.song_id: + cache_path = self._get_cache_path(self.song_id) + # 检查缓存是否存在且可用 + if os.path.exists(cache_path): + try: + # 尝试直接使用缓存文件播放 + pygame.mixer.music.load(cache_path) + use_cache = True + logger.info(f"使用缓存播放: {cache_path}") + except Exception as e: + # 如果加载缓存失败,回退到下载 + logger.warning(f"加载缓存文件失败: {str(e)},将重新下载") + use_cache = False + + if not use_cache: + # 需要下载文件 + if os.path.exists(temp_file): + try: + # 尝试清理已存在的临时文件 + os.remove(temp_file) + logger.info("已清理临时播放文件") + except Exception as e: + logger.warning(f"清理临时播放文件失败: {str(e)}") + # 使用唯一文件名代替 + temp_file = os.path.join(temp_dir, f"playing_{int(time.time())}.mp3") + + # 记录当前使用的临时文件 + self.current_temp_file = temp_file + + # 清理过多的旧临时文件 + self._cleanup_temp_files(max_keep=3) + + # 如果有缓存路径但缓存不存在,直接下载到缓存位置并创建符号链接或副本到临时位置 + if cache_path and not os.path.exists(cache_path): + logger.info(f"下载音乐到缓存: {cache_path}") + if self._download_file(url, cache_path): + try: + # 创建从缓存到临时文件的副本 + import shutil + shutil.copy2(cache_path, temp_file) + logger.info(f"从缓存创建临时播放文件: {temp_file}") + pygame.mixer.music.load(temp_file) + except Exception as e: + logger.error(f"创建临时播放文件失败: {str(e)},尝试直接使用缓存") + try: + pygame.mixer.music.load(cache_path) + except Exception as e2: + logger.error(f"加载缓存文件失败: {str(e2)}") + return False + else: + logger.error("下载到缓存失败") + return False + else: + # 没有缓存路径或无法使用缓存,直接下载到临时文件 + logger.info(f"下载音乐到临时文件: {temp_file}") + if not self._download_file(url, temp_file): + logger.error("下载音乐文件失败") + return False + + pygame.mixer.music.load(temp_file) + + # 如果有缓存路径但缓存不存在,异步创建缓存(非必须,仅作为备份) + if cache_path and not os.path.exists(cache_path) and os.path.exists(temp_file): + def copy_to_cache(): + try: + import shutil + shutil.copy2(temp_file, cache_path) + logger.info(f"临时文件已复制到缓存: {cache_path}") + except Exception as e: + logger.warning(f"复制到缓存失败: {str(e)}") + + threading.Thread( + target=copy_to_cache, + daemon=True + ).start() + + # 开始播放 + pygame.mixer.music.play() + self.is_playing = True + self.paused = False + self.current_position = 0 + self.start_play_time = time.time() + + # 更新UI显示 + if self.app: + self.app.schedule(lambda: self.app.set_chat_message( + "assistant", f"正在播放: {self.current_song}")) + + # 启动进度更新线程 + self._start_progress_thread() + + return True + + except Exception as e: + logger.error(f"播放歌曲失败: {str(e)}") + self.is_playing = False + return False + + def play_pause(self) -> Dict[str, Any]: + """ + 播放/暂停切换 + + 返回: + Dict[str, Any]: 操作结果 + """ + if not self.is_playing: + # 如果没有正在播放的歌曲但有URL,尝试播放 + if self.current_url: + if self._play_url(self.current_url): + return { + "status": "success", + "message": f"开始播放: {self.current_song}" + } + else: + return { + "status": "error", + "message": "播放失败" + } + else: + return { + "status": "error", + "message": "没有可播放的歌曲" + } + elif self.paused: + # 恢复播放 + pygame.mixer.music.unpause() + self.paused = False + # 更新开始时间,考虑已经暂停的时间 + self.start_play_time = time.time() - self.current_position + + if self.app: + self.app.schedule(lambda: self.app.set_chat_message( + "assistant", f"继续播放: {self.current_song}")) + + return { + "status": "success", + "message": f"继续播放: {self.current_song}" + } + else: + # 暂停播放 + pygame.mixer.music.pause() + self.paused = True + self.current_position = time.time() - self.start_play_time + + if self.app: + pos_str = self._format_time(self.current_position) + dur_str = self._format_time(self.total_duration) + self.app.schedule(lambda: self.app.set_chat_message( + "assistant", f"已暂停: {self.current_song} [{pos_str}/{dur_str}]")) + + return { + "status": "success", + "message": f"已暂停: {self.current_song}", + "position": self.current_position + } + + def stop(self) -> Dict[str, Any]: + """ + 停止播放 + + 返回: + Dict[str, Any]: 操作结果 + """ + if not self.is_playing: + return { + "status": "info", + "message": "没有正在播放的歌曲" + } + + # 停止进度更新线程 + self.stop_progress.set() + if self.progress_thread and self.progress_thread.is_alive(): + self.progress_thread.join(timeout=1.0) + self.stop_progress.clear() + + # 停止音乐播放 + pygame.mixer.music.stop() + + # 更改播放状态 + current_song = self.current_song + self.is_playing = False + self.paused = False + self.paused_for_tts = False # 重置TTS暂停状态 + + # 清理临时文件 + temp_dir = os.path.join(self.cache_dir, "temp") + if os.path.exists(temp_dir): + try: + temp_file = os.path.join(temp_dir, "current_playing.mp3") + if os.path.exists(temp_file): + try: + # 尝试删除临时播放文件 + os.remove(temp_file) + logger.info("已清理临时播放文件") + except Exception as e: + logger.warning(f"清理临时播放文件失败: {str(e)}") + except Exception as e: + logger.warning(f"清理临时文件时出错: {str(e)}") + + # 清理旧的临时文件 + self._cleanup_temp_files(max_keep=1) + + # 重置当前临时文件 + self.current_temp_file = None + + # 返回结果 + msg = f"已停止播放: {current_song}" + if self.app: + self.app.schedule(lambda: self.app.set_chat_message("assistant", msg)) + + return { + "status": "success", + "message": msg + } + + def seek(self, position: float) -> Dict[str, Any]: + """ + 跳转到指定位置 + + 参数: + position: 目标位置(秒) + + 返回: + Dict[str, Any]: 操作结果 + """ + if not self.is_playing: + return { + "status": "error", + "message": "没有正在播放的歌曲" + } + + # 确保位置在有效范围内 + position = max(0, min(position, self.total_duration)) + + # 记录当前位置 + self.current_position = position + + # 更新开始时间 + self.start_play_time = time.time() - position + + # 使用pygame跳转 + pygame.mixer.music.rewind() + pygame.mixer.music.set_pos(position) + + # 如果处于暂停状态,保持暂停 + if self.paused: + pygame.mixer.music.pause() + + # 更新UI + pos_str = self._format_time(position) + dur_str = self._format_time(self.total_duration) + msg = f"已跳转到: {pos_str}/{dur_str}" + + if self.app: + self.app.schedule(lambda: self.app.set_chat_message("assistant", msg)) + + return { + "status": "success", + "message": msg, + "position": position + } + + def _start_progress_thread(self): + """启动进度更新线程""" + # 确保之前的线程已经停止 + if self.progress_thread and self.progress_thread.is_alive(): + self.stop_progress.set() + self.progress_thread.join(timeout=1.0) + self.stop_progress.clear() + + # 创建新线程 + self.progress_thread = threading.Thread( + target=self._update_progress_thread, + daemon=True + ) + self.progress_thread.start() + + def _update_progress_thread(self): + """进度更新线程""" + last_lyric_update = 0 + last_tts_check = 0 + + while not self.stop_progress.is_set() and self.is_playing: + current_time = time.time() + + # 如果暂停了,等待恢复 + if self.paused and not self.paused_for_tts: + time.sleep(0.2) + continue + + # 每200ms检查一次TTS状态 + if current_time - last_tts_check > 0.2: + self._handle_tts_priority() + last_tts_check = current_time + + # 如果因为TTS而暂停,继续等待 + if self.paused_for_tts: + time.sleep(0.1) + continue + + # 计算当前位置 + self.current_position = time.time() - self.start_play_time + + # 检查是否到达歌曲末尾 + if self.current_position >= self.total_duration: + # 已播放完成 + self.current_position = self.total_duration + logger.info(f"歌曲 '{self.current_song}' 播放完成") + + # 停止播放并重置状态 + pygame.mixer.music.stop() + self.is_playing = False + self.paused_for_tts = False # 重置TTS暂停状态 + + # 更新UI显示完成状态 + if self.app: + dur_str = self._format_time(self.total_duration) + self.app.schedule(lambda: self.app.set_chat_message( + "assistant", f"播放完成: {self.current_song} [{dur_str}]")) + + # 根据自动模式设置应用状态 + if self.app: + self.app.schedule(lambda: self.app.set_device_state(DeviceState.IDLE)) + break + + # 更新歌词显示(每0.5秒检查一次) + if time.time() - last_lyric_update > 0.5: + self._update_lyrics() + last_lyric_update = time.time() + + # 短暂休眠再继续 + time.sleep(0.1) + + logger.debug("进度更新线程已退出") + + def _format_time(self, seconds: float) -> str: + """ + 将秒数格式化为 mm:ss 格式 + + 参数: + seconds: 秒数 + + 返回: + str: 格式化后的时间字符串 + """ + minutes = int(seconds) // 60 + seconds = int(seconds) % 60 + return f"{minutes:02d}:{seconds:02d}" \ No newline at end of file diff --git a/src/iot/things/query_bridge_rag.py b/src/iot/things/query_bridge_rag.py new file mode 100644 index 0000000000000000000000000000000000000000..e74a1a64cc13a6d6217d7c71e650547710a678c5 --- /dev/null +++ b/src/iot/things/query_bridge_rag.py @@ -0,0 +1,93 @@ +from src.iot.thing import Thing, Parameter, ValueType + + +def get_rag_result(qurey): + """ + 介绍莱斯城市治理系统 + + 返回: + str: 介绍信息 + """ + print("查询:",qurey) + introduction = "这里是你查询的函数,并且返回内容得地方" + return introduction + + +class QueryBridgeRAG(Thing): + def __init__(self): + super().__init__("查询桥接器", "联网查询信息并存储结果") + # 存储查询到的内容 + self.query_result = "" + self.last_query = "" + + # 注册属性 + self.add_property("query_result", "当前查询结果", lambda: self.query_result) + self.add_property("last_query", "上次查询内容", lambda: self.last_query) + + self._register_methods() + + def _register_methods(self): + # 查询信息 + self.add_method( + "Query", + "查询信息", + [Parameter("query", "查询内容", ValueType.STRING, True)], + lambda params: self._query_info_and_store(params["query"].get_value()) + ) + + # 获取查询结果 + self.add_method( + "GetQueryResult", + "获取查询结果", + [], + lambda params: {"result": self.query_result, "query": self.last_query} + ) + + def _query_info(self, query): + """ + 查询信息 + + 参数: + query (str): 查询内容 + + 返回: + str: 查询结果 + """ + try: + # 调用逻辑层的 RAG 知识库查询 + result = get_rag_result(query) + # rag 查询 + + # 其他的联网方式例如dify + + + return result + except Exception as e: + print(f"查询信息失败: {e}") + return f"很抱歉,查询'{query}'时出现了错误。" + + def _query_info_and_store(self, query): + """ + 查询信息并存储 + + 参数: + query (str): 查询内容 + + 返回: + dict: 操作结果 + """ + try: + # 记录查询内容 + self.last_query = query + + # 查询信息并存储 + self.query_result = self._query_info(query) + + return { + "success": True, + "message": "查询成功", + "result": self.query_result + } + except Exception as e: + return {"success": False, "message": f"查询失败: {e}"} + diff --git a/src/iot/things/speaker.py b/src/iot/things/speaker.py new file mode 100644 index 0000000000000000000000000000000000000000..3d548da19975577c183e116fdfdde7368cc2a289 --- /dev/null +++ b/src/iot/things/speaker.py @@ -0,0 +1,39 @@ +from src.application import Application +from src.iot.thing import Thing, Parameter, ValueType + + +class Speaker(Thing): + def __init__(self): + super().__init__("Speaker", "当前 AI 机器人的扬声器") + + # 获取当前显示实例的音量作为初始值 + try: + app = Application.get_instance() + self.volume = app.display.current_volume + except Exception: + # 如果获取失败,使用默认值 + self.volume = 100 # 默认音量 + + # 定义属性 + self.add_property("volume", "当前音量值", lambda: self.volume) + + # 定义方法 + self.add_method( + "SetVolume", + "设置音量", + [Parameter("volume", "0到100之间的整数", ValueType.NUMBER, True)], + lambda params: self._set_volume(params["volume"].get_value()) + ) + + def _set_volume(self, volume): + if 0 <= volume <= 100: + self.volume = volume + try: + app = Application.get_instance() + app.display.update_volume(volume) + return {"success": True, "message": f"音量已设置为: {volume}"} + except Exception as e: + print(f"设置音量失败: {e}") + return {"success": False, "message": f"设置音量失败: {e}"} + else: + raise ValueError("音量必须在0-100之间") \ No newline at end of file diff --git a/src/iot/things/temperature_sensor.py b/src/iot/things/temperature_sensor.py new file mode 100644 index 0000000000000000000000000000000000000000..a9a28feece265d30568632a464babd343f90351d --- /dev/null +++ b/src/iot/things/temperature_sensor.py @@ -0,0 +1,247 @@ +import asyncio +import json +import time +import threading +from typing import Dict +from datetime import datetime + +from src.application import Application +from src.constants.constants import DeviceState +from src.iot.thing import Thing, Parameter, ValueType +from src.network.mqtt_client import MqttClient + + + +class TemperatureSensor(Thing): + def __init__(self): + super().__init__("TemperatureSensor", "温度传感器设备") + self.temperature = 0.0 # 初始温度值为0摄氏度 + self.humidity = 0.0 # 初始湿度值为0% + self.last_update_time = 0 # 最后一次更新时间 + self.is_running = False + self.mqtt_client = None + self.app = None # 初始化app属性为None + + print("[IoT设备] 温度传感器接收端初始化完成") + + # 定义属性 + self.add_property("temperature", "当前温度(摄氏度)", + lambda: self.temperature) + self.add_property("humidity", "当前湿度(%)", + lambda: self.humidity) + self.add_property("last_update_time", "最后更新时间", + lambda: self.last_update_time) + + # self.add_method("getTemperature", "获取温度传感器数据", + # [], + # lambda params: self.get_temperature()) + + # 初始化MQTT客户端 + self._init_mqtt() + + def _init_mqtt(self): + """初始化MQTT客户端""" + from src.utils.config_manager import ConfigManager + config = ConfigManager.get_instance() + try: + self.mqtt_client = MqttClient( + server=config.get_config("TEMPERATURE_SENSOR_MQTT_INFO.endpoint"), + port=config.get_config("TEMPERATURE_SENSOR_MQTT_INFO.port"), + username=config.get_config("TEMPERATURE_SENSOR_MQTT_INFO.username"), + password=config.get_config("TEMPERATURE_SENSOR_MQTT_INFO.password"), + # 订阅传感器数据发送的主题 + subscribe_topic=config.get_config("TEMPERATURE_SENSOR_MQTT_INFO.subscribe_topic"), + ) + + # 设置自定义消息处理回调 + self.mqtt_client.client.on_message = self._on_mqtt_message + + # 连接MQTT服务器 + self.mqtt_client.connect() + self.mqtt_client.start() + print("[温度传感器] MQTT客户端已连接") + except Exception as e: + print(f"[温度传感器] MQTT连接失败: {e}") + + def _on_mqtt_message(self, client, userdata, msg): + """处理MQTT消息""" + try: + topic = msg.topic + payload = msg.payload.decode('utf-8') + print(f"[温度传感器] 收到数据 - 主题: {topic}, 内容: {payload}") + + # 尝试将消息解析为JSON + try: + data = json.loads(payload) + + # 如果收到的是温度传感器数据 + if 'temperature' in data and 'humidity' in data: + # 更新温度和湿度 + self.temperature = data.get('temperature') + self.humidity = data.get('humidity') + + # 处理时间戳 - 支持多种格式 + timestamp = data.get('timestamp') + if timestamp is not None: + # 如果是字符串格式(ISO时间) + if isinstance(timestamp, str): + try: + # 尝试解析ISO格式的时间字符串 + dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) + self.last_update_time = int(dt.timestamp()) + except ValueError: + # 如果解析失败,使用当前时间 + self.last_update_time = int(time.time()) + else: + # 如果是数字,直接使用 + self.last_update_time = int(timestamp) + else: + # 如果没有提供时间戳,使用当前时间 + self.last_update_time = int(time.time()) + + # 输出更新信息 + update_time = time.strftime( + '%Y-%m-%d %H:%M:%S', + time.localtime(self.last_update_time) + ) + + + print(f"[温度传感器] 更新数据: 温度={self.temperature}°C, " + f"湿度={self.humidity}%, 时间={update_time}") + # 设置设备状态并发送消息 + self.handle_temperature_update() + except json.JSONDecodeError: + print(f"[温度传感器] 无法解析JSON消息: {payload}") + + except Exception as e: + print(f"[温度传感器] 处理MQTT消息时出错: {e}") + + def handle_temperature_update(self): + """处理温度更新后的操作""" + try: + if self.app is None: + self.app = Application.get_instance() + + # 设置设备状态为IDLE并更新物联网状态 + self.app.set_device_state(DeviceState.IDLE) + + # 使用线程处理异步操作,避免阻塞MQTT线程 + threading.Thread( + target=self._delayed_send_wake_word, + daemon=True + ).start() + + except Exception as e: + print(f"[温度传感器] 处理温度更新时出错: {e}") + + def _delayed_send_wake_word(self): + """延迟发送唤醒词消息,确保连接稳定""" + try: + # 检查音频通道是否已打开 + channel_opened = False + if not self.app.protocol.is_audio_channel_opened(): + # 先打开音频通道 + future = asyncio.run_coroutine_threadsafe( + self.app.protocol.open_audio_channel(), + self.app.loop + ) + # 等待操作完成并获取结果 + try: + channel_opened = future.result(timeout=5.0) + except Exception as e: + print(f"[温度传感器] 打开音频通道失败: {e}") + return + + if channel_opened: + # 等待3秒确保连接稳定 + print("[温度传感器] 音频通道已打开,等待3秒后发送唤醒词...") + time.sleep(3) + else: + print("[温度传感器] 打开音频通道失败") + return + # 更新物联网设备状态 + self.app._update_iot_states(delta=True) + + # 音频通道已打开,发送唤醒词消息 + asyncio.run_coroutine_threadsafe( + self.app.protocol.send_wake_word_detected("播报温湿度传感器数据(无需调用任何方法)"), + self.app.loop + ) + print("[温度传感器] 已发送唤醒词消息") + + except Exception as e: + print(f"[温度传感器] 延迟发送唤醒词时出错: {e}") + + def _request_sensor_data(self): + """请求所有传感器报告当前状态""" + if self.mqtt_client: + # 兼容两种命令格式 + command = { + "command": "get_data", + "action": "get_data", # 增加action字段支持 + "timestamp": int(time.time()) + } + self.mqtt_client.publish(json.dumps(command)) + print("[温度传感器] 已发送数据请求命令") + + def send_command(self, action_name, **kwargs): + """发送命令到传感器""" + if self.mqtt_client: + command = { + "command": action_name, + "action": action_name, + "timestamp": int(time.time()) + } + # 添加任何额外参数 + command.update(kwargs) + + self.mqtt_client.publish(json.dumps(command)) + print(f"[温度传感器] 已发送命令: {action_name}") + return True + return False + + def get_temperature(self): + return {"success": True, "message": f"[温度传感器] 更新数据: 温度={self.temperature}°C, " + f"湿度={self.humidity}%, 时间={self.last_update_time}"} + + def __del__(self): + """析构函数,确保资源被正确释放""" + if self.mqtt_client: + try: + self.mqtt_client.stop() + except Exception: + pass + + +# 测试代码 +# if __name__ == "__main__": +# # 创建温度传感器接收端实例 +# sensor = TemperatureSensor() +# +# # 启动传感器接收 +# sensor.invoke({"method": "Start"}) +# +# try: +# # 运行10分钟 +# print("温度传感器接收端已启动,等待接收数据...") +# print("按Ctrl+C可停止程序") +# print("也可以输入'send'发送数据请求命令") +# +# while True: +# cmd = input("> ") +# if cmd.lower() == 'send': +# sensor.send_command("get_data") +# elif cmd.lower() == 'quit' or cmd.lower() == 'exit': +# break +# elif cmd.lower() == 'help': +# print("命令列表:") +# print(" send - 发送数据请求命令") +# print(" quit - 退出程序") +# print(" help - 显示帮助") +# time.sleep(0.1) +# +# except KeyboardInterrupt: +# print("\n程序被用户中断") +# finally: +# # 停止传感器接收 +# sensor.invoke({"method": "Stop"}) \ No newline at end of file diff --git a/src/network/mqtt_client.py b/src/network/mqtt_client.py new file mode 100644 index 0000000000000000000000000000000000000000..8b54c5f56b261a8814169d3b038e81deb4d84b35 --- /dev/null +++ b/src/network/mqtt_client.py @@ -0,0 +1,166 @@ +import paho.mqtt.client as mqtt + + +class MqttClient: + def __init__( + self, server, port, username, password, subscribe_topic, publish_topic=None, + client_id="PythonClient", on_connect=None, on_message=None, + on_publish=None, on_disconnect=None + ): + """ + 初始化 MqttClient 实例。 + + :param server: MQTT 服务器地址 + :param port: MQTT 服务器端口 + :param username: 登录用户名 + :param password: 登录密码 + :param subscribe_topic: 订阅的主题 + :param publish_topic: 发布的主题 + :param client_id: 客户端 ID,默认为 "PythonClient" + :param on_connect: 自定义的连接回调函数 + :param on_message: 自定义的消息接收回调函数 + :param on_publish: 自定义的消息发布回调函数 + :param on_disconnect: 自定义的断开连接回调函数 + """ + self.server = server + self.port = port + self.username = username + self.password = password + self.subscribe_topic = subscribe_topic + self.publish_topic = publish_topic + self.client_id = client_id + + # 创建 MQTT 客户端实例,使用最新的API版本 + self.client = mqtt.Client( + client_id=self.client_id, protocol=mqtt.MQTTv5 + ) + + # 设置用户名和密码 + self.client.username_pw_set(self.username, self.password) + + # 设置回调函数,如果提供了自定义回调函数,则使用自定义的,否则使用默认的 + if on_connect: + self.client.on_connect = on_connect + else: + self.client.on_connect = self._on_connect + + self.client.on_message = on_message if on_message else self._on_message + self.client.on_publish = on_publish if on_publish else self._on_publish + + if on_disconnect: + self.client.on_disconnect = on_disconnect + else: + self.client.on_disconnect = self._on_disconnect + + def _on_connect(self, client, userdata, flags, rc, properties=None): + """默认的连接回调函数。""" + if rc == 0: + print("✅ 成功连接到 MQTT 服务器") + # 连接成功后,自动订阅主题 + client.subscribe(self.subscribe_topic) + print(f"📥 已订阅主题:{self.subscribe_topic}") + else: + print(f"❌ 连接失败,错误码:{rc}") + + def _on_message(self, client, userdata, msg): + """默认的消息接收回调函数。""" + topic = msg.topic + content = msg.payload.decode() + print(f"📩 收到消息 - 主题: {topic},内容: {content}") + + def _on_publish(self, client, userdata, mid, properties=None): + """默认的消息发布回调函数。""" + print(f"📤 消息已发布,消息 ID:{mid}") + + def _on_disconnect(self, client, userdata, rc, properties=None): + """默认的断开连接回调函数。""" + print("🔌 与 MQTT 服务器的连接已断开") + + def connect(self): + """连接到 MQTT 服务器。""" + try: + self.client.connect(self.server, self.port, 60) + print(f"🔗 正在连接到服务器 {self.server}:{self.port}") + except Exception as e: + print(f"❌ 连接失败,错误: {e}") + + def start(self): + """启动客户端并开始网络循环。""" + self.client.loop_start() + + def publish(self, message): + """发布消息到指定主题。""" + result = self.client.publish(self.publish_topic, message) + status = result.rc + if status == 0: + print(f"✅ 成功发布到主题 `{self.publish_topic}`") + else: + print(f"❌ 发布失败,错误码:{status}") + + def stop(self): + """停止网络循环并断开连接。""" + self.client.loop_stop() + self.client.disconnect() + print("🛑 客户端已停止连接") + + +if __name__ == "__main__": + pass + # 自定义的回调函数 + # def custom_on_connect(client, userdata, flags, rc, properties=None): + # if rc == 0: + # print("🎉 自定义回调:成功连接到 MQTT 服务器") + # topic_data = userdata['subscribe_topic'] + # client.subscribe(topic_data) + # print(f"📥 自定义回调:已订阅主题:{topic_data}") + # else: + # print(f"❌ 自定义回调:连接失败,错误码:{rc}") + # + # def custom_on_message(client, userdata, msg): + # topic = msg.topic + # content = msg.payload.decode() + # print(f"📩 自定义回调:收到消息 - 主题: {topic},内容: {content}") + # + # def custom_on_publish(client, userdata, mid, properties=None): + # print(f"📤 自定义回调:消息已发布,消息 ID:{mid}") + # + # def custom_on_disconnect(client, userdata, rc, properties=None): + # print("🔌 自定义回调:与 MQTT 服务器的连接已断开") + # + # # 创建 MqttClient 实例,传入自定义的回调函数 + # mqtt_client = MqttClient( + # server="8.130.181.98", + # port=1883, + # username="admin", + # password="dtwin@123", + # subscribe_topic="sensors/temperature/request", + # publish_topic="sensors/temperature/device_001/state", + # client_id="CustomClient", + # on_connect=custom_on_connect, + # on_message=custom_on_message, + # on_publish=custom_on_publish, + # on_disconnect=custom_on_disconnect + # ) + # + # # 将订阅主题信息作为用户数据传递给回调函数 + # mqtt_client.client.user_data_set( + # {'subscribe_topic': mqtt_client.subscribe_topic} + # ) + # + # # 连接到 MQTT 服务器 + # mqtt_client.connect() + # + # # 启动客户端 + # mqtt_client.start() + # + # try: + # while True: + # # 发布消息 + # message = input("输入要发布的消息:") + # mqtt_client.publish(message) + # except KeyboardInterrupt: + # print("\n⛔️ 程序已停止") + # finally: + # # 停止并断开连接 + # mqtt_client.stop() + diff --git a/src/protocols/mqtt_protocol.py b/src/protocols/mqtt_protocol.py new file mode 100644 index 0000000000000000000000000000000000000000..1356f7831046af3ce4af8377991d6d7c648a0960 --- /dev/null +++ b/src/protocols/mqtt_protocol.py @@ -0,0 +1,543 @@ +import asyncio +import json +import logging +import time +import uuid +import socket +import threading +from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes +from cryptography.hazmat.backends import default_backend +import paho.mqtt.client as mqtt +from src.utils.config_manager import ConfigManager +from src.protocols.protocol import Protocol +from src.constants.constants import AudioConfig +from src.utils.logging_config import get_logger + +# 配置日志 +logger = get_logger(__name__) + + +class MqttProtocol(Protocol): + def __init__(self, loop): + super().__init__() + self.loop = loop + self.config = ConfigManager.get_instance() # 在这里实例化 + self.mqtt_client = None + self.udp_socket = None + self.udp_thread = None + self.udp_running = False + + # MQTT配置 + self.endpoint = None + self.client_id = None + self.username = None + self.password = None + self.publish_topic = None + self.subscribe_topic = None + + # UDP配置 + self.udp_server = "" + self.udp_port = 0 + self.aes_key = None + self.aes_nonce = None + self.local_sequence = 0 + self.remote_sequence = 0 + + # 事件 + self.server_hello_event = asyncio.Event() + + async def connect(self): + """连接到MQTT服务器""" + # 重置hello事件 + self.server_hello_event = asyncio.Event() + + # 首先尝试获取MQTT配置 + try: + # 尝试从OTA服务器获取MQTT配置 + mqtt_config = self.config.get_config("SYSTEM_OPTIONS.NETWORK.MQTT_INFO") + + print(mqtt_config) + + # 更新MQTT配置 + self.endpoint = mqtt_config.get("endpoint") + self.client_id = mqtt_config.get("client_id") + self.username = mqtt_config.get("username") + self.password = mqtt_config.get("password") + self.publish_topic = mqtt_config.get("publish_topic") + self.subscribe_topic = mqtt_config.get("subscribe_topic") + + logger.info(f"已从OTA服务器获取MQTT配置: {self.endpoint}") + except Exception as e: + logger.warning(f"从OTA服务器获取MQTT配置失败: {e}") + + # 验证MQTT配置 + if not self.endpoint or not self.username or not self.password or not self.publish_topic or not self.subscribe_topic: + logger.error("MQTT配置不完整") + if self.on_network_error: + await self.on_network_error("MQTT配置不完整") + return False + + # 如果已有MQTT客户端,先断开连接 + if self.mqtt_client: + try: + self.mqtt_client.loop_stop() + self.mqtt_client.disconnect() + except: + pass + + # 创建新的MQTT客户端 + self.mqtt_client = mqtt.Client( + client_id=self.client_id + ) + self.mqtt_client.username_pw_set(self.username, self.password) + + # 配置TLS加密连接 + try: + self.mqtt_client.tls_set( + ca_certs=None, + certfile=None, + keyfile=None, + cert_reqs=mqtt.ssl.CERT_REQUIRED, + tls_version=mqtt.ssl.PROTOCOL_TLS + ) + except Exception as e: + logger.warning(f"TLS配置失败: {e},尝试不使用TLS连接") + + # 创建连接Future + connect_future = self.loop.create_future() + + def on_connect_callback(client, userdata, flags, rc, properties=None): + if rc == 0: + logger.info("已连接到MQTT服务器") + self.loop.call_soon_threadsafe(lambda: connect_future.set_result(True)) + else: + logger.error(f"连接MQTT服务器失败,返回码: {rc}") + self.loop.call_soon_threadsafe(lambda: connect_future.set_exception( + Exception(f"连接MQTT服务器失败,返回码: {rc}"))) + + def on_message_callback(client, userdata, msg): + try: + payload = msg.payload.decode('utf-8') + + self._handle_mqtt_message(payload) + except Exception as e: + logger.error(f"处理MQTT消息时出错: {e}") + + def on_disconnect_callback(client, userdata, rc): + """MQTT断开连接回调 + + Args: + client: MQTT客户端实例 + userdata: 用户数据 + rc: 返回码 + """ + try: + logger.info(f"MQTT连接已断开,返回码: {rc}") + self.connected = False + + # 停止UDP接收线程 + self._stop_udp_receiver() + + # 通知音频通道关闭 + if self.on_audio_channel_closed: + asyncio.run_coroutine_threadsafe( + self.on_audio_channel_closed(), + self.loop + ) + except Exception as e: + logger.error(f"断开MQTT连接失败: {e}") + + # 设置回调 + self.mqtt_client.on_connect = on_connect_callback + self.mqtt_client.on_message = on_message_callback + self.mqtt_client.on_disconnect = on_disconnect_callback + + try: + # 连接MQTT服务器 + logger.info(f"正在连接MQTT服务器: {self.endpoint}") + self.mqtt_client.connect_async(self.endpoint, 8883, 90) + self.mqtt_client.loop_start() + + # 等待连接完成 + await asyncio.wait_for(connect_future, timeout=10.0) + + # 发送hello消息 + hello_message = { + "type": "hello", + "version": 3, + "transport": "udp", + "audio_params": { + "format": "opus", + "sample_rate": AudioConfig.OUTPUT_SAMPLE_RATE, + "channels": AudioConfig.CHANNELS, + "frame_duration": AudioConfig.FRAME_DURATION, + } + } + + # 发送消息并等待响应 + if not await self.send_text(json.dumps(hello_message)): + logger.error("发送hello消息失败") + return False + + try: + await asyncio.wait_for(self.server_hello_event.wait(), timeout=10.0) + except asyncio.TimeoutError: + logger.error("等待服务器hello消息超时") + if self.on_network_error: + await self.on_network_error("等待响应超时") + return False + + # 创建UDP套接字 + try: + if self.udp_socket: + self.udp_socket.close() + + self.udp_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + self.udp_socket.settimeout(0.5) + + # 启动UDP接收线程 + if self.udp_thread and self.udp_thread.is_alive(): + self.udp_running = False + self.udp_thread.join(1.0) + + self.udp_running = True + self.udp_thread = threading.Thread(target=self._udp_receive_thread) + self.udp_thread.daemon = True + self.udp_thread.start() + + return True + except Exception as e: + logger.error(f"创建UDP套接字失败: {e}") + if self.on_network_error: + await self.on_network_error(f"创建UDP连接失败: {e}") + return False + + except Exception as e: + logger.error(f"连接MQTT服务器失败: {e}") + if self.on_network_error: + await self.on_network_error(f"连接MQTT服务器失败: {e}") + return False + + def _handle_mqtt_message(self, payload): + """处理MQTT消息""" + try: + data = json.loads(payload) + msg_type = data.get("type") + + if msg_type == "goodbye": + # 处理goodbye消息 + session_id = data.get("session_id") + if not session_id or session_id == self.session_id: + # 在主事件循环中执行清理 + asyncio.run_coroutine_threadsafe(self._handle_goodbye(), self.loop) + return + + elif msg_type == "hello": + print("服务链接返回初始化配置", data) + # 处理服务器hello响应 + transport = data.get("transport") + if transport != "udp": + logger.error(f"不支持的传输方式: {transport}") + return + + # 获取会话ID + self.session_id = data.get("session_id", "") + + # 获取UDP配置 + udp = data.get("udp") + if not udp: + logger.error("UDP配置缺失") + return + + self.udp_server = udp.get("server") + self.udp_port = udp.get("port") + self.aes_key = udp.get("key") + self.aes_nonce = udp.get("nonce") + + # 重置序列号 + self.local_sequence = 0 + self.remote_sequence = 0 + + logger.info(f"收到服务器hello响应,UDP服务器: {self.udp_server}:{self.udp_port}") + + # 设置hello事件 + self.loop.call_soon_threadsafe(self.server_hello_event.set) + + # 触发音频通道打开回调 + if self.on_audio_channel_opened: + self.loop.call_soon_threadsafe( + lambda: asyncio.create_task(self.on_audio_channel_opened())) + + else: + # 处理其他JSON消息 + if self.on_incoming_json: + def process_json(json_data=data): + if asyncio.iscoroutinefunction(self.on_incoming_json): + coro = self.on_incoming_json(json_data) + if coro is not None: + asyncio.create_task(coro) + else: + self.on_incoming_json(json_data) + + self.loop.call_soon_threadsafe(process_json) + except json.JSONDecodeError: + logger.error(f"无效的JSON数据: {payload}") + except Exception as e: + logger.error(f"处理MQTT消息时出错: {e}") + + def _udp_receive_thread(self): + """UDP接收线程 + + 参考 audio_player.py 的实现方式 + """ + logger.info(f"UDP接收线程已启动,监听来自 {self.udp_server}:{self.udp_port} 的数据") + + self.udp_running = True + debug_counter = 0 + + while self.udp_running: + try: + data, addr = self.udp_socket.recvfrom(4096) + debug_counter += 1 + + try: + # 验证数据包 + if len(data) < 16: # 至少需要16字节的nonce + logger.error(f"无效的音频数据包大小: {len(data)}") + continue + + # 分离nonce和加密数据 + received_nonce = data[:16] + encrypted_audio = data[16:] + + # 使用AES-CTR解密 + decrypted = self.aes_ctr_decrypt( + bytes.fromhex(self.aes_key), + received_nonce, + encrypted_audio + ) + + # 调试信息 + if debug_counter % 100 == 0: + logger.debug(f"已解密音频数据包 #{debug_counter}, 大小: {len(decrypted)} 字节") + + # 处理解密后的音频数据 + if self.on_incoming_audio: + def process_audio(audio_data=decrypted): + + if asyncio.iscoroutinefunction(self.on_incoming_audio): + coro = self.on_incoming_audio(audio_data) + if coro is not None: + asyncio.create_task(coro) + else: + self.on_incoming_audio(audio_data) + + self.loop.call_soon_threadsafe(process_audio) + + except Exception as e: + logger.error(f"处理音频数据包错误: {e}") + continue + + except socket.timeout: + # 超时是正常的,继续循环 + pass + except Exception as e: + logger.error(f"UDP接收线程错误: {e}") + if not self.udp_running: + break + time.sleep(0.1) # 避免在错误情况下过度消耗CPU + + logger.info("UDP接收线程已停止") + + async def send_text(self, message): + """发送文本消息""" + if not self.mqtt_client: + logger.error("MQTT客户端未初始化") + return False + + try: + result = self.mqtt_client.publish(self.publish_topic, message) + result.wait_for_publish() + return True + except Exception as e: + logger.error(f"发送MQTT消息失败: {e}") + if self.on_network_error: + await self.on_network_error(f"发送MQTT消息失败: {e}") + return False + + async def send_audio(self, audio_data): + """发送音频数据 + + 参考 audio_sender.py 的实现方式 + """ + if not self.udp_socket or not self.udp_server or not self.udp_port: + logger.error("UDP通道未初始化") + return False + + try: + # 生成新的nonce (类似于 audio_sender.py 中的实现) + # 格式: 0x01 (1字节) + 0x00 (3字节) + 长度 (2字节) + 原始nonce (8字节) + 序列号 (8字节) + self.local_sequence = (self.local_sequence + 1) & 0xFFFFFFFF + new_nonce = ( + self.aes_nonce[:4] + # 固定前缀 + format(len(audio_data), '04x') + # 数据长度 + self.aes_nonce[8:24] + # 原始nonce + format(self.local_sequence, '08x') # 序列号 + ) + + encrypt_encoded_data = self.aes_ctr_encrypt( + bytes.fromhex(self.aes_key), + bytes.fromhex(new_nonce), + bytes(audio_data) + ) + + # 拼接nonce和密文 + packet = bytes.fromhex(new_nonce) + encrypt_encoded_data + + # 发送数据包 + self.udp_socket.sendto(packet, (self.udp_server, self.udp_port)) + + # 每发送10个包打印一次日志 + if self.local_sequence % 10 == 0: + logger.info(f"已发送音频数据包,序列号: {self.local_sequence},目标: {self.udp_server}:{self.udp_port}") + + self.local_sequence += 1 + return True + except Exception as e: + logger.error(f"发送音频数据失败: {e}") + if self.on_network_error: + asyncio.create_task(self.on_network_error(f"发送音频数据失败: {e}")) + return False + + async def open_audio_channel(self): + """打开音频通道""" + if not self.mqtt_client: + return await self.connect() + return True + + async def close_audio_channel(self): + """关闭音频通道""" + try: + # 如果有会话ID,发送goodbye消息 + if self.session_id: + goodbye_msg = { + "type": "goodbye", + "session_id": self.session_id + } + await self.send_text(json.dumps(goodbye_msg)) + + # 处理goodbye + await self._handle_goodbye() + + except Exception as e: + logger.error(f"关闭音频通道时出错: {e}") + # 确保即使出错也调用回调 + if self.on_audio_channel_closed: + await self.on_audio_channel_closed() + + def is_audio_channel_opened(self): + """检查音频通道是否已打开""" + return self.udp_socket is not None + + def aes_ctr_encrypt(self, key, nonce, plaintext): + """AES-CTR模式加密函数 + Args: + key: bytes格式的加密密钥 + nonce: bytes格式的初始向量 + plaintext: 待加密的原始数据 + Returns: + bytes格式的加密数据 + """ + cipher = Cipher(algorithms.AES(key), modes.CTR(nonce), backend=default_backend()) + encryptor = cipher.encryptor() + return encryptor.update(plaintext) + encryptor.finalize() + + def aes_ctr_decrypt(self, key, nonce, ciphertext): + """AES-CTR模式解密函数 + Args: + key: bytes格式的解密密钥 + nonce: bytes格式的初始向量(需要与加密时使用的相同) + ciphertext: bytes格式的加密数据 + Returns: + bytes格式的解密后的原始数据 + """ + cipher = Cipher(algorithms.AES(key), modes.CTR(nonce), backend=default_backend()) + decryptor = cipher.decryptor() + plaintext = decryptor.update(ciphertext) + decryptor.finalize() + return plaintext + + async def _handle_goodbye(self): + """处理goodbye消息""" + try: + # 停止UDP接收线程 + if self.udp_thread and self.udp_thread.is_alive(): + self.udp_running = False + self.udp_thread.join(1.0) + self.udp_thread = None + logger.info("UDP接收线程已停止") + + # 关闭UDP套接字 + if self.udp_socket: + try: + self.udp_socket.close() + except Exception as e: + logger.error(f"关闭UDP套接字失败: {e}") + self.udp_socket = None + + # 停止MQTT客户端 + if self.mqtt_client: + try: + self.mqtt_client.loop_stop() + self.mqtt_client.disconnect() + self.mqtt_client.loop_forever() # 确保断开连接完全完成 + except Exception as e: + logger.error(f"断开MQTT连接失败: {e}") + self.mqtt_client = None + + # 重置所有状态 + self.connected = False + self.session_id = None + self.local_sequence = 0 + self.remote_sequence = 0 + self.udp_server = "" + self.udp_port = 0 + self.aes_key = None + self.aes_nonce = None + + # 调用音频通道关闭回调 + if self.on_audio_channel_closed: + await self.on_audio_channel_closed() + + except Exception as e: + logger.error(f"处理goodbye消息时出错: {e}") + + def _stop_udp_receiver(self): + """停止UDP接收线程和关闭UDP套接字""" + # 关闭UDP接收线程 + if hasattr(self, 'udp_thread') and self.udp_thread and self.udp_thread.is_alive(): + self.udp_running = False + try: + self.udp_thread.join(1.0) + except RuntimeError: + pass # 处理线程已经终止的情况 + + # 关闭UDP套接字 + if hasattr(self, 'udp_socket') and self.udp_socket: + try: + self.udp_socket.close() + except: + pass + + def __del__(self): + """析构函数,清理资源""" + # 停止UDP接收相关资源 + self._stop_udp_receiver() + + # 关闭MQTT客户端 + if hasattr(self, 'mqtt_client') and self.mqtt_client: + try: + self.mqtt_client.loop_stop() + self.mqtt_client.disconnect() + self.mqtt_client.loop_forever() # 确保断开连接完全完成 + except Exception as e: + pass diff --git a/src/protocols/protocol.py b/src/protocols/protocol.py new file mode 100644 index 0000000000000000000000000000000000000000..054df8e63024f945ce1af5e50e24caf53489f01c --- /dev/null +++ b/src/protocols/protocol.py @@ -0,0 +1,100 @@ +import json + +from src.constants.constants import AbortReason, ListeningMode + + +class Protocol: + def __init__(self): + self.session_id = "" + # 初始化回调函数为None + self.on_incoming_json = None + self.on_incoming_audio = None + self.on_audio_channel_opened = None + self.on_audio_channel_closed = None + self.on_network_error = None + + def on_incoming_json(self, callback): + """设置JSON消息接收回调函数""" + self.on_incoming_json = callback + + def on_incoming_audio(self, callback): + """设置音频数据接收回调函数""" + self.on_incoming_audio = callback + + def on_audio_channel_opened(self, callback): + """设置音频通道打开回调函数""" + self.on_audio_channel_opened = callback + + def on_audio_channel_closed(self, callback): + """设置音频通道关闭回调函数""" + self.on_audio_channel_closed = callback + + def on_network_error(self, callback): + """设置网络错误回调函数""" + self.on_network_error = callback + + async def send_text(self, message): + """发送文本消息的抽象方法,需要在子类中实现""" + raise NotImplementedError("send_text方法必须由子类实现") + + async def send_abort_speaking(self, reason): + """发送中止语音的消息""" + message = { + "session_id": self.session_id, + "type": "abort" + } + if reason == AbortReason.WAKE_WORD_DETECTED: + message["reason"] = "wake_word_detected" + await self.send_text(json.dumps(message)) + + async def send_wake_word_detected(self, wake_word): + """发送检测到唤醒词的消息""" + message = { + "session_id": self.session_id, + "type": "listen", + "state": "detect", + "text": wake_word + } + await self.send_text(json.dumps(message)) + + async def send_start_listening(self, mode): + """发送开始监听的消息""" + mode_map = { + ListeningMode.ALWAYS_ON: "realtime", + ListeningMode.AUTO_STOP: "auto", + ListeningMode.MANUAL: "manual" + } + message = { + "session_id": self.session_id, + "type": "listen", + "state": "start", + "mode": mode_map[mode] + } + await self.send_text(json.dumps(message)) + + async def send_stop_listening(self): + """发送停止监听的消息""" + message = { + "session_id": self.session_id, + "type": "listen", + "state": "stop" + } + await self.send_text(json.dumps(message)) + + async def send_iot_descriptors(self, descriptors): + """发送物联网设备描述信息""" + message = { + "session_id": self.session_id, + "type": "iot", + "descriptors": json.loads(descriptors) if isinstance(descriptors, str) else descriptors + } + await self.send_text(json.dumps(message)) + + async def send_iot_states(self, states): + """发送物联网设备状态信息""" + message = { + "session_id": self.session_id, + "type": "iot", + "states": json.loads(states) if isinstance(states, str) else states + } + await self.send_text(json.dumps(message)) \ No newline at end of file diff --git a/src/protocols/websocket_protocol.py b/src/protocols/websocket_protocol.py new file mode 100644 index 0000000000000000000000000000000000000000..1f1227c096014fb43b1f4dd8deddf8810d421afa --- /dev/null +++ b/src/protocols/websocket_protocol.py @@ -0,0 +1,190 @@ +import asyncio +import json +import logging +import websockets + +from src.constants.constants import AudioConfig +from src.protocols.protocol import Protocol +from src.utils.config_manager import ConfigManager +from src.utils.logging_config import get_logger + +logger = get_logger(__name__) + + +class WebsocketProtocol(Protocol): + def __init__(self): + super().__init__() + # 获取配置管理器实例 + self.config = ConfigManager.get_instance() + self.websocket = None + self.connected = False + self.hello_received = None # 初始化时先设为 None + self.WEBSOCKET_URL = self.config.get_config("SYSTEM_OPTIONS.NETWORK.WEBSOCKET_URL") + self.HEADERS = { + "Authorization": f"Bearer {self.config.get_config('SYSTEM_OPTIONS.NETWORK.WEBSOCKET_ACCESS_TOKEN')}", + "Protocol-Version": "1", + "Device-Id": self.config.get_config("SYSTEM_OPTIONS.DEVICE_ID"), # 获取设备MAC地址 + "Client-Id": self.config.get_config("SYSTEM_OPTIONS.CLIENT_ID") + } + + async def connect(self) -> bool: + """连接到WebSocket服务器""" + try: + # 在连接时创建 Event,确保在正确的事件循环中 + self.hello_received = asyncio.Event() + + # 建立WebSocket连接 (兼容不同Python版本的写法) + try: + # 新的写法 (在Python 3.11+版本中) + self.websocket = await websockets.connect( + uri=self.WEBSOCKET_URL, + additional_headers=self.HEADERS + ) + except TypeError: + # 旧的写法 (在较早的Python版本中) + self.websocket = await websockets.connect( + self.WEBSOCKET_URL, + extra_headers=self.HEADERS + ) + + # 启动消息处理循环 + asyncio.create_task(self._message_handler()) + + # 发送客户端hello消息 + hello_message = { + "type": "hello", + "version": 1, + "transport": "websocket", + "audio_params": { + "format": "opus", + "sample_rate": AudioConfig.INPUT_SAMPLE_RATE, + "channels": AudioConfig.CHANNELS, + "frame_duration": AudioConfig.FRAME_DURATION, + } + } + await self.send_text(json.dumps(hello_message)) + + # 等待服务器hello响应 + try: + await asyncio.wait_for( + self.hello_received.wait(), + timeout=10.0 + ) + self.connected = True + logger.info("已连接到WebSocket服务器") + return True + except asyncio.TimeoutError: + logger.error("等待服务器hello响应超时") + if self.on_network_error: + self.on_network_error("等待响应超时") + return False + + except Exception as e: + logger.error(f"WebSocket连接失败: {e}") + if self.on_network_error: + self.on_network_error(f"无法连接服务: {str(e)}") + return False + + async def _message_handler(self): + """处理接收到的WebSocket消息""" + try: + async for message in self.websocket: + if isinstance(message, str): + try: + data = json.loads(message) + msg_type = data.get("type") + if msg_type == "hello": + # 处理服务器 hello 消息 + await self._handle_server_hello(data) + else: + if self.on_incoming_json: + self.on_incoming_json(data) + except json.JSONDecodeError as e: + logger.error(f"无效的JSON消息: {message}, 错误: {e}") + elif self.on_incoming_audio: # 使用 elif 更清晰 + self.on_incoming_audio(message) + + except websockets.ConnectionClosed: + logger.info("WebSocket连接已关闭") + self.connected = False + if self.on_audio_channel_closed: + # 使用 schedule 确保回调在主线程中执行 + await self.on_audio_channel_closed() + except Exception as e: + logger.error(f"消息处理错误: {e}") + self.connected = False + if self.on_network_error: + # 使用 schedule 确保错误处理在主线程中执行 + self.on_network_error(f"连接错误: {str(e)}") + + async def send_audio(self, data: bytes): + """发送音频数据""" + if not self.is_audio_channel_opened(): # 使用已有的 is_connected 方法 + return + + try: + await self.websocket.send(data) + except Exception as e: + if self.on_network_error: + self.on_network_error(f"发送音频数据失败: {str(e)}") + + async def send_text(self, message: str): + """发送文本消息""" + if self.websocket: + try: + await self.websocket.send(message) + except Exception as e: + await self.close_audio_channel() + if self.on_network_error: + self.on_network_error(f"发送消息失败: {str(e)}") + + def is_audio_channel_opened(self) -> bool: + """检查音频通道是否打开""" + return self.websocket is not None and self.connected + + async def open_audio_channel(self) -> bool: + """建立 WebSocket 连接 + + 如果尚未连接,则创建新的 WebSocket 连接 + Returns: + bool: 连接是否成功 + """ + if not self.connected: + return await self.connect() + return True + + async def _handle_server_hello(self, data: dict): + """处理服务器的 hello 消息""" + try: + # 验证传输方式 + transport = data.get("transport") + if not transport or transport != "websocket": + logger.error(f"不支持的传输方式: {transport}") + return + print("服务链接返回初始化配置", data) + + # 设置 hello 接收事件 + self.hello_received.set() + + # 通知音频通道已打开 + if self.on_audio_channel_opened: + await self.on_audio_channel_opened() + + logger.info("成功处理服务器 hello 消息") + + except Exception as e: + logger.error(f"处理服务器 hello 消息时出错: {e}") + if self.on_network_error: + self.on_network_error(f"处理服务器响应失败: {str(e)}") + + async def close_audio_channel(self): + """关闭音频通道""" + if self.websocket: + try: + await self.websocket.close() + self.websocket = None + self.connected = False + if self.on_audio_channel_closed: + await self.on_audio_channel_closed() + except Exception as e: + logger.error(f"关闭WebSocket连接失败: {e}") \ No newline at end of file diff --git a/src/utils/config_constants.py b/src/utils/config_constants.py new file mode 100644 index 0000000000000000000000000000000000000000..afa74b7509d44810a3015e0340af063b96dd3415 --- /dev/null +++ b/src/utils/config_constants.py @@ -0,0 +1,51 @@ +"""配置常量模块 - 存放所有配置相关的常量""" +from pathlib import Path + +# 配置文件路径 +CONFIG_DIR = Path(__file__).parent.parent.parent / "config" +CONFIG_FILE = CONFIG_DIR / "config.json" + +# 默认配置 +DEFAULT_CONFIG = { + "SYSTEM_OPTIONS": { + "CLIENT_ID": None, + "DEVICE_ID": None, + "NETWORK": { + "OTA_VERSION_URL": "https://api.tenclass.net/xiaozhi/ota/", + "WEBSOCKET_URL": "wss://api.tenclass.net/xiaozhi/v1/", + "WEBSOCKET_ACCESS_TOKEN": "test-token", + "MQTT_INFO": None, + "ACTIVATION_VERSION": "v1" # 可选值: v1, v2 + } + }, + "WAKE_WORD_OPTIONS": { + "USE_WAKE_WORD": False, + "MODEL_PATH": "models/vosk-model-small-cn-0.22", + "WAKE_WORDS": [ + "小智", + "小美" + ] + }, + "TEMPERATURE_SENSOR_MQTT_INFO": { + "endpoint": "你的Mqtt连接地址", + "port": 1883, + "username": "admin", + "password": "123456", + "publish_topic": "sensors/temperature/command", + "subscribe_topic": "sensors/temperature/device_001/state" + }, + "CAMERA": { + "camera_index": 0, + "frame_width": 640, + "frame_height": 480, + "fps": 30, + "Loacl_VL_url": "https://open.bigmodel.cn/api/paas/v4/", + "VLapi_key": "你自己的key", + "models": "glm-4v-plus" + }, + "HOME_ASSISTANT": { + "URL": "http://localhost:8123", + "TOKEN": "", + "DEVICES": [] + } +} \ No newline at end of file diff --git a/src/utils/config_manager.py b/src/utils/config_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..dbad23d4147a5935513689bdd802a68c8d1a7c82 --- /dev/null +++ b/src/utils/config_manager.py @@ -0,0 +1,616 @@ +import json +import hashlib +import time +import requests +from pathlib import Path +from typing import Dict, Any, Optional +import threading +import socket +import uuid +import sys + +from src.utils.logging_config import get_logger +from src.utils.config_constants import CONFIG_DIR, CONFIG_FILE, DEFAULT_CONFIG +from src.utils.device_activator import DeviceActivator + +logger = get_logger(__name__) + + +class ConfigManager: + """配置管理器 - 单例模式""" + + _instance = None + _lock = threading.Lock() + + # 记录配置文件路径 + logger.info(f"配置目录: {CONFIG_DIR.absolute()}") + logger.info(f"配置文件: {CONFIG_FILE.absolute()}") + + def __new__(cls): + """确保单例模式""" + if cls._instance is None: + cls._instance = super().__new__(cls) + return cls._instance + + def __init__(self): + """初始化配置管理器""" + self.logger = logger + if hasattr(self, '_initialized'): + return + self._initialized = True + + # 加载配置 + self._config = self._load_config() + self._initialize_client_id() + self._initialize_device_id() + + # 初始化设备激活器 - 确保在网络配置前初始化 + self.device_activator = DeviceActivator(self) + + # 检查efuse.json是否存在,如果不存在则创建 + self._ensure_efuse_exists() + + # 初始化MQTT信息并处理激活流程 + self._initialize_mqtt_info() + + def _ensure_efuse_exists(self): + """确保efuse.json文件存在并包含必要的配置""" + efuse_file = Path(__file__).parent.parent.parent / "config" / "efuse.json" + + # 记录配置文件路径 + self.logger.info(f"efuse文件路径: {efuse_file.absolute()}") + + if not efuse_file.exists(): + # 使用设备指纹生成序列号 + from src.utils.device_fingerprint import get_device_fingerprint + fingerprint = get_device_fingerprint() + serial_number, source = fingerprint.generate_serial_number() + + # 使用硬件哈希生成HMAC密钥 (使用不同的哈希算法避免重复) + hmac_key = fingerprint.generate_hardware_hash() + + self.logger.info(f"使用{source}生成序列号: {serial_number}") + + # 创建默认efuse数据 + default_data = { + "serial_number": serial_number, + "hmac_key": hmac_key, + "activation_status": False + } + + # 确保目录存在 + efuse_file.parent.mkdir(parents=True, exist_ok=True) + + try: + # 写入默认数据 + with open(efuse_file, 'w', encoding='utf-8') as f: + json.dump(default_data, f, indent=2, ensure_ascii=False) + + self.logger.info(f"已创建efuse配置文件: {efuse_file}") + self.logger.info(f"生成序列号: {serial_number}") + self.logger.info(f"生成HMAC密钥: {hmac_key[:8]}...") + print(f"设备序列号: {serial_number}") + except Exception as e: + self.logger.error(f"创建efuse配置文件失败: {e}") + else: + self.logger.info(f"efuse配置文件已存在: {efuse_file}") + + # 验证文件内容是否完整 + try: + with open(efuse_file, 'r', encoding='utf-8') as f: + data = json.load(f) + + # 检查必要字段是否存在 + required_fields = ["serial_number", "hmac_key", "activation_status"] + missing_fields = [ + field for field in required_fields if field not in data + ] + + if missing_fields: + self.logger.warning(f"efuse配置文件缺少字段: {missing_fields}") + + # 添加缺失的字段 + for field in missing_fields: + if field == "serial_number": + # 使用设备指纹生成序列号 + from src.utils.device_fingerprint import get_device_fingerprint + fingerprint = get_device_fingerprint() + serial_number, source = fingerprint.generate_serial_number() + data[field] = serial_number + self.logger.info( + f"使用{source}生成序列号: {data[field]}" + ) + elif field == "hmac_key": + # 使用设备指纹生成HMAC密钥 + from src.utils.device_fingerprint import get_device_fingerprint + fingerprint = get_device_fingerprint() + data[field] = fingerprint.generate_hardware_hash() + self.logger.info( + f"使用硬件哈希生成HMAC密钥: {data[field][:8]}..." + ) + else: + data[field] = False + + # 重新写入修复后的数据 + with open(efuse_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + + self.logger.info("已修复efuse配置文件") + + # 检查序列号和HMAC密钥是否为None,如果是,生成新值 + if data.get("serial_number") is None: + # 使用设备指纹生成序列号 + from src.utils.device_fingerprint import get_device_fingerprint + fingerprint = get_device_fingerprint() + serial_number, source = fingerprint.generate_serial_number() + data["serial_number"] = serial_number + self.logger.info( + f"使用{source}生成序列号: {data['serial_number']}" + ) + update_needed = True + else: + self.logger.info(f"现有序列号: {data['serial_number']}") + update_needed = False + + if data.get("hmac_key") is None: + # 使用设备指纹生成HMAC密钥 + from src.utils.device_fingerprint import get_device_fingerprint + fingerprint = get_device_fingerprint() + data["hmac_key"] = fingerprint.generate_hardware_hash() + self.logger.info( + f"使用硬件哈希生成HMAC密钥: {data['hmac_key'][:8]}..." + ) + update_needed = True + else: + self.logger.info("现有HMAC密钥已存在") + + # 如果更新了值,重新写入文件 + if update_needed: + with open(efuse_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + self.logger.info("已更新efuse配置文件") + + # 打印设备序列号 + print(f"设备序列号: {data['serial_number']}") + + except Exception as e: + self.logger.error(f"验证efuse配置文件失败: {e}") + + def _load_config(self) -> Dict[str, Any]: + """加载配置文件,如果不存在则创建""" + try: + # 先尝试从当前工作目录加载 + config_file = Path("config/config.json") + if config_file.exists(): + config = json.loads(config_file.read_text(encoding='utf-8')) + return self._merge_configs(DEFAULT_CONFIG, config) + + # 再尝试从打包目录加载 + if getattr(sys, 'frozen', False) and hasattr(sys, '_MEIPASS'): + config_file = Path(sys._MEIPASS) / "config" / "config.json" + if config_file.exists(): + config = json.loads( + config_file.read_text(encoding='utf-8') + ) + return self._merge_configs(DEFAULT_CONFIG, config) + + # 最后尝试从开发环境目录加载 + if CONFIG_FILE.exists(): + config = json.loads( + CONFIG_FILE.read_text(encoding='utf-8') + ) + return self._merge_configs(DEFAULT_CONFIG, config) + else: + # 创建默认配置 + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + self._save_config(DEFAULT_CONFIG) + return DEFAULT_CONFIG.copy() + except Exception as e: + logger.error(f"Error loading config: {e}") + return DEFAULT_CONFIG.copy() + + def _save_config(self, config: dict) -> bool: + """保存配置到文件""" + try: + CONFIG_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_FILE.write_text( + json.dumps(config, indent=2, ensure_ascii=False), + encoding='utf-8' + ) + return True + except Exception as e: + logger.error(f"Error saving config: {e}") + return False + + @staticmethod + def _merge_configs(default: dict, custom: dict) -> dict: + """递归合并配置字典""" + result = default.copy() + for key, value in custom.items(): + if (key in result and isinstance(result[key], dict) + and isinstance(value, dict)): + result[key] = ConfigManager._merge_configs(result[key], value) + else: + result[key] = value + return result + + def get_config(self, path: str, default: Any = None) -> Any: + """ + 通过路径获取配置值 + path: 点分隔的配置路径,如 "network.mqtt.host" + """ + try: + value = self._config + for key in path.split('.'): + value = value[key] + return value + except (KeyError, TypeError): + return default + + def update_config(self, path: str, value: Any) -> bool: + """ + 更新特定配置项 + path: 点分隔的配置路径,如 "network.mqtt.host" + """ + try: + current = self._config + *parts, last = path.split('.') + for part in parts: + current = current.setdefault(part, {}) + current[last] = value + return self._save_config(self._config) + except Exception as e: + logger.error(f"Error updating config {path}: {e}") + return False + + @classmethod + def get_instance(cls): + """获取配置管理器实例(线程安全)""" + with cls._lock: + if cls._instance is None: + cls._instance = cls() + return cls._instance + + def get_mac_address(self): + """获取系统MAC地址作为设备ID""" + mac = uuid.UUID(int=uuid.getnode()).hex[-12:] + return ":".join([mac[i:i + 2] for i in range(0, 12, 2)]) + + def generate_uuid(self) -> str: + """生成 UUID v4""" + return str(uuid.uuid4()) + + def get_local_ip(self): + """获取本地IP地址""" + try: + # 创建一个临时 socket 连接来获取本机 IP + s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) + s.connect(('8.8.8.8', 80)) + ip = s.getsockname()[0] + s.close() + return ip + except Exception: + return '127.0.0.1' + + def _initialize_client_id(self): + """确保存在客户端ID""" + if not self.get_config("SYSTEM_OPTIONS.CLIENT_ID"): + client_id = self.generate_uuid() + success = self.update_config("SYSTEM_OPTIONS.CLIENT_ID", client_id) + if success: + logger.info(f"Generated new CLIENT_ID: {client_id}") + else: + logger.error("Failed to save new CLIENT_ID") + + def _initialize_device_id(self): + """确保存在设备ID""" + if not self.get_config("SYSTEM_OPTIONS.DEVICE_ID"): + try: + # 使用device_fingerprint获取主网卡MAC地址 + from src.utils.device_fingerprint import get_device_fingerprint + fingerprint = get_device_fingerprint() + primary_mac_info = fingerprint.get_primary_mac_address() + + if primary_mac_info: + device_hash, mac_type = primary_mac_info + self.logger.info( + f"使用{mac_type} MAC地址作为设备ID: {device_hash}" + ) + else: + # 备选方案:使用系统MAC地址 + device_hash = self.get_mac_address() + self.logger.info(f"使用系统MAC地址作为设备ID: {device_hash}") + + success = self.update_config( + "SYSTEM_OPTIONS.DEVICE_ID", device_hash) + if success: + logger.info(f"Generated new DEVICE_ID: {device_hash}") + else: + logger.error("Failed to save new DEVICE_ID") + except Exception as e: + logger.error(f"Error generating DEVICE_ID: {e}") + # 出错时仍使用旧方法 + device_hash = self.get_mac_address() + self.update_config("SYSTEM_OPTIONS.DEVICE_ID", device_hash) + logger.info(f"Fallback to system MAC as DEVICE_ID: {device_hash}") + + def _initialize_mqtt_info(self): + """ + 初始化MQTT信息和WebSocket信息 + 每次启动都重新获取最新的服务配置信息 + + Returns: + dict: MQTT配置信息,获取失败则返回已保存的配置 + """ + try: + # 尝试获取新的OTA信息 + ota_response = self._get_ota_response() + + if not ota_response: + self.logger.warning("获取OTA信息失败,使用已保存的配置") + return self.get_config("SYSTEM_OPTIONS.NETWORK.MQTT_INFO") + + # 处理激活信息 + if ("activation" in ota_response and + self.get_config("SYSTEM_OPTIONS.NETWORK.ACTIVATION_VERSION") == "v2"): + self.logger.info("检测到激活请求,开始设备激活流程") + # 如果设备已经激活,但服务器仍然发送激活请求,可能需要重新激活 + if self.device_activator.is_activated(): + self.logger.warning("设备已激活,但服务器仍然请求激活,尝试重新激活") + + # 处理激活流程 + activation_success = self.device_activator.process_activation( + ota_response["activation"]) + + if not activation_success: + self.logger.error("设备激活失败") + # 如果是全新设备且激活失败,可能需要返回现有配置 + return self.get_config("SYSTEM_OPTIONS.NETWORK.MQTT_INFO") + else: + self.logger.info("设备激活成功,重新获取配置") + # 重新获取OTA响应,应该不再包含激活信息 + ota_response = self._get_ota_response() + + # 处理WebSocket配置 + if "websocket" in ota_response: + websocket_info = ota_response["websocket"] + self.logger.info("检测到WebSocket配置信息") + + # 更新WebSocket URL + if "url" in websocket_info: + self.update_config( + "SYSTEM_OPTIONS.NETWORK.WEBSOCKET_URL", + websocket_info["url"] + ) + self.logger.info(f"WebSocket URL已更新: {websocket_info['url']}") + + # 更新WebSocket Token + if "token" in websocket_info: + self.update_config( + "SYSTEM_OPTIONS.NETWORK.WEBSOCKET_ACCESS_TOKEN", + websocket_info["token"] + ) + self.logger.info("WebSocket Token已更新") + + print("\nWebSocket配置信息:") + print(f"URL: {self.get_config('SYSTEM_OPTIONS.NETWORK.WEBSOCKET_URL')}") + print(f"Token: {self.get_config('SYSTEM_OPTIONS.NETWORK.WEBSOCKET_ACCESS_TOKEN')[:10]}...") + + # 提取MQTT信息 + if "mqtt" in ota_response: + mqtt_info = ota_response["mqtt"] + # 更新配置 + self.update_config( + "SYSTEM_OPTIONS.NETWORK.MQTT_INFO", mqtt_info) + self.logger.info("MQTT信息已成功更新") + return mqtt_info + else: + self.logger.warning("OTA响应中没有MQTT信息") + return self.get_config("SYSTEM_OPTIONS.NETWORK.MQTT_INFO") + + except Exception as e: + self.logger.error(f"初始化网络配置信息失败: {e}") + # 发生错误时返回已保存的配置 + return self.get_config("SYSTEM_OPTIONS.NETWORK.MQTT_INFO") + + def _get_ota_response(self): + """获取OTA服务器的完整响应""" + MAC_ADDR = self.get_config("SYSTEM_OPTIONS.DEVICE_ID") + OTA_VERSION_URL = self.get_config( + "SYSTEM_OPTIONS.NETWORK.OTA_VERSION_URL") + + # 获取应用信息 + app_name = "xiaozhi" + app_version = "1.6.0" # 从payload中获取 + board_type = "lc-esp32-s3" # 立创ESP32-S3开发板 + + # 获取激活版本设置 + activation_version_setting = self.get_config( + "SYSTEM_OPTIONS.NETWORK.ACTIVATION_VERSION", "v2") + + # 确定使用哪个版本的激活协议 + if activation_version_setting in ["v1", "1"]: + activation_version = "1" + else: + activation_version = "2" + + self.logger.info( + f"OTA请求使用激活版本: {activation_version} " + f"(配置值: {activation_version_setting})" + ) + + # 设置请求头 + headers = { + "Activation-Version": activation_version, + "Device-Id": MAC_ADDR, + "Client-Id": self.get_config("SYSTEM_OPTIONS.CLIENT_ID"), + "Content-Type": "application/json", + "User-Agent": f"{board_type}/{app_name}-{app_version}", + "Accept-Language": "zh-CN" # 添加语言标识,与C++版本保持一致 + } + + # 构建设备信息payload + payload = { + "version": 2, + "flash_size": 16777216, # 闪存大小 (16MB) + "psram_size": 8388608, # 8MB PSRAM + "minimum_free_heap_size": 7265024, # 最小可用堆内存 + "mac_address": MAC_ADDR, # 设备MAC地址 + "uuid": self.get_config("SYSTEM_OPTIONS.CLIENT_ID"), + "chip_model_name": "esp32s3", # 芯片型号 + "chip_info": { + "model": 9, # ESP32-S3 + "cores": 2, + "revision": 0, # 芯片版本修订 + "features": 20 # WiFi + BLE + PSRAM + }, + "application": { + "name": "xiaozhi", + "version": "1.6.0", + "compile_time": "2025-4-16T12:00:00Z", + "idf_version": "v5.3.2" + }, + "partition_table": [ + { + "label": "nvs", + "type": 1, + "subtype": 2, + "address": 36864, + "size": 24576 + }, + { + "label": "otadata", + "type": 1, + "subtype": 0, + "address": 61440, + "size": 8192 + }, + { + "label": "app0", + "type": 0, + "subtype": 0, + "address": 65536, + "size": 1966080 + }, + { + "label": "app1", + "type": 0, + "subtype": 0, + "address": 2031616, + "size": 1966080 + }, + { + "label": "spiffs", + "type": 1, + "subtype": 130, + "address": 3997696, + "size": 1966080 + } + ], + "ota": { + "label": "app0" + }, + "board": { + "type": "lc-esp32-s3", + "name": "立创ESP32-S3开发板", + "features": ["wifi", "ble", "psram", "octal_flash"], + "ip": self.get_local_ip(), + "mac": MAC_ADDR + } + } + + try: + # 发送请求到OTA服务器 + response = requests.post( + OTA_VERSION_URL, + headers=headers, + json=payload, + timeout=10, # 设置超时时间,防止请求卡死 + proxies={'http': None, 'https': None} # 禁用代理 + ) + + # 检查HTTP状态码 + if response.status_code != 200: + self.logger.error(f"OTA服务器错误: HTTP {response.status_code}") + return None + + # 解析JSON数据 + response_data = response.json() + + # 保存OTA响应到文件 + try: + log_dir = Path("logs") + log_dir.mkdir(exist_ok=True) + + # 保存OTA请求 + with open(log_dir / "ota_request.json", "w", encoding="utf-8") as f: + request_data = { + "url": OTA_VERSION_URL, + "headers": headers, + "payload": payload + } + json.dump(request_data, f, indent=4, ensure_ascii=False) + + # 保存OTA响应 + with open(log_dir / "ota_response.json", "w", encoding="utf-8") as f: + json.dump(response_data, f, indent=4, ensure_ascii=False) + + self.logger.info("OTA请求和响应已保存到logs目录") + except Exception as e: + self.logger.error(f"保存OTA日志失败: {e}") + + # 调试信息:打印完整的OTA响应 + self.logger.debug( + f"OTA服务器返回数据: " + f"{json.dumps(response_data, indent=4, ensure_ascii=False)}" + ) + + return response_data + + except requests.Timeout: + self.logger.error("OTA请求超时,请检查网络或服务器状态") + return None + + except requests.RequestException as e: + self.logger.error(f"OTA请求失败: {e}") + return None + + except Exception as e: + self.logger.error(f"OTA请求处理过程中发生错误: {e}") + return None + + +# 用于测试的函数 +def setup_device_for_activation(): + """设置设备用于测试激活流程""" + # 获取配置管理器实例 + config_manager = ConfigManager.get_instance() + + # 检查序列号和HMAC密钥 + if not config_manager.device_activator.has_serial_number(): + # 生成随机序列号 + serial_number = f"SN-{uuid.uuid4().hex[:16].upper()}" + print(f"生成序列号: {serial_number}") + + # 烧录序列号 + if config_manager.device_activator.burn_serial_number(serial_number): + print("序列号烧录成功") + else: + print("序列号烧录失败") + else: + sn = config_manager.device_activator.get_serial_number() + print(f"设备已有序列号: {sn}") + + # 检查HMAC密钥 + if not config_manager.device_activator.get_hmac_key(): + # 生成随机HMAC密钥 + hmac_key = uuid.uuid4().hex + print(f"生成HMAC密钥: {hmac_key}") + + # 烧录HMAC密钥 + if config_manager.device_activator.burn_hmac_key(hmac_key): + print("HMAC密钥烧录成功") + else: + print("HMAC密钥烧录失败") + else: + print("设备已有HMAC密钥") \ No newline at end of file diff --git a/src/utils/device_activator.py b/src/utils/device_activator.py new file mode 100644 index 0000000000000000000000000000000000000000..deae6fda4360a1016f4f6e9db748bc6d95e45402 --- /dev/null +++ b/src/utils/device_activator.py @@ -0,0 +1,368 @@ +import json +import hashlib +import hmac +import time +import requests +import uuid +from pathlib import Path + +from src.utils.logging_config import get_logger + +logger = get_logger(__name__) + + +class DeviceActivator: + """设备激活管理器 - 与ConfigManager配合使用""" + + def __init__(self, config_manager): + """初始化设备激活器""" + self.logger = get_logger(__name__) + self.config_manager = config_manager + self.efuse_file = Path(__file__).parent.parent.parent / "config" / "efuse.json" + self._ensure_efuse_file() + + def _ensure_efuse_file(self): + """确保efuse文件存在""" + if not self.efuse_file.exists(): + # 创建默认efuse数据 + default_data = { + "serial_number": None, + "hmac_key": None, + "activation_status": False + } + + # 确保目录存在 + self.efuse_file.parent.mkdir(parents=True, exist_ok=True) + + # 写入默认数据 + with open(self.efuse_file, 'w', encoding='utf-8') as f: + json.dump(default_data, f, indent=2, ensure_ascii=False) + + self.logger.info(f"已创建efuse配置文件: {self.efuse_file}") + + def _load_efuse_data(self) -> dict: + """加载efuse数据""" + try: + with open(self.efuse_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + self.logger.error(f"加载efuse数据失败: {e}") + return { + "serial_number": None, + "hmac_key": None, + "activation_status": False + } + + def _save_efuse_data(self, data: dict) -> bool: + """保存efuse数据""" + try: + with open(self.efuse_file, 'w', encoding='utf-8') as f: + json.dump(data, f, indent=2, ensure_ascii=False) + return True + except Exception as e: + self.logger.error(f"保存efuse数据失败: {e}") + return False + + def has_serial_number(self) -> bool: + """检查是否有序列号""" + efuse_data = self._load_efuse_data() + return efuse_data.get("serial_number") is not None + + def get_serial_number(self) -> str: + """获取序列号""" + efuse_data = self._load_efuse_data() + return efuse_data.get("serial_number") + + def burn_serial_number(self, serial_number: str) -> bool: + """烧录序列号到模拟efuse""" + efuse_data = self._load_efuse_data() + + # 检查是否已有序列号 + if efuse_data.get("serial_number") is not None: + self.logger.warning("已存在序列号,无法重新烧录") + return False + + # 更新序列号 + efuse_data["serial_number"] = serial_number + result = self._save_efuse_data(efuse_data) + + if result: + self.logger.info(f"序列号 {serial_number} 已成功烧录") + + return result + + def burn_hmac_key(self, hmac_key: str) -> bool: + """烧录HMAC密钥到模拟efuse""" + efuse_data = self._load_efuse_data() + + # 检查是否已有HMAC密钥 + if efuse_data.get("hmac_key") is not None: + self.logger.warning("已存在HMAC密钥,无法重新烧录") + return False + + # 更新HMAC密钥 + efuse_data["hmac_key"] = hmac_key + result = self._save_efuse_data(efuse_data) + + if result: + self.logger.info("HMAC密钥已成功烧录") + + return result + + def get_hmac_key(self) -> str: + """获取HMAC密钥""" + efuse_data = self._load_efuse_data() + return efuse_data.get("hmac_key") + + def set_activation_status(self, status: bool) -> bool: + """设置激活状态""" + efuse_data = self._load_efuse_data() + efuse_data["activation_status"] = status + return self._save_efuse_data(efuse_data) + + def is_activated(self) -> bool: + """检查设备是否已激活""" + efuse_data = self._load_efuse_data() + return efuse_data.get("activation_status", False) + + def generate_hmac(self, challenge: str) -> str: + """使用HMAC密钥生成签名""" + hmac_key = self.get_hmac_key() + + if not hmac_key: + self.logger.error("未找到HMAC密钥,无法生成签名") + return None + + try: + # 计算HMAC-SHA256签名 + signature = hmac.new( + hmac_key.encode(), + challenge.encode(), + hashlib.sha256 + ).hexdigest() + + return signature + except Exception as e: + self.logger.error(f"生成HMAC签名失败: {e}") + return None + + def process_activation(self, activation_data: dict) -> bool: + """ + 处理激活流程 + + Args: + activation_data: 包含激活信息的字典,至少应该包含challenge和code + + Returns: + bool: 激活是否成功 + """ + # 检查是否有激活挑战和验证码 + if not activation_data.get("challenge"): + self.logger.error("激活数据中缺少challenge字段") + return False + + if not activation_data.get("code"): + self.logger.error("激活数据中缺少code字段") + return False + + challenge = activation_data["challenge"] + code = activation_data["code"] + message = activation_data.get("message", "请在xiaozhi.me输入验证码") + + # 检查序列号 + if not self.has_serial_number(): + self.logger.error("设备没有序列号,无法进行激活") + print("\n错误: 设备没有序列号,无法进行激活。请确保efuse.json文件已正确创建") + print("正在重新创建efuse.json文件并重新尝试...") + + # 尝试创建序列号和HMAC密钥 + serial_number = f"SN-{uuid.uuid4().hex[:16].upper()}" + hmac_key = uuid.uuid4().hex + + success1 = self.burn_serial_number(serial_number) + success2 = self.burn_hmac_key(hmac_key) + + if success1 and success2: + self.logger.info("已自动创建设备序列号和HMAC密钥") + print(f"已自动创建设备序列号: {serial_number}") + else: + self.logger.error("创建序列号或HMAC密钥失败") + return False + + # 显示激活信息给用户 + self.logger.info(f"激活提示: {message}") + self.logger.info(f"验证码: {code}") + print("\n==================") + print(f"请登录到控制面板添加设备,输入验证码:{code}") + print("==================\n") + + # 尝试激活设备 + return self.activate(challenge) + + def activate(self, challenge: str) -> bool: + """ + 执行激活流程 + + Args: + challenge: 服务器发送的挑战字符串 + + Returns: + bool: 激活是否成功 + """ + # 检查序列号 + serial_number = self.get_serial_number() + if not serial_number: + self.logger.error("设备没有序列号,无法完成HMAC验证步骤") + return False + + # 计算HMAC签名 + hmac_signature = self.generate_hmac(challenge) + if not hmac_signature: + self.logger.error("无法生成HMAC签名,激活失败") + return False + + # 包装一层外部payload,符合服务器期望格式 + payload = { + "Payload": { + "algorithm": "hmac-sha256", + "serial_number": serial_number, + "challenge": challenge, + "hmac": hmac_signature + } + } + + # 获取激活URL + ota_url = self.config_manager.get_config( + "SYSTEM_OPTIONS.NETWORK.OTA_VERSION_URL") + if not ota_url: + self.logger.error("未找到OTA URL配置") + return False + + # 确保URL以斜杠结尾 + if not ota_url.endswith('/'): + ota_url += '/' + + activate_url = f"{ota_url}activate" + + # 获取激活版本设置 + activation_version_setting = self.config_manager.get_config( + "SYSTEM_OPTIONS.NETWORK.ACTIVATION_VERSION", "v2") + + # 确定使用哪个版本的激活协议 + if activation_version_setting in ["v1", "1"]: + activation_version = "1" + else: + activation_version = "2" + + self.logger.info(f"OTA请求使用激活版本: {activation_version} " + f"(配置值: {activation_version_setting})") + + # 设置请求头 + headers = { + "Activation-Version": activation_version, + "Device-Id": self.config_manager.get_config("SYSTEM_OPTIONS.DEVICE_ID"), + "Client-Id": self.config_manager.get_config("SYSTEM_OPTIONS.CLIENT_ID"), + "Content-Type": "application/json" + } + + # 重试逻辑 + max_retries = 60 # 增加重试次数,最长等待5分钟 + retry_interval = 5 # 设置5秒的重试间隔 + + error_count = 0 + last_error = None + + for attempt in range(max_retries): + try: + self.logger.info(f"尝试激活 (尝试 {attempt + 1}/{max_retries})...") + + # 发送激活请求 + response = requests.post( + activate_url, + headers=headers, + json=payload, + timeout=10 + ) + + # 打印完整响应 + print(f"\n激活响应 (HTTP {response.status_code}):") + try: + response_json = response.json() + print(json.dumps(response_json, indent=2)) + + # 保存激活请求和响应到文件 + try: + log_dir = Path("logs") + log_dir.mkdir(exist_ok=True) + + # 保存激活请求 + with open(log_dir / "activate_request.json", "w", encoding="utf-8") as f: + request_data = { + "url": activate_url, + "headers": headers, + "payload": payload + } + json.dump(request_data, f, indent=4, ensure_ascii=False) + + # 保存激活响应 + with open(log_dir / "activate_response.json", "w", encoding="utf-8") as f: + json.dump(response_json, f, indent=4, ensure_ascii=False) + + self.logger.info("激活请求和响应已保存到logs目录") + except Exception as e: + self.logger.error(f"保存激活日志失败: {e}") + + except Exception: + print(response.text) + + # 检查响应状态码 + if response.status_code == 200: + # 激活成功 + self.logger.info("设备激活成功!") + print("\n*** 设备激活成功! ***\n") + self.set_activation_status(True) + return True + elif response.status_code == 202: + # 等待用户输入验证码 + self.logger.info("等待用户输入验证码,继续等待...") + print("\n等待用户在网站输入验证码,继续等待...\n") + time.sleep(retry_interval) + else: + # 处理其他错误但继续重试 + error_msg = "未知错误" + try: + error_data = response.json() + error_msg = error_data.get( + 'error', + f"未知错误 (状态码: {response.status_code})" + ) + except Exception: + error_msg = f"服务器返回错误 (状态码: {response.status_code})" + + # 记录错误但不终止流程 + if error_msg != last_error: + # 只在错误消息改变时记录,避免重复日志 + self.logger.warning(f"服务器返回: {error_msg},继续等待验证码激活") + print(f"\n服务器返回: {error_msg},继续等待验证码激活...\n") + last_error = error_msg + + # 计数连续相同错误 + if "Device not found" in error_msg: + error_count += 1 + if error_count >= 5 and error_count % 5 == 0: + # 每5次相同错误,提示用户可能需要重新获取验证码 + print("\n提示: 如果错误持续出现,可能需要在网站上刷新页面获取新验证码\n") + + time.sleep(retry_interval) + + except requests.Timeout: + time.sleep(retry_interval) + except Exception as e: + self.logger.warning(f"激活过程中发生错误: {e},重试中...") + print(f"激活过程中发生错误: {e},重试中...") + time.sleep(retry_interval) + + # 只有在达到最大重试次数后才真正失败 + self.logger.error(f"激活失败,达到最大重试次数 ({max_retries}),最后错误: {last_error}") + print("\n激活失败,达到最大等待时间,请重新获取验证码并尝试激活\n") + return False \ No newline at end of file diff --git a/src/utils/device_fingerprint.py b/src/utils/device_fingerprint.py new file mode 100644 index 0000000000000000000000000000000000000000..baf1e114dbe90bf5da48e21e790cee64d0a04d87 --- /dev/null +++ b/src/utils/device_fingerprint.py @@ -0,0 +1,497 @@ +import os +import sys +import subprocess +import uuid +import hashlib +import platform +import logging +import re +import json +from pathlib import Path +from typing import Dict, Optional, List, Tuple + +# 获取日志记录器 +logger = logging.getLogger(__name__) + + +class DeviceFingerprint: + """设备指纹收集器 - 用于生成唯一的设备标识""" + + def __init__(self): + """初始化设备指纹收集器""" + self.system = platform.system() + self.fingerprint_cache_file = (Path(__file__).parent.parent.parent / + "config" / ".device_fingerprint") + + def get_hostname(self) -> str: + """获取计算机主机名""" + return platform.node() + + def get_mac_address(self) -> str: + """获取MAC地址(小写格式)""" + mac = uuid.UUID(int=uuid.getnode()).hex[-12:] + return ":".join([mac[i:i + 2] for i in range(0, 12, 2)]).lower() + + def get_all_mac_addresses(self) -> List[Dict[str, str]]: + """获取所有网络适配器的MAC地址""" + mac_addresses = [] + + try: + if self.system == "Windows": + # Windows系统通过WMI获取所有网络适配器 + import wmi + w = wmi.WMI() + for nic in w.Win32_NetworkAdapter(): + if nic.MACAddress: + # 确保MAC地址为小写 + mac_addr = nic.MACAddress.lower() if nic.MACAddress else "" + mac_addresses.append({ + "name": nic.Name, + "mac": mac_addr, + "device_id": nic.DeviceID, + "adapter_type": getattr(nic, "AdapterType", ""), + "net_connection_id": getattr(nic, "NetConnectionID", ""), + "physical": nic.PhysicalAdapter if hasattr(nic, "PhysicalAdapter") else False + }) + # 其他系统的实现可以在这里添加 + except Exception as e: + logger.error(f"获取所有MAC地址时出错: {e}") + + return mac_addresses + + def get_primary_mac_address(self) -> Optional[Tuple[str, str]]: + """ + 智能选择最适合的物理网卡MAC地址 + + Returns: + Tuple[str, str]: (MAC地址, 网卡类型描述) + """ + all_macs = self.get_all_mac_addresses() + + # 如果没有找到任何MAC地址,返回None + if not all_macs: + return None + + # 对适配器进行分类 + ethernet_adapters = [] # 有线网卡 + wifi_adapters = [] # WiFi网卡 + bluetooth_adapters = [] # 蓝牙适配器 + physical_adapters = [] # 其他物理网卡 + virtual_adapters = [] # 虚拟网卡 + + for adapter in all_macs: + # 确保name和connection_id不是None + name = str(adapter.get("name", "")).lower() + # 不使用connection_id进行分类,避免None错误 + physical = adapter.get("physical", False) + + # 主要通过名称来判断网卡类型 + if (any(keyword in name for keyword in + ["ethernet", "realtek", "intel", "broadcom"]) and physical): + ethernet_adapters.append(adapter) + elif (any(keyword in name for keyword in + ["wi-fi", "wifi", "wireless", "wlan"]) and physical): + wifi_adapters.append(adapter) + elif "bluetooth" in name: + bluetooth_adapters.append(adapter) + elif physical: + physical_adapters.append(adapter) + else: + virtual_adapters.append(adapter) + + # 按优先级选择MAC地址 + if ethernet_adapters: + return ethernet_adapters[0]["mac"], "有线网卡" + elif wifi_adapters: + return wifi_adapters[0]["mac"], "WiFi网卡" + elif bluetooth_adapters: + return bluetooth_adapters[0]["mac"], "蓝牙适配器" + elif physical_adapters: + return physical_adapters[0]["mac"], "物理网卡" + elif virtual_adapters: + return virtual_adapters[0]["mac"], "虚拟网卡" + + # 如果所有分类都为空,返回第一个MAC地址作为备选 + return all_macs[0]["mac"], "未知类型" + + def get_bluetooth_mac_address(self) -> Optional[str]: + """获取蓝牙适配器的MAC地址""" + try: + all_macs = self.get_all_mac_addresses() + for mac_info in all_macs: + # 检查名称中是否包含"Bluetooth"关键字 + if "Bluetooth" in mac_info.get("name", ""): + return mac_info.get("mac") + return None + except Exception as e: + logger.error(f"获取蓝牙MAC地址时出错: {e}") + return None + + def get_cpu_info(self) -> Dict: + """获取CPU信息""" + cpu_info = { + "processor": platform.processor(), + "machine": platform.machine() + } + + try: + if self.system == "Windows": + # Windows系统通过WMI获取CPU信息 + import wmi + w = wmi.WMI() + processor = w.Win32_Processor()[0] + cpu_info["id"] = processor.ProcessorId.strip() + cpu_info["name"] = processor.Name.strip() + cpu_info["cores"] = processor.NumberOfCores + elif self.system == "Linux": + # Linux系统通过/proc/cpuinfo获取CPU信息 + with open('/proc/cpuinfo', 'r') as f: + info = f.readlines() + + cpu_id = None + model_name = None + cpu_cores = 0 + + for line in info: + if "serial" in line or "Serial" in line: + # 一些Linux系统可能会有CPU序列号 + cpu_id = line.split(':')[1].strip() + elif "model name" in line: + model_name = line.split(':')[1].strip() + elif "cpu cores" in line: + cpu_cores = int(line.split(':')[1].strip()) + + if cpu_id: + cpu_info["id"] = cpu_id + if model_name: + cpu_info["name"] = model_name + if cpu_cores: + cpu_info["cores"] = cpu_cores + elif self.system == "Darwin": # macOS + # macOS通过系统命令获取CPU信息 + cmd = "sysctl -n machdep.cpu.brand_string" + model_name = subprocess.check_output(cmd, shell=True).decode().strip() + + cmd = "sysctl -n hw.physicalcpu" + cpu_cores = int(subprocess.check_output(cmd, shell=True).decode().strip()) + + # macOS没有直接暴露CPU ID,使用其他信息组合 + cmd = "ioreg -l | grep IOPlatformUUID" + try: + platform_uuid = subprocess.check_output(cmd, shell=True).decode().strip() + uuid_match = re.search(r'IOPlatformUUID" = "([^"]+)"', platform_uuid) + if uuid_match: + cpu_info["id"] = uuid_match.group(1) + except Exception: + pass + + cpu_info["name"] = model_name + cpu_info["cores"] = cpu_cores + except Exception as e: + logger.error(f"获取CPU信息时出错: {e}") + + return cpu_info + + def get_disk_info(self) -> List[Dict]: + """获取硬盘信息""" + disks = [] + + try: + if self.system == "Windows": + # Windows系统通过WMI获取硬盘信息 + import wmi + w = wmi.WMI() + for disk in w.Win32_DiskDrive(): + if disk.SerialNumber: + disks.append({ + "model": disk.Model.strip(), + "serial": disk.SerialNumber.strip(), + "size": str(int(disk.Size or 0)) + }) + elif self.system == "Linux": + # Linux系统通过lsblk命令获取硬盘信息 + cmd = "lsblk -d -o NAME,SERIAL,MODEL,SIZE --json" + try: + result = subprocess.check_output(cmd, shell=True).decode() + data = json.loads(result) + for device in data.get("blockdevices", []): + if device.get("serial"): + disks.append({ + "model": device.get("model", "").strip(), + "serial": device.get("serial").strip(), + "size": device.get("size", "").strip() + }) + except Exception: + # 备用方法通过/dev/disk/by-id获取 + try: + disk_ids = os.listdir('/dev/disk/by-id') + for disk_id in disk_ids: + if (not disk_id.startswith('usb-') and + not disk_id.startswith('nvme-eui') and + not disk_id.startswith('wwn-') and + 'part' not in disk_id): + disks.append({ + "model": "Unknown", + "serial": disk_id, + "size": "0" + }) + except Exception: + pass + elif self.system == "Darwin": # macOS + # macOS通过diskutil命令获取硬盘信息 + cmd = "diskutil list -plist" + try: + import plistlib + result = subprocess.check_output(cmd, shell=True) + data = plistlib.loads(result) + + for disk in data.get('AllDisksAndPartitions', []): + disk_id = disk.get('DeviceIdentifier') + if not disk_id: + continue + + # 获取该磁盘的详细信息 + cmd = f"diskutil info -plist {disk_id}" + disk_info = subprocess.check_output(cmd, shell=True) + disk_data = plistlib.loads(disk_info) + + serial = (disk_data.get('IORegistryEntryName') or + disk_data.get('MediaName')) + if serial: + disks.append({ + "model": disk_data.get('MediaName', 'Unknown'), + "serial": serial, + "size": str(disk_data.get('TotalSize', 0)) + }) + except Exception as e: + logger.error(f"在macOS上获取磁盘信息时出错: {e}") + except Exception as e: + logger.error(f"获取硬盘信息时出错: {e}") + + return disks + + def get_motherboard_info(self) -> Dict: + """获取主板信息""" + mb_info = {} + + try: + if self.system == "Windows": + # Windows通过WMI获取主板信息 + import wmi + w = wmi.WMI() + for board in w.Win32_BaseBoard(): + mb_info["manufacturer"] = board.Manufacturer.strip() if board.Manufacturer else "" + mb_info["model"] = board.Product.strip() if board.Product else "" + mb_info["serial"] = board.SerialNumber.strip() if board.SerialNumber else "" + break + + # 如果没有获取到序列号,尝试使用BIOS序列号 + if not mb_info.get("serial"): + for bios in w.Win32_BIOS(): + if bios.SerialNumber: + mb_info["bios_serial"] = bios.SerialNumber.strip() + break + elif self.system == "Linux": + # Linux通过dmidecode命令获取主板信息 + try: + cmd = "dmidecode -t 2" + result = subprocess.check_output( + cmd, shell=True, stderr=subprocess.PIPE).decode() + for line in result.split('\n'): + if "Manufacturer" in line: + mb_info["manufacturer"] = line.split(':')[1].strip() + elif "Product Name" in line: + mb_info["model"] = line.split(':')[1].strip() + elif "Serial Number" in line: + mb_info["serial"] = line.split(':')[1].strip() + except Exception: + # 备用方法从/sys/class/dmi/id获取 + try: + with open('/sys/class/dmi/id/board_vendor', 'r') as f: + mb_info["manufacturer"] = f.read().strip() + with open('/sys/class/dmi/id/board_name', 'r') as f: + mb_info["model"] = f.read().strip() + with open('/sys/class/dmi/id/board_serial', 'r') as f: + mb_info["serial"] = f.read().strip() + except Exception: + pass + elif self.system == "Darwin": # macOS + # macOS使用ioreg命令获取主板或系统信息 + try: + cmd = "ioreg -l | grep -E '(board-id|IOPlatformSerialNumber|IOPlatformUUID)'" + result = subprocess.check_output(cmd, shell=True).decode() + + board_id = re.search(r'board-id" = <"([^"]+)">', result) + if board_id: + mb_info["model"] = board_id.group(1) + + serial = re.search(r'IOPlatformSerialNumber" = "([^"]+)"', result) + if serial: + mb_info["serial"] = serial.group(1) + + uuid_match = re.search(r'IOPlatformUUID" = "([^"]+)"', result) + if uuid_match: + mb_info["uuid"] = uuid_match.group(1) + except Exception as e: + logger.error(f"在macOS上获取主板信息时出错: {e}") + except Exception as e: + logger.error(f"获取主板信息时出错: {e}") + + return mb_info + + def generate_fingerprint(self) -> Dict: + """生成完整的设备指纹""" + # 检查是否有缓存的指纹 + cached_fingerprint = self._load_cached_fingerprint() + if cached_fingerprint: + return cached_fingerprint + + # 获取主要网卡MAC地址 + primary_mac, mac_type = self.get_primary_mac_address() or (self.get_mac_address(), "系统MAC") + + # 收集各种硬件信息 + fingerprint = { + "system": self.system, + "hostname": self.get_hostname(), + "mac_address": self.get_mac_address(), # 保留系统获取的MAC地址用于兼容 + "primary_mac": primary_mac, + "primary_mac_type": mac_type, + "bluetooth_mac": self.get_bluetooth_mac_address(), + "cpu": self.get_cpu_info(), + "disks": self.get_disk_info(), + "motherboard": self.get_motherboard_info() + } + + # 缓存指纹 + self._cache_fingerprint(fingerprint) + + return fingerprint + + def _load_cached_fingerprint(self) -> Optional[Dict]: + """从缓存文件加载指纹""" + if not self.fingerprint_cache_file.exists(): + return None + + try: + with open(self.fingerprint_cache_file, 'r', encoding='utf-8') as f: + return json.load(f) + except Exception as e: + logger.error(f"加载缓存的设备指纹时出错: {e}") + return None + + def _cache_fingerprint(self, fingerprint: Dict): + """缓存指纹到文件""" + try: + # 确保目录存在 + self.fingerprint_cache_file.parent.mkdir(parents=True, exist_ok=True) + + with open(self.fingerprint_cache_file, 'w', encoding='utf-8') as f: + json.dump(fingerprint, f, indent=2, ensure_ascii=False) + + logger.info("设备指纹已缓存到文件") + except Exception as e: + logger.error(f"缓存设备指纹时出错: {e}") + + def generate_hardware_hash(self) -> str: + """根据硬件信息生成唯一的哈希值""" + fingerprint = self.generate_fingerprint() + + # 提取最不可变的硬件标识符 + identifiers = [] + + # 主机名 + hostname = fingerprint.get("hostname") + if hostname: + identifiers.append(hostname) + + # CPU ID + if fingerprint.get("cpu", {}).get("id"): + identifiers.append(fingerprint["cpu"]["id"]) + else: + identifiers.append(fingerprint.get("cpu", {}).get("name", "unknown_cpu")) + + # 主板序列号 + mb_serial = fingerprint.get("motherboard", {}).get("serial") + if mb_serial and mb_serial != "To be filled by O.E.M.": # 排除默认值 + identifiers.append(mb_serial) + else: + mb_uuid = fingerprint.get("motherboard", {}).get("uuid") + if mb_uuid: + identifiers.append(mb_uuid) + + # 硬盘序列号(使用第一个非空的硬盘序列号) + for disk in fingerprint.get("disks", []): + if disk.get("serial") and disk["serial"] != "0000_0000": + identifiers.append(disk["serial"]) + break + + # 如果没有收集到足够的硬件信息,使用MAC地址作为备选 + if len(identifiers) < 2: + identifiers.append(fingerprint.get("mac_address", "")) + + # 如果有蓝牙MAC地址,也加入 + if fingerprint.get("bluetooth_mac"): + identifiers.append(fingerprint.get("bluetooth_mac")) + + # 将所有标识符连接起来并计算哈希值 + fingerprint_str = "||".join(identifiers) + return hashlib.sha256(fingerprint_str.encode('utf-8')).hexdigest() + + def generate_serial_number(self) -> Tuple[str, str]: + """ + 生成设备序列号 + + Returns: + Tuple[str, str]: (序列号, 生成方法说明) + """ + fingerprint = self.generate_fingerprint() + + # 获取主机名,用于序列号生成 + hostname = fingerprint.get("hostname", "") + short_hostname = "".join(c for c in hostname if c.isalnum())[:8] + + # 优先使用主网卡MAC地址生成序列号 + primary_mac = fingerprint.get("primary_mac") + primary_mac_type = fingerprint.get("primary_mac_type", "未知网卡") + + if primary_mac: + # 确保MAC地址为小写且没有冒号 + mac_clean = primary_mac.lower().replace(":", "") + short_hash = hashlib.md5(mac_clean.encode()).hexdigest()[:8].upper() + serial_number = f"SN-{short_hash}-{mac_clean}" + return serial_number, primary_mac_type + + # 备选方案: 尝试使用蓝牙MAC地址 + bluetooth_mac = fingerprint.get("bluetooth_mac") + if bluetooth_mac: + # 确保MAC地址为小写且没有冒号 + mac_clean = bluetooth_mac.lower().replace(":", "") + short_hash = hashlib.md5(mac_clean.encode()).hexdigest()[:8].upper() + serial_number = f"SN-{short_hash}-{mac_clean}" + return serial_number, "蓝牙MAC地址" + + # 备选方案: 使用常规MAC地址 + mac_address = fingerprint.get("mac_address") + if mac_address: + # 确保MAC地址为小写且没有冒号 + mac_clean = mac_address.lower().replace(":", "") + short_hash = hashlib.md5(mac_clean.encode()).hexdigest()[:8].upper() + serial_number = f"SN-{short_hash}-{mac_clean}" + return serial_number, "系统MAC地址" + + # 最后方案: 使用硬件哈希 + hardware_hash = self.generate_hardware_hash()[:16].upper() + serial_number = f"SN-{hardware_hash}" + return serial_number, "硬件哈希值" + + +# 单例模式获取设备指纹 +_fingerprint_instance = None + + +def get_device_fingerprint() -> DeviceFingerprint: + """获取设备指纹实例(单例模式)""" + global _fingerprint_instance + if _fingerprint_instance is None: + _fingerprint_instance = DeviceFingerprint() + return _fingerprint_instance \ No newline at end of file diff --git a/src/utils/logging_config.py b/src/utils/logging_config.py new file mode 100644 index 0000000000000000000000000000000000000000..58bec50182baa36d7047f702d3d5d865ffb4dc4a --- /dev/null +++ b/src/utils/logging_config.py @@ -0,0 +1,105 @@ +import logging +import os +from logging.handlers import TimedRotatingFileHandler +from colorlog import ColoredFormatter + + +def setup_logging(): + """配置日志系统""" + # 创建logs目录(如果不存在) + log_dir = os.path.join( + os.path.dirname(os.path.abspath(__file__)), + '..', + '..', + 'logs' + ) + os.makedirs(log_dir, exist_ok=True) + + # 日志文件路径 + log_file = os.path.join(log_dir, 'app.log') + + # 创建根日志记录器 + root_logger = logging.getLogger() + root_logger.setLevel(logging.INFO) # 设置根日志级别 + + # 清除已有的处理器(避免重复添加) + if root_logger.handlers: + root_logger.handlers.clear() + + # 创建控制台处理器 + console_handler = logging.StreamHandler() + console_handler.setLevel(logging.INFO) + + # 创建按天切割的文件处理器 + file_handler = TimedRotatingFileHandler( + log_file, + when='midnight', # 每天午夜切割 + interval=1, # 每1天 + backupCount=30, # 保留30天的日志 + encoding='utf-8' + ) + file_handler.setLevel(logging.INFO) + file_handler.suffix = "%Y-%m-%d.log" # 日志文件后缀格式 + + # 创建格式化器 + formatter = logging.Formatter( + '%(asctime)s[%(name)s] - %(levelname)s - %(message)s - %(threadName)s' + ) + + # 控制台颜色格式化器 + color_formatter = ColoredFormatter( + '%(green)s%(asctime)s%(reset)s[%(blue)s%(name)s%(reset)s] - ' + '%(log_color)s%(levelname)s%(reset)s - %(green)s%(message)s%(reset)s - ' + '%(cyan)s%(threadName)s%(reset)s', + log_colors={ + 'DEBUG': 'cyan', + 'INFO': 'white', + 'WARNING': 'yellow', + 'ERROR': 'red', + 'CRITICAL': 'red,bg_white', + }, + secondary_log_colors={ + 'asctime': {'green': 'green'}, + 'name': {'blue': 'blue'} + } + ) + console_handler.setFormatter(color_formatter) + file_handler.setFormatter(formatter) + + # 添加处理器到根日志记录器 + root_logger.addHandler(console_handler) + root_logger.addHandler(file_handler) + + # 输出日志配置信息 + logging.info("日志系统已初始化,日志文件: %s", log_file) + + return log_file + + +def get_logger(name): + """ + 获取统一配置的日志记录器 + + Args: + name: 日志记录器名称,通常是模块名 + + Returns: + logging.Logger: 配置好的日志记录器 + + 示例: + logger = get_logger(__name__) + logger.info("这是一条信息") + logger.error("出错了: %s", error_msg) + """ + logger = logging.getLogger(name) + + # 添加一些辅助方法 + def log_error_with_exc(msg, *args, **kwargs): + """记录错误并自动包含异常堆栈""" + kwargs['exc_info'] = True + logger.error(msg, *args, **kwargs) + + # 添加到日志记录器 + logger.error_exc = log_error_with_exc + + return logger \ No newline at end of file diff --git a/src/utils/system_info.py b/src/utils/system_info.py new file mode 100644 index 0000000000000000000000000000000000000000..a5c4e7e390cef7637c1858eebf2ee8ff4073a46e --- /dev/null +++ b/src/utils/system_info.py @@ -0,0 +1,343 @@ +# 在导入 opuslib 之前处理 opus 动态库 +import ctypes +import os +import sys +import platform +import shutil +from pathlib import Path + +# 获取日志记录器 +from src.utils.logging_config import get_logger +logger = get_logger(__name__) + + +# 平台和架构常量定义 +WINDOWS = 'windows' +MACOS = 'darwin' +LINUX = 'linux' + +# 库文件信息 +LIB_INFO = { + WINDOWS: {'name': 'opus.dll', 'system_name': 'opus'}, + MACOS: {'name': 'libopus.dylib', 'system_name': 'libopus.dylib'}, + LINUX: { + 'name': 'libopus.so', + 'system_name': ['libopus.so.0', 'libopus.so'] + }, +} + +# 目录结构定义 - 根据实际目录结构定义路径 +DIR_STRUCTURE = { + WINDOWS: {'arch': 'x86_64', 'path': 'libs/libopus/win/x86_64'}, + MACOS: { + 'arch': {'arm': 'arm64', 'intel': 'x64'}, + 'path': 'libs/libopus/mac/{arch}' + }, + LINUX: { + 'arch': {'arm': 'arm64', 'intel': 'x64'}, + 'path': 'libs/libopus/linux/{arch}' + }, +} + + +def get_system_info(): + """获取当前系统信息""" + system = platform.system().lower() + architecture = platform.machine().lower() + + # 标准化系统名称 + if system == 'windows' or system.startswith('win'): + system = WINDOWS + elif system == 'darwin': + system = MACOS + elif system.startswith('linux'): + system = LINUX + + # 标准化架构名称 + is_arm = 'arm' in architecture or 'aarch64' in architecture + + if system == MACOS: + arch_name = DIR_STRUCTURE[MACOS]['arch']['arm' if is_arm else 'intel'] + elif system == WINDOWS: + arch_name = DIR_STRUCTURE[WINDOWS]['arch'] + else: # Linux + arch_name = DIR_STRUCTURE[LINUX]['arch']['arm' if is_arm else 'intel'] + + return system, arch_name + + +def get_search_paths(system, arch_name): + """获取库文件搜索路径列表""" + # 可能的基准路径 + possible_base_dirs = [ + Path(__file__).parent.parent.parent, # 项目根目录 + Path.cwd(), # 当前工作目录 + ] + + # 如果是打包后的环境,添加可执行文件目录 + if getattr(sys, 'frozen', False): + # 可执行文件所在目录 + exe_dir = Path(sys.executable).parent + possible_base_dirs.append(exe_dir) + + # PyInstaller的_MEIPASS路径(如果存在) - 包含解压的所有资源 + if hasattr(sys, '_MEIPASS'): + meipass_dir = Path(sys._MEIPASS) + possible_base_dirs.append(meipass_dir) + # 支持PyInstaller 6.0.0+:_MEIPASS可能是_internal目录 + if meipass_dir.name == '_internal': + # 添加_internal的父目录 + possible_base_dirs.append(meipass_dir.parent) + + # 增加向上一级目录的搜索 + parent_dir = exe_dir.parent + possible_base_dirs.append(parent_dir) + + # 支持PyInstaller 6.0.0+:检查_internal目录 + internal_dir = exe_dir / '_internal' + if internal_dir.exists(): + possible_base_dirs.append(internal_dir) + + logger.debug(f"可执行文件目录: {exe_dir}") + logger.debug(f"可执行文件父目录: {parent_dir}") + if hasattr(sys, '_MEIPASS'): + logger.debug(f"PyInstaller资源目录: {meipass_dir}") + + # 根据系统和架构构建搜索路径 + lib_name = LIB_INFO[system]['name'] + search_paths = [] + + for base_dir in filter(None, possible_base_dirs): + # 使用标准化的目录结构 + if system == MACOS: + lib_path = DIR_STRUCTURE[MACOS]['path'].format(arch=arch_name) + search_paths.append((base_dir / lib_path, lib_name)) + elif system == WINDOWS: + lib_path = DIR_STRUCTURE[WINDOWS]['path'] + search_paths.append((base_dir / lib_path, lib_name)) + elif system == LINUX: + lib_path = DIR_STRUCTURE[LINUX]['path'] + search_paths.append((base_dir / lib_path, lib_name)) + + # 根目录 (作为备选) + search_paths.append((base_dir, lib_name)) + + # 如果是打包环境,也搜索和可执行文件同级的libs子目录 + is_exe_dir = ( + getattr(sys, 'frozen', False) and + base_dir == Path(sys.executable).parent + ) + if is_exe_dir: + # 检查与可执行文件同级的libs目录 + libs_dir = base_dir / 'libs' + if libs_dir.exists(): + if system == MACOS: + macos_lib_path = f"libopus/mac/{arch_name}" + search_paths.append((libs_dir / macos_lib_path, lib_name)) + elif system == WINDOWS: + win_lib_path = "libopus/win/x86_64" + search_paths.append((libs_dir / win_lib_path, lib_name)) + elif system == LINUX: + linux_lib_path = f"libopus/linux/{arch_name}" + search_paths.append((libs_dir / linux_lib_path, lib_name)) + + # 检查_internal/libs目录 (PyInstaller 6.0.0+) + internal_libs_dir = base_dir / '_internal' / 'libs' + if internal_libs_dir.exists(): + if system == MACOS: + macos_path = f"libopus/mac/{arch_name}" + search_paths.append( + (internal_libs_dir / macos_path, lib_name) + ) + elif system == WINDOWS: + win_path = "libopus/win/x86_64" + search_paths.append( + (internal_libs_dir / win_path, lib_name) + ) + elif system == LINUX: + linux_path = f"libopus/linux/{arch_name}" + search_paths.append( + (internal_libs_dir / linux_path, lib_name) + ) + + # 打印所有搜索路径,帮助调试 + for dir_path, filename in search_paths: + logger.debug(f"搜索路径: {dir_path / filename}") + + return search_paths + + +def find_system_opus(): + """从系统路径查找opus库""" + system, _ = get_system_info() + lib_path = None + + try: + # 获取系统上opus库的名称 + lib_names = LIB_INFO[system]['system_name'] + if not isinstance(lib_names, list): + lib_names = [lib_names] + + # 尝试加载每个可能的名称 + for lib_name in lib_names: + try: + # 导入ctypes.util以使用find_library函数 + import ctypes.util + system_lib_path = ctypes.util.find_library(lib_name) + + if system_lib_path: + lib_path = system_lib_path + logger.info(f"在系统路径中找到opus库: {lib_path}") + break + else: + # 直接尝试加载库名 + ctypes.cdll.LoadLibrary(lib_name) + lib_path = lib_name + logger.info(f"直接加载系统opus库: {lib_name}") + break + except Exception as e: + logger.debug(f"加载系统库 {lib_name} 失败: {e}") + continue + + except Exception as e: + logger.error(f"查找系统opus库失败: {e}") + + return lib_path + + +def copy_opus_to_project(system_lib_path): + """将系统库复制到项目目录""" + system, arch_name = get_system_info() + + if not system_lib_path: + logger.error("无法复制opus库:系统库路径为空") + return None + + try: + # 确定目标根目录 + if getattr(sys, 'frozen', False): + # 在打包环境中,使用可执行文件目录 + project_root = Path(sys.executable).parent + else: + # 在开发环境中,使用项目根目录 + project_root = Path(__file__).parent.parent.parent + + # 获取目标目录路径 - 使用实际目录结构 + if system == MACOS: + target_path = DIR_STRUCTURE[MACOS]['path'].format(arch=arch_name) + elif system == WINDOWS: + target_path = DIR_STRUCTURE[WINDOWS]['path'] + else: # Linux + target_path = DIR_STRUCTURE[LINUX]['path'] + + target_dir = project_root / target_path + + # 创建目标目录(如果不存在) + target_dir.mkdir(parents=True, exist_ok=True) + + # 确定目标文件名 + lib_name = LIB_INFO[system]['name'] + target_file = target_dir / lib_name + + # 复制文件 + shutil.copy2(system_lib_path, target_file) + logger.info(f"已将opus库从 {system_lib_path} 复制到 {target_file}") + + return str(target_file) + + except Exception as e: + logger.error(f"复制opus库到项目目录失败: {e}") + return None + + +def setup_opus(): + """设置opus动态库""" + # 检查是否已经由runtime_hook加载 + if hasattr(sys, '_opus_loaded'): + logger.info("opus库已由运行时钩子加载") + return True + + # 获取当前系统信息 + system, arch_name = get_system_info() + logger.info(f"当前系统: {system}, 架构: {arch_name}") + + # 构建搜索路径 + search_paths = get_search_paths(system, arch_name) + + # 查找本地库文件 + lib_path = None + lib_dir = None + + for dir_path, file_name in search_paths: + full_path = dir_path / file_name + if full_path.exists(): + lib_path = str(full_path) + lib_dir = str(dir_path) + logger.info(f"找到opus库文件: {lib_path}") + break + + # 如果本地没找到,尝试从系统查找 + if lib_path is None: + logger.warning("本地未找到opus库文件,尝试从系统路径加载") + system_lib_path = find_system_opus() + + if system_lib_path: + # 首次尝试直接使用系统库 + try: + _ = ctypes.cdll.LoadLibrary(system_lib_path) + logger.info(f"已从系统路径加载opus库: {system_lib_path}") + sys._opus_loaded = True + return True + except Exception as e: + logger.warning(f"加载系统opus库失败: {e},尝试复制到项目目录") + + # 如果直接加载失败,尝试复制到项目目录 + lib_path = copy_opus_to_project(system_lib_path) + if lib_path: + lib_dir = str(Path(lib_path).parent) + else: + logger.error("无法找到或复制opus库文件") + return False + else: + logger.error("在系统中也未找到opus库文件") + return False + + # Windows平台特殊处理 + if system == WINDOWS and lib_dir: + # 添加DLL搜索路径 + if hasattr(os, 'add_dll_directory'): + try: + os.add_dll_directory(lib_dir) + logger.debug(f"已添加DLL搜索路径: {lib_dir}") + except Exception as e: + logger.warning(f"添加DLL搜索路径失败: {e}") + + # 设置环境变量 + os.environ['PATH'] = lib_dir + os.pathsep + os.environ.get('PATH', '') + + # 修补库路径 + _patch_find_library('opus', lib_path) + + # 尝试加载库 + try: + # 加载DLL并存储引用以防止垃圾回收 + _ = ctypes.CDLL(lib_path) + logger.info(f"成功加载opus库: {lib_path}") + sys._opus_loaded = True + return True + except Exception as e: + logger.error(f"加载opus库失败: {e}") + return False + + +def _patch_find_library(lib_name, lib_path): + """修补ctypes.util.find_library函数""" + import ctypes.util + original_find_library = ctypes.util.find_library + + def patched_find_library(name): + if name == lib_name: + return lib_path + return original_find_library(name) + + ctypes.util.find_library = patched_find_library \ No newline at end of file diff --git a/src/utils/tts_utility.py b/src/utils/tts_utility.py new file mode 100644 index 0000000000000000000000000000000000000000..b8b54e28100762b1d0396efc51f94c2fc5c4b6af --- /dev/null +++ b/src/utils/tts_utility.py @@ -0,0 +1,75 @@ +import opuslib +import asyncio +from edge_tts import Communicate +import soundfile as sf +import io +from pydub import AudioSegment +import numpy as np + + +class TtsUtility: + def __init__(self, audio_config): + self.audio_config = audio_config + + async def generate_tts(self, text: str) -> bytes: + """使用 Edge TTS 生成语音""" + communicate = Communicate(text, "zh-CN-XiaoxiaoNeural") + audio_data = b"" + async for chunk in communicate.stream(): + if chunk["type"] == "audio": + audio_data += chunk["data"] + return audio_data + + async def text_to_opus_audio(self, text: str) -> list: + """将文本转换为 Opus 音频""" + + # 1. 生成 TTS 语音 + audio_data = await self.generate_tts(text) + + try: + # 2. 将 MP3 数据转换为 PCM 数据 + audio = AudioSegment.from_mp3(io.BytesIO(audio_data)) + + # 修改采样率与通道数,以匹配录音数据格式 + audio = audio.set_frame_rate(self.audio_config.INPUT_SAMPLE_RATE) + audio = audio.set_channels(self.audio_config.CHANNELS) + + wav_data = io.BytesIO() + audio.export(wav_data, format='wav') + wav_data.seek(0) + + # 使用 soundfile 读取 WAV 数据 + data, samplerate = sf.read(wav_data) + + # 确保数据是 16 位整数格式 + if data.dtype != np.int16: + data = (data * 32767).astype(np.int16) + + # 转换为字节序列 + raw_data = data.tobytes() + + except Exception as e: + print(f"[ERROR] 音频转换失败: {e}") + return None + + # 3. 初始化Opus编码器 + encoder = opuslib.Encoder( + self.audio_config.INPUT_SAMPLE_RATE, + self.audio_config.CHANNELS, + opuslib.APPLICATION_VOIP + ) + + # 4. 分帧编码 + frame_size = self.audio_config.INPUT_FRAME_SIZE # 与录音时的帧大小保持一致 + opus_frames = [] + + # 按帧处理所有音频数据 + for i in range(0, len(raw_data), frame_size * 2): # 16bit = 2bytes/sample + chunk = raw_data[i:i + frame_size * 2] + if len(chunk) < frame_size * 2: + # 填充最后一帧 + chunk += b'\x00' * (frame_size * 2 - len(chunk)) + opus_frame = encoder.encode(chunk, frame_size) + opus_frames.append(opus_frame) + + return opus_frames \ No newline at end of file diff --git a/src/utils/volume_controller.py b/src/utils/volume_controller.py new file mode 100644 index 0000000000000000000000000000000000000000..3e547517e256bf517a6b27f4f468c796bd62a90e --- /dev/null +++ b/src/utils/volume_controller.py @@ -0,0 +1,306 @@ +import logging +import platform +import subprocess +import re +import shutil + + +class VolumeController: + """跨平台音量控制器""" + + def __init__(self): + self.logger = logging.getLogger("VolumeController") + self.system = platform.system() + self.is_arm = platform.machine().startswith(('arm', 'aarch')) + + # 初始化特定平台的控制器 + if self.system == "Windows": + self._init_windows() + elif self.system == "Darwin": # macOS + self._init_macos() + elif self.system == "Linux": + self._init_linux() + else: + self.logger.warning(f"不支持的操作系统: {self.system}") + raise NotImplementedError(f"不支持的操作系统: {self.system}") + + def _init_windows(self): + """初始化Windows音量控制""" + try: + from ctypes import cast, POINTER + from comtypes import CLSCTX_ALL + from pycaw.pycaw import AudioUtilities, IAudioEndpointVolume + + self.devices = AudioUtilities.GetSpeakers() + interface = self.devices.Activate(IAudioEndpointVolume._iid_, CLSCTX_ALL, None) + self.volume_control = cast(interface, POINTER(IAudioEndpointVolume)) + self.logger.debug("Windows音量控制初始化成功") + except Exception as e: + self.logger.error(f"Windows音量控制初始化失败: {e}") + raise + + def _init_macos(self): + """初始化macOS音量控制""" + try: + import applescript + # 测试是否可以访问音量控制 + result = applescript.run('get volume settings') + if not result or result.code != 0: + raise Exception("无法访问macOS音量控制") + self.logger.debug("macOS音量控制初始化成功") + except Exception as e: + self.logger.error(f"macOS音量控制初始化失败: {e}") + raise + + def _init_linux(self): + """初始化Linux音量控制""" + # 检测可用的音量控制工具 + self.linux_tool = None + + def cmd_exists(cmd): + return shutil.which(cmd) is not None + + # 按优先级检查工具 + if cmd_exists("pactl"): + self.linux_tool = "pactl" + elif cmd_exists("wpctl"): + self.linux_tool = "wpctl" + elif cmd_exists("amixer"): + self.linux_tool = "amixer" + elif cmd_exists("alsamixer") and cmd_exists("expect"): + self.linux_tool = "alsamixer" + + if not self.linux_tool: + self.logger.error("未找到可用的Linux音量控制工具") + raise Exception("未找到可用的Linux音量控制工具") + + self.logger.debug(f"Linux音量控制初始化成功,使用: {self.linux_tool}") + + def get_volume(self): + """获取当前音量 (0-100)""" + if self.system == "Windows": + return self._get_windows_volume() + elif self.system == "Darwin": + return self._get_macos_volume() + elif self.system == "Linux": + return self._get_linux_volume() + return 70 # 默认音量 + + def set_volume(self, volume): + """设置音量 (0-100)""" + # 确保音量在有效范围内 + volume = max(0, min(100, volume)) + + if self.system == "Windows": + self._set_windows_volume(volume) + elif self.system == "Darwin": + self._set_macos_volume(volume) + elif self.system == "Linux": + self._set_linux_volume(volume) + + def _get_windows_volume(self): + """获取Windows音量""" + try: + # 获取音量百分比 + volume_scalar = self.volume_control.GetMasterVolumeLevelScalar() + return int(volume_scalar * 100) + except Exception as e: + self.logger.warning(f"获取Windows音量失败: {e}") + return 70 + + def _set_windows_volume(self, volume): + """设置Windows音量""" + try: + # 直接设置音量百分比 + self.volume_control.SetMasterVolumeLevelScalar(volume / 100.0, None) + except Exception as e: + self.logger.warning(f"设置Windows音量失败: {e}") + + def _get_macos_volume(self): + """获取macOS音量""" + try: + import applescript + result = applescript.run('output volume of (get volume settings)') + if result and result.out: + return int(result.out.strip()) + return 70 + except Exception as e: + self.logger.warning(f"获取macOS音量失败: {e}") + return 70 + + def _set_macos_volume(self, volume): + """设置macOS音量""" + try: + import applescript + applescript.run(f'set volume output volume {volume}') + except Exception as e: + self.logger.warning(f"设置macOS音量失败: {e}") + + def _get_linux_volume(self): + """获取Linux音量""" + if self.linux_tool == "pactl": + return self._get_pactl_volume() + elif self.linux_tool == "wpctl": + return self._get_wpctl_volume() + elif self.linux_tool == "amixer": + return self._get_amixer_volume() + return 70 + + def _set_linux_volume(self, volume): + """设置Linux音量""" + if self.linux_tool == "pactl": + self._set_pactl_volume(volume) + elif self.linux_tool == "wpctl": + self._set_wpctl_volume(volume) + elif self.linux_tool == "amixer": + self._set_amixer_volume(volume) + elif self.linux_tool == "alsamixer": + self._set_alsamixer_volume(volume) + + def _get_pactl_volume(self): + """使用pactl获取音量""" + try: + result = subprocess.run( + ["pactl", "list", "sinks"], + capture_output=True, + text=True + ) + if result.returncode == 0: + for line in result.stdout.split('\n'): + if 'Volume:' in line and 'front-left:' in line: + match = re.search(r'(\d+)%', line) + if match: + return int(match.group(1)) + except Exception as e: + self.logger.debug(f"通过pactl获取音量失败: {e}") + return 70 + + def _set_pactl_volume(self, volume): + """使用pactl设置音量""" + try: + subprocess.run( + ["pactl", "set-sink-volume", "@DEFAULT_SINK@", f"{volume}%"], + capture_output=True, + text=True + ) + except Exception as e: + self.logger.warning(f"通过pactl设置音量失败: {e}") + + def _get_wpctl_volume(self): + """使用wpctl获取音量""" + try: + result = subprocess.run( + ["wpctl", "get-volume", "@DEFAULT_AUDIO_SINK@"], + capture_output=True, + text=True, + check=True + ) + return int(float(result.stdout.split(' ')[1]) * 100) + except Exception as e: + self.logger.debug(f"通过wpctl获取音量失败: {e}") + return 70 + + def _set_wpctl_volume(self, volume): + """使用wpctl设置音量""" + try: + subprocess.run( + ["wpctl", "set-volume", "@DEFAULT_AUDIO_SINK@", f"{volume}%"], + capture_output=True, + text=True, + check=True + ) + except Exception as e: + self.logger.warning(f"通过wpctl设置音量失败: {e}") + + def _get_amixer_volume(self): + """使用amixer获取音量""" + try: + result = subprocess.run( + ["amixer", "get", "Master"], + capture_output=True, + text=True + ) + if result.returncode == 0: + match = re.search(r'(\d+)%', result.stdout) + if match: + return int(match.group(1)) + except Exception as e: + self.logger.debug(f"通过amixer获取音量失败: {e}") + return 70 + + def _set_amixer_volume(self, volume): + """使用amixer设置音量""" + try: + subprocess.run( + ["amixer", "sset", "Master", f"{volume}%"], + capture_output=True, + text=True + ) + except Exception as e: + self.logger.warning(f"通过amixer设置音量失败: {e}") + + def _set_alsamixer_volume(self, volume): + """使用alsamixer设置音量""" + try: + script = f""" + spawn alsamixer + send "m" + send "{volume}" + send "%" + send "q" + expect eof + """ + subprocess.run( + ["expect", "-c", script], + capture_output=True, + text=True + ) + except Exception as e: + self.logger.warning(f"通过alsamixer设置音量失败: {e}") + + @staticmethod + def check_dependencies(): + """检查并报告缺少的依赖""" + import platform + system = platform.system() + missing = [] + + if system == "Windows": + try: + import pycaw + except ImportError: + missing.append("pycaw") + try: + import comtypes + except ImportError: + missing.append("comtypes") + + elif system == "Darwin": # macOS + try: + import applescript + except ImportError: + missing.append("applescript") + + elif system == "Linux": + import shutil + tools = ["pactl", "wpctl", "amixer", "alsamixer"] + found = False + for tool in tools: + if shutil.which(tool): + found = True + break + if not found: + missing.append("pulseaudio-utils、wireplumber 或 alsa-utils") + + if missing: + print(f"警告: 音量控制需要以下依赖,但未找到: {', '.join(missing)}") + print("请使用以下命令安装缺少的依赖:") + if system == "Windows": + print("pip install " + " ".join(missing)) + elif system == "Darwin": + print("pip install " + " ".join(missing)) + elif system == "Linux": + print("sudo apt-get install " + " ".join(missing)) + return False + + return True \ No newline at end of file