diff --git a/README.md b/README.md
index 7602db54..eb718c24 100644
--- a/README.md
+++ b/README.md
@@ -183,8 +183,8 @@ Spearheaded by Professor Siyuan Liu's Team (South China University of Technology
#### 🚀 部署方式选择
| 部署方式 | 特点 | 适用场景 | 部署文档 | 配置要求 | 视频教程 |
|---------|------|---------|---------|---------|---------|
-| **最简化安装** | 智能对话、IOT、MCP、视觉感知 | 低配置环境,数据存储在配置文件,无需数据库 | [①Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - |
-| **全模块安装** | 智能对话、IOT、MCP接入点、声纹识别、视觉感知、OTA、智控台 | 完整功能体验,数据存储在数据库 |[①Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
+| **最简化安装** | 智能对话、单智能体管理 | 低配置环境,数据存储在配置文件,无需数据库 | [①Docker版](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②源码部署](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 如果使用`FunASR`要2核4G,如果全API,要2核2G | - |
+| **全模块安装** | 智能对话、多用户管理、多智能体管理、智控台界面操作 | 完整功能体验,数据存储在数据库 |[①Docker版](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②源码部署](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③源码部署自动更新教程](./docs/dev-ops-integration.md) | 如果使用`FunASR`要4核8G,如果全API,要2核4G| [本地源码启动视频教程](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
常见问题及相关教程,可参考[这个链接](./docs/FAQ.md)
diff --git a/README_de.md b/README_de.md
index 53609462..0e4c74d5 100644
--- a/README_de.md
+++ b/README_de.md
@@ -181,8 +181,8 @@ Dieses Projekt bietet zwei Bereitstellungsmethoden. Bitte wählen Sie basierend
#### 🚀 Auswahl der Bereitstellungsmethode
| Bereitstellungsmethode | Funktionen | Anwendungsszenarien | Deployment-Dokumente | Konfigurationsanforderungen | Video-Tutorials |
|---------|------|---------|---------|---------|---------|
-| **Vereinfachte Installation** | Intelligenter Dialog, IOT, MCP, visuelle Wahrnehmung | Umgebungen mit geringer Konfiguration, Daten in Konfigurationsdateien gespeichert, keine Datenbank erforderlich | [①Docker-Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Quellcode-Deployment](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 Kerne 4GB bei Verwendung von `FunASR`, 2 Kerne 2GB bei allen APIs | - |
-| **Vollständige Modulinstallation** | Intelligenter Dialog, IOT, MCP-Endpunkte, Stimmabdruckerkennung, visuelle Wahrnehmung, OTA, intelligente Steuerkonsole | Vollständige Funktionserfahrung, Daten in Datenbank gespeichert |[①Docker-Version](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Quellcode-Deployment](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Quellcode-Deployment Auto-Update-Tutorial](./docs/dev-ops-integration.md) | 4 Kerne 8GB bei Verwendung von `FunASR`, 2 Kerne 4GB bei allen APIs| [Video-Tutorial für lokalen Quellcode-Start](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
+| **Vereinfachte Installation** | Intelligenter Dialog, Einzel-Agenten-Verwaltung | Umgebungen mit geringer Konfiguration, Daten in Konfigurationsdateien gespeichert, keine Datenbank erforderlich | [①Docker-Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Quellcode-Deployment](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 Kerne 4GB bei Verwendung von `FunASR`, 2 Kerne 2GB bei allen APIs | - |
+| **Vollständige Modulinstallation** | Intelligenter Dialog, Mehrbenutzerverwaltung, Mehr-Agenten-Verwaltung, Intelligente Steuerkonsole-Bedienung | Vollständige Funktionserfahrung, Daten in Datenbank gespeichert |[①Docker-Version](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Quellcode-Deployment](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Quellcode-Deployment Auto-Update-Tutorial](./docs/dev-ops-integration.md) | 4 Kerne 8GB bei Verwendung von `FunASR`, 2 Kerne 4GB bei allen APIs| [Video-Tutorial für lokalen Quellcode-Start](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
Häufige Fragen und entsprechende Tutorials finden Sie unter [diesem Link](./docs/FAQ.md)
diff --git a/README_en.md b/README_en.md
index cc0f170c..196cf16f 100644
--- a/README_en.md
+++ b/README_en.md
@@ -181,8 +181,8 @@ This project provides two deployment methods. Please choose based on your specif
#### 🚀 Deployment Method Selection
| Deployment Method | Features | Applicable Scenarios | Deployment Docs | Configuration Requirements | Video Tutorials |
|---------|------|---------|---------|---------|---------|
-| **Simplified Installation** | Intelligent dialogue, IOT, MCP, visual perception | Low-configuration environments, data stored in config files, no database required | [①Docker Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Source Code Deployment](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 cores 4GB if using `FunASR`, 2 cores 2GB if all APIs | - |
-| **Full Module Installation** | Intelligent dialogue, IOT, MCP endpoints, voiceprint recognition, visual perception, OTA, intelligent control console | Complete functionality experience, data stored in database |[①Docker Version](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Source Code Deployment](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Source Code Deployment Auto-Update Tutorial](./docs/dev-ops-integration.md) | 4 cores 8GB if using `FunASR`, 2 cores 4GB if all APIs| [Local Source Code Startup Video Tutorial](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
+| **Simplified Installation** | Intelligent dialogue, single agent management | Low-configuration environments, data stored in config files, no database required | [①Docker Version](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Source Code Deployment](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 cores 4GB if using `FunASR`, 2 cores 2GB if all APIs | - |
+| **Full Module Installation** | Intelligent dialogue, multi-user management, multi-agent management, intelligent console interface operation | Complete functionality experience, data stored in database |[①Docker Version](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Source Code Deployment](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Source Code Deployment Auto-Update Tutorial](./docs/dev-ops-integration.md) | 4 cores 8GB if using `FunASR`, 2 cores 4GB if all APIs| [Local Source Code Startup Video Tutorial](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
> 💡 Note: Below is a test platform deployed with the latest code. You can burn and test if needed. Concurrent users: 6, data will be cleared daily.
diff --git a/README_vi.md b/README_vi.md
index 83557ce5..e6d5f489 100644
--- a/README_vi.md
+++ b/README_vi.md
@@ -182,8 +182,8 @@ Dự án này cung cấp hai phương pháp triển khai, vui lòng chọn theo
#### 🚀 Lựa chọn phương pháp triển khai
| Phương pháp triển khai | Đặc điểm | Tình huống áp dụng | Tài liệu triển khai | Yêu cầu cấu hình | Video hướng dẫn |
|---------|------|---------|---------|---------|---------|
-| **Cài đặt tối giản** | Đối thoại thông minh, IOT, MCP, cảm nhận thị giác | Môi trường cấu hình thấp, dữ liệu lưu trong tệp cấu hình, không cần cơ sở dữ liệu | [①Phiên bản Docker](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Triển khai mã nguồn](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 nhân 4GB nếu dùng `FunASR`, 2 nhân 2GB nếu toàn API | - |
-| **Cài đặt toàn bộ module** | Đối thoại thông minh, IOT, điểm truy cập MCP, nhận dạng giọng nói, cảm nhận thị giác, OTA, bảng điều khiển thông minh | Trải nghiệm đầy đủ tính năng, dữ liệu lưu trong cơ sở dữ liệu |[①Phiên bản Docker](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Triển khai mã nguồn](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Hướng dẫn tự động cập nhật triển khai mã nguồn](./docs/dev-ops-integration.md) | 4 nhân 8GB nếu dùng `FunASR`, 2 nhân 4GB nếu toàn API| [Video hướng dẫn khởi động mã nguồn cục bộ](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
+| **Cài đặt tối giản** | Đối thoại thông minh, quản lý đơn tác nhân | Môi trường cấu hình thấp, dữ liệu lưu trong tệp cấu hình, không cần cơ sở dữ liệu | [①Phiên bản Docker](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E5%8F%AA%E8%BF%90%E8%A1%8Cserver) / [②Triển khai mã nguồn](./docs/Deployment.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E5%8F%AA%E8%BF%90%E8%A1%8Cserver)| 2 nhân 4GB nếu dùng `FunASR`, 2 nhân 2GB nếu toàn API | - |
+| **Cài đặt toàn bộ module** | Đối thoại thông minh, quản lý đa người dùng, quản lý đa tác nhân, bảng điều khiển thông minh | Trải nghiệm đầy đủ tính năng, dữ liệu lưu trong cơ sở dữ liệu |[①Phiên bản Docker](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%B8%80docker%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [②Triển khai mã nguồn](./docs/Deployment_all.md#%E6%96%B9%E5%BC%8F%E4%BA%8C%E6%9C%AC%E5%9C%B0%E6%BA%90%E7%A0%81%E8%BF%90%E8%A1%8C%E5%85%A8%E6%A8%A1%E5%9D%97) / [③Hướng dẫn tự động cập nhật triển khai mã nguồn](./docs/dev-ops-integration.md) | 4 nhân 8GB nếu dùng `FunASR`, 2 nhân 4GB nếu toàn API| [Video hướng dẫn khởi động mã nguồn cục bộ](https://www.bilibili.com/video/BV1wBJhz4Ewe) |
Câu hỏi thường gặp và hướng dẫn liên quan, vui lòng tham khảo [liên kết này](./docs/FAQ.md)
diff --git a/docs/FAQ.md b/docs/FAQ.md
index f1e12d84..69494c3d 100644
--- a/docs/FAQ.md
+++ b/docs/FAQ.md
@@ -69,6 +69,7 @@ VAD:
### 9、编译固件相关教程
1、[如何自己编译小智固件](./firmware-build.md)
2、[如何基于虾哥编译好的固件修改OTA地址](./firmware-setting.md)
+3、[单模块部署如何配置固件OTA自动升级](./ota-upgrade-guide.md)
### 10、拓展相关教程
1、[如何开启手机号码注册智控台](./ali-sms-integration.md)
diff --git a/docs/ota-upgrade-guide.md b/docs/ota-upgrade-guide.md
new file mode 100644
index 00000000..0df4a2ff
--- /dev/null
+++ b/docs/ota-upgrade-guide.md
@@ -0,0 +1,142 @@
+# 单模块部署固件OTA自动升级配置指南
+
+本教程将指导你如何在**单模块部署**场景下配置固件OTA自动升级功能,实现设备固件的自动更新。
+
+如果你已经使用**全模块部署**,请忽略本教程。
+
+## 功能介绍
+
+在单模块部署中,xiaozhi-server内置了OTA固件管理功能,可以自动检测设备版本并下发升级固件。系统会根据设备型号和当前版本,自动匹配并推送最新的固件版本。
+
+## 前提条件
+
+- 你已经成功进行**单模块部署**并运行xiaozhi-server
+- 设备能够正常连接到服务器
+
+## 第一步 准备固件文件
+
+### 1. 创建固件存放目录
+
+固件文件需要放在`data/bin/`目录下。如果该目录不存在,请手动创建:
+
+```bash
+mkdir -p data/bin
+```
+
+### 2. 固件文件命名规则
+
+固件文件必须遵循以下命名格式:
+
+```
+{设备型号}_{版本号}.bin
+```
+
+**命名规则说明:**
+- `设备型号`:设备的型号名称,例如 `lichuang-dev`、`bread-compact-wifi` 等
+- `版本号`:固件版本号,必须以数字开头,支持数字、字母、点号、下划线和短横线,例如 `1.6.6`、`2.0.0` 等
+- 文件扩展名必须是 `.bin`
+
+**命名示例:**
+```
+bread-compact-wifi_1.6.6.bin
+lichuang-dev_2.0.0.bin
+```
+
+### 3. 放置固件文件
+
+将准备好的固件文件(.bin文件)复制到`data/bin/`目录下:
+
+重要的事情说三遍:升级的bin文件是`xiaozhi.bin`,不是全量固件文件`merged-binary.bin`!
+
+重要的事情说三遍:升级的bin文件是`xiaozhi.bin`,不是全量固件文件`merged-binary.bin`!
+
+重要的事情说三遍:升级的bin文件是`xiaozhi.bin`,不是全量固件文件`merged-binary.bin`!
+
+```bash
+cp xiaozhi.bin data/bin/设备型号_版本号.bin
+```
+
+例如:
+```bash
+cp xiaozhi.bin data/bin/bread-compact-wifi_1.6.6.bin
+```
+
+## 第二步 配置公网访问地址(仅公网部署需要)
+
+**注意:此步骤仅适用于单模块公网部署的场景。**
+
+如果你的xiaozhi-server是公网部署(使用公网IP或域名),**必须**配置`server.vision_explain`参数,因为OTA固件下载地址会使用该配置的域名和端口。
+
+如果你是局域网部署,可以跳过此步骤。
+
+### 为什么要配置这个参数?
+
+在单模块部署中,系统生成固件下载地址时,会使用`vision_explain`配置的域名和端口作为基础地址。如果不配置或配置错误,设备将无法访问固件下载地址。
+
+### 配置方法
+
+打开`data/.config.yaml`文件,找到`server`配置段,设置`vision_explain`参数:
+
+```yaml
+server:
+ vision_explain: http://你的域名或IP:端口号/mcp/vision/explain
+```
+
+**配置示例:**
+
+局域网部署(默认):
+```yaml
+server:
+ vision_explain: http://192.168.1.100:8003/mcp/vision/explain
+```
+
+公网域名部署:
+```yaml
+server:
+ vision_explain: http://yourdomain.com:8003/mcp/vision/explain
+```
+
+### 注意事项
+
+- 域名或IP必须是设备能够访问的地址
+- 如果使用Docker部署,不能使用Docker内部地址(如127.0.0.1或localhost)
+- 如果你使用了nginx反向代理,请填写对外的地址和端口号,不是本项目运行的端口号
+
+
+## 常见问题
+
+### 1. 设备收不到固件更新
+
+**可能原因和解决方法:**
+
+- 检查固件文件命名是否符合规则:`{型号}_{版本号}.bin`
+- 检查固件文件是否正确放置在`data/bin/`目录
+- 检查设备型号是否与固件文件名中的型号匹配
+- 检查固件版本号是否高于设备当前版本
+- 查看服务器日志,确认OTA请求是否正常处理
+
+### 2. 设备报告下载地址无法访问
+
+**可能原因和解决方法:**
+
+- 检查`server.vision_explain`配置的域名或IP是否正确
+- 确认端口号配置正确(默认8003)
+- 如果是公网部署,确保设备能够访问该公网地址
+- 如果是Docker部署,确保不是使用了内部地址(127.0.0.1)
+- 检查防火墙是否开放了对应端口
+- 如果你使用了nginx反向代理,请填写对外的地址和端口号,不是本项目运行的端口号
+
+### 3. 如何确认设备当前版本
+
+查看OTA请求日志,日志中会显示设备上报的版本号:
+
+```
+[ota_handler] - 设备 AA:BB:CC:DD:EE:FF 固件已是最新: 1.6.6
+```
+
+### 4. 固件文件放置后没有生效
+
+系统有30秒的缓存时间(默认),可以:
+- 等待30秒后再让设备发起OTA请求
+- 重启xiaozhi-server服务
+- 调整`firmware_cache_ttl`配置为更短的时间
diff --git a/main/xiaozhi-server/core/api/base_handler.py b/main/xiaozhi-server/core/api/base_handler.py
index 7330185e..db277543 100644
--- a/main/xiaozhi-server/core/api/base_handler.py
+++ b/main/xiaozhi-server/core/api/base_handler.py
@@ -10,7 +10,15 @@ class BaseHandler:
def _add_cors_headers(self, response):
"""添加CORS头信息"""
response.headers["Access-Control-Allow-Headers"] = (
- "client-id, content-type, device-id"
+ "client-id, content-type, device-id, authorization"
)
response.headers["Access-Control-Allow-Credentials"] = "true"
response.headers["Access-Control-Allow-Origin"] = "*"
+
+ async def handle_options(self, request):
+ """处理OPTIONS请求,添加CORS头信息"""
+ response = web.Response(body=b"", content_type="text/plain")
+ self._add_cors_headers(response)
+ # 添加允许的方法
+ response.headers["Access-Control-Allow-Methods"] = "GET, POST, OPTIONS"
+ return response
diff --git a/main/xiaozhi-server/core/api/ota_handler.py b/main/xiaozhi-server/core/api/ota_handler.py
index b6c88dff..1e3b3bd0 100644
--- a/main/xiaozhi-server/core/api/ota_handler.py
+++ b/main/xiaozhi-server/core/api/ota_handler.py
@@ -3,15 +3,46 @@ import time
import base64
import hashlib
import hmac
+import os
+import re
+import glob
+from typing import Dict, List, Tuple
from aiohttp import web
from core.auth import AuthManager
-from core.utils.util import get_local_ip
+from core.utils.util import get_local_ip, get_vision_url
from core.api.base_handler import BaseHandler
TAG = __name__
+def _safe_basename(filename: str) -> str:
+ # Prevent directory traversal
+ return os.path.basename(filename)
+
+
+def _parse_version(ver: str) -> Tuple[int, ...]:
+ # conservative parser: split by non-digit, keep numeric parts
+ parts = re.findall(r"\d+", ver)
+ return tuple(int(p) for p in parts) if parts else (0,)
+
+
+def _is_higher_version(a: str, b: str) -> bool:
+ """Return True if version string a > b (semver-like numeric compare)."""
+ ta = _parse_version(a)
+ tb = _parse_version(b)
+ # compare tuple lexicographically, but allow different lengths
+ maxlen = max(len(ta), len(tb))
+ for i in range(maxlen):
+ ai = ta[i] if i < len(ta) else 0
+ bi = tb[i] if i < len(tb) else 0
+ if ai > bi:
+ return True
+ if ai < bi:
+ return False
+ return False
+
+
class OTAHandler(BaseHandler):
def __init__(self, config: dict):
super().__init__(config)
@@ -23,6 +54,54 @@ class OTAHandler(BaseHandler):
expire_seconds = auth_config.get("expire_seconds")
self.auth = AuthManager(secret_key=secret_key, expire_seconds=expire_seconds)
+ # firmware storage
+ self.bin_dir = os.path.join(os.getcwd(), "data", "bin")
+ # cache structure: { 'updated_at': timestamp, 'ttl': seconds, 'files_by_model': { model: [(version, filename), ...] } }
+ self._bin_cache: Dict = {
+ "updated_at": 0,
+ "ttl": config.get("firmware_cache_ttl", 30),
+ "files_by_model": {},
+ }
+
+ def _refresh_bin_cache_if_needed(self):
+ now = int(time.time())
+ ttl = int(self._bin_cache.get("ttl", 30))
+ if now - int(
+ self._bin_cache.get("updated_at", 0)
+ ) < ttl and self._bin_cache.get("files_by_model"):
+ return
+
+ files_by_model: Dict[str, List[Tuple[str, str]]] = {}
+ try:
+ if not os.path.isdir(self.bin_dir):
+ os.makedirs(self.bin_dir, exist_ok=True)
+
+ # match files like model_1.2.3.bin (allow dots, dashes, underscores in model and version)
+ pattern = os.path.join(self.bin_dir, "*.bin")
+ for path in glob.glob(pattern):
+ fname = os.path.basename(path)
+ # filename format: {model}_{version}.bin
+ m = re.match(r"^(.+?)_([0-9][A-Za-z0-9\.\-_]*)\.bin$", fname)
+ if not m:
+ # skip files not conforming to naming rule
+ continue
+ model = m.group(1)
+ version = m.group(2)
+ files_by_model.setdefault(model, []).append((version, fname))
+
+ # sort versions for each model descending
+ for model, items in files_by_model.items():
+ items.sort(key=lambda it: _parse_version(it[0]), reverse=True)
+
+ self._bin_cache["files_by_model"] = files_by_model
+ self._bin_cache["updated_at"] = now
+ self.logger.bind(tag=TAG).info(
+ f"Firmware cache refreshed: {len(files_by_model)} models"
+ )
+ except Exception as e:
+ self.logger.bind(tag=TAG).error(f"刷新固件缓存失败: {e}")
+ # keep previous cache if any
+
def generate_password_signature(self, content: str, secret_key: str) -> str:
"""生成MQTT密码签名
@@ -62,7 +141,14 @@ class OTAHandler(BaseHandler):
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
async def handle_post(self, request):
- """处理 OTA POST 请求"""
+ """处理 OTA POST 请求
+
+ This handler will:
+ - read device id/client id (as before)
+ - attempt to determine device model and current firmware version (prefer headers, fallback to body)
+ - check data/bin for newer firmware for that model
+ - if found a newer firmware, set firmware.url to the download endpoint
+ """
try:
data = await request.text()
self.logger.bind(tag=TAG).debug(f"OTA请求方法: {request.method}")
@@ -81,33 +167,76 @@ class OTAHandler(BaseHandler):
else:
raise Exception("OTA请求ClientID为空")
- data_json = json.loads(data)
+ data_json = {}
+ try:
+ data_json = json.loads(data) if data else {}
+ except Exception:
+ data_json = {}
server_config = self.config["server"]
- port = int(server_config.get("port", 8000))
+ # Distinguish ports:
+ # - websocket_port is used to construct websocket URL (server["port"])
+ # - http_port is used to construct OTA download URLs (server["http_port"])
+ websocket_port = int(server_config.get("port", 8000))
+ http_port = int(server_config.get("http_port", 8003))
local_ip = get_local_ip()
+ # Determine device model (prefer headers)
+ device_model = ""
+ # header candidates
+ for h in ("device-model", "device_model", "model"):
+ if h in request.headers:
+ device_model = request.headers.get(h, "").strip()
+ break
+ # body fallback
+ if not device_model:
+ try:
+ if "board" in data_json and isinstance(data_json["board"], dict):
+ device_model = data_json["board"].get("type", "")
+ elif "model" in data_json:
+ device_model = data_json.get("model", "")
+ except Exception:
+ device_model = ""
+ if not device_model:
+ device_model = "default"
+
+ # Determine device current version (prefer headers)
+ device_version = ""
+ for h in (
+ "device-version",
+ "device_version",
+ "firmware-version",
+ "app-version",
+ "application-version",
+ ):
+ if h in request.headers:
+ device_version = request.headers.get(h, "").strip()
+ break
+ if not device_version:
+ try:
+ device_version = data_json.get("application", {}).get("version", "")
+ except Exception:
+ device_version = ""
+ if not device_version:
+ device_version = "0.0.0"
+
return_json = {
"server_time": {
"timestamp": int(round(time.time() * 1000)),
"timezone_offset": server_config.get("timezone_offset", 8) * 60,
},
"firmware": {
- "version": data_json["application"].get("version", "1.0.0"),
+ "version": device_version,
"url": "",
},
}
+ # existing mqtt/websocket logic (unchanged)
mqtt_gateway_endpoint = server_config.get("mqtt_gateway")
if mqtt_gateway_endpoint: # 如果配置了非空字符串
- # 尝试从请求数据中获取设备型号
- device_model = "default"
+ # 尝试从请求数据中获取设备型号(已解析 above)
try:
- if "device" in data_json and isinstance(data_json["device"], dict):
- device_model = data_json["device"].get("model", "default")
- elif "model" in data_json:
- device_model = data_json["model"]
group_id = f"GID_{device_model}".replace(":", "_").replace(" ", "_")
except Exception as e:
self.logger.bind(tag=TAG).error(f"获取设备型号失败: {e}")
@@ -159,20 +288,61 @@ class OTAHandler(BaseHandler):
token = self.auth.generate_token(client_id, device_id)
else:
token = self.auth.generate_token(client_id, device_id)
+ # NOTE: use websocket_port here
return_json["websocket"] = {
- "url": self._get_websocket_url(local_ip, port),
+ "url": self._get_websocket_url(local_ip, websocket_port),
"token": token,
}
self.logger.bind(tag=TAG).info(
f"未配置MQTT网关,为设备 {device_id} 下发WebSocket配置"
)
- self.logger.bind(tag=TAG).info(f"{return_json}")
+
+ # Now check firmware files for updates
+ try:
+ self._refresh_bin_cache_if_needed()
+ files_by_model = self._bin_cache.get("files_by_model", {})
+ candidates = files_by_model.get(device_model, [])
+
+ self.logger.bind(tag=TAG).info(
+ f"查找型号 {device_model} 的固件,找到 {len(candidates)} 个候选"
+ )
+
+ chosen_url = ""
+ chosen_version = device_version
+
+ # candidates are sorted descending by version
+ for ver, fname in candidates:
+ if _is_higher_version(ver, device_version):
+ # build download url (only allow download via our download endpoint)
+ chosen_version = ver
+ # Use get_vision_url to get the base URL and replace the path
+ vision_url = get_vision_url(self.config)
+ # Replace the path from "/mcp/vision/explain" to "/xiaozhi/ota/download/{fname}"
+ chosen_url = vision_url.replace(
+ "/mcp/vision/explain", f"/xiaozhi/ota/download/{fname}"
+ )
+ break
+
+ if chosen_url:
+ return_json["firmware"]["version"] = chosen_version
+ return_json["firmware"]["url"] = chosen_url
+ self.logger.bind(tag=TAG).info(
+ f"为设备 {device_id} 下发固件 {chosen_version} [如果地址前缀有误,请检查配置文件中的server.vision_explain]-> {chosen_url} "
+ )
+ else:
+ self.logger.bind(tag=TAG).info(
+ f"设备 {device_id} 固件已是最新: {device_version}"
+ )
+
+ except Exception as e:
+ self.logger.bind(tag=TAG).error(f"检查固件版本时出错: {e}")
response = web.Response(
text=json.dumps(return_json, separators=(",", ":")),
content_type="application/json",
)
except Exception as e:
+ self.logger.bind(tag=TAG).error(f"OTA POST处理异常: {e}")
return_json = {"success": False, "message": "request error."}
response = web.Response(
text=json.dumps(return_json, separators=(",", ":")),
@@ -187,8 +357,9 @@ class OTAHandler(BaseHandler):
try:
server_config = self.config["server"]
local_ip = get_local_ip()
- port = int(server_config.get("port", 8000))
- websocket_url = self._get_websocket_url(local_ip, port)
+ # use websocket port for websocket URL
+ websocket_port = int(server_config.get("port", 8000))
+ websocket_url = self._get_websocket_url(local_ip, websocket_port)
message = f"OTA接口运行正常,向设备发送的websocket地址是:{websocket_url}"
response = web.Response(text=message, content_type="text/plain")
except Exception as e:
@@ -197,3 +368,48 @@ class OTAHandler(BaseHandler):
finally:
self._add_cors_headers(response)
return response
+
+ async def handle_download(self, request):
+ """
+ 下载固件接口
+ URL: /xiaozhi/ota/download/{filename}
+ - 只允许下载 data/bin 目录下的 .bin 文件
+ - filename 必须是 basename 且匹配安全的模式
+ """
+ try:
+ fname = request.match_info.get("filename", "")
+ if not fname:
+ raise web.HTTPBadRequest(text="filename required")
+
+ # sanitize
+ fname = _safe_basename(fname)
+ # pattern: allow letters, numbers, dot, underscore, dash
+ if not re.match(r"^[A-Za-z0-9\.\-_]+\.bin$", fname):
+ raise web.HTTPBadRequest(text="invalid filename")
+
+ file_path = os.path.join(self.bin_dir, fname)
+ # ensure realpath is under bin_dir
+ file_real = os.path.realpath(file_path)
+ bin_dir_real = os.path.realpath(self.bin_dir)
+ if (
+ not file_real.startswith(bin_dir_real + os.sep)
+ and file_real != bin_dir_real
+ ):
+ raise web.HTTPForbidden(text="forbidden")
+
+ if not os.path.isfile(file_real):
+ raise web.HTTPNotFound(text="file not found")
+
+ # use FileResponse to stream file
+ resp = web.FileResponse(path=file_real)
+ except web.HTTPError as e:
+ resp = e
+ except Exception as e:
+ self.logger.bind(tag=TAG).error(f"固件下载异常: {e}")
+ resp = web.Response(text="download error", status=500)
+ finally:
+ try:
+ self._add_cors_headers(resp)
+ except Exception:
+ pass
+ return resp
diff --git a/main/xiaozhi-server/core/api/vision_handler.py b/main/xiaozhi-server/core/api/vision_handler.py
index a4817a84..28f48753 100644
--- a/main/xiaozhi-server/core/api/vision_handler.py
+++ b/main/xiaozhi-server/core/api/vision_handler.py
@@ -2,6 +2,7 @@ import json
import copy
from aiohttp import web
from config.logger import setup_logging
+from core.api.base_handler import BaseHandler
from core.utils.util import get_vision_url, is_valid_image_file
from core.utils.vllm import create_instance
from config.config_loader import get_private_config_from_api
@@ -16,10 +17,9 @@ TAG = __name__
MAX_FILE_SIZE = 5 * 1024 * 1024
-class VisionHandler:
+class VisionHandler(BaseHandler):
def __init__(self, config: dict):
- self.config = config
- self.logger = setup_logging()
+ super().__init__(config)
# 初始化认证工具
self.auth = AuthToken(config["server"]["auth_key"])
@@ -172,11 +172,3 @@ class VisionHandler:
finally:
self._add_cors_headers(response)
return response
-
- def _add_cors_headers(self, response):
- """添加CORS头信息"""
- response.headers["Access-Control-Allow-Headers"] = (
- "client-id, content-type, device-id"
- )
- response.headers["Access-Control-Allow-Credentials"] = "true"
- response.headers["Access-Control-Allow-Origin"] = "*"
diff --git a/main/xiaozhi-server/core/http_server.py b/main/xiaozhi-server/core/http_server.py
index edbdf1fe..feb96f3b 100644
--- a/main/xiaozhi-server/core/http_server.py
+++ b/main/xiaozhi-server/core/http_server.py
@@ -33,38 +33,60 @@ class SimpleHttpServer:
return f"ws://{local_ip}:{port}/xiaozhi/v1/"
async def start(self):
- server_config = self.config["server"]
- read_config_from_api = self.config.get("read_config_from_api", False)
- host = server_config.get("ip", "0.0.0.0")
- port = int(server_config.get("http_port", 8003))
+ try:
+ server_config = self.config["server"]
+ read_config_from_api = self.config.get("read_config_from_api", False)
+ host = server_config.get("ip", "0.0.0.0")
+ port = int(server_config.get("http_port", 8003))
- if port:
- app = web.Application()
+ if port:
+ app = web.Application()
- if not read_config_from_api:
- # 如果没有开启智控台,只是单模块运行,就需要再添加简单OTA接口,用于下发websocket接口
+ if not read_config_from_api:
+ # 如果没有开启智控台,只是单模块运行,就需要再添加简单OTA接口,用于下发websocket接口
+ app.add_routes(
+ [
+ web.get("/xiaozhi/ota/", self.ota_handler.handle_get),
+ web.post("/xiaozhi/ota/", self.ota_handler.handle_post),
+ web.options(
+ "/xiaozhi/ota/", self.ota_handler.handle_options
+ ),
+ # 下载接口,仅提供 data/bin/*.bin 下载
+ web.get(
+ "/xiaozhi/ota/download/{filename}",
+ self.ota_handler.handle_download,
+ ),
+ web.options(
+ "/xiaozhi/ota/download/{filename}",
+ self.ota_handler.handle_options,
+ ),
+ ]
+ )
+ # 添加路由
app.add_routes(
[
- web.get("/xiaozhi/ota/", self.ota_handler.handle_get),
- web.post("/xiaozhi/ota/", self.ota_handler.handle_post),
- web.options("/xiaozhi/ota/", self.ota_handler.handle_post),
+ web.get("/mcp/vision/explain", self.vision_handler.handle_get),
+ web.post(
+ "/mcp/vision/explain", self.vision_handler.handle_post
+ ),
+ web.options(
+ "/mcp/vision/explain", self.vision_handler.handle_options
+ ),
]
)
- # 添加路由
- app.add_routes(
- [
- web.get("/mcp/vision/explain", self.vision_handler.handle_get),
- web.post("/mcp/vision/explain", self.vision_handler.handle_post),
- web.options("/mcp/vision/explain", self.vision_handler.handle_post),
- ]
- )
- # 运行服务
- runner = web.AppRunner(app)
- await runner.setup()
- site = web.TCPSite(runner, host, port)
- await site.start()
+ # 运行服务
+ runner = web.AppRunner(app)
+ await runner.setup()
+ site = web.TCPSite(runner, host, port)
+ await site.start()
- # 保持服务运行
- while True:
- await asyncio.sleep(3600) # 每隔 1 小时检查一次
+ # 保持服务运行
+ while True:
+ await asyncio.sleep(3600) # 每隔 1 小时检查一次
+ except Exception as e:
+ self.logger.bind(tag=TAG).error(f"HTTP服务器启动失败: {e}")
+ import traceback
+
+ self.logger.bind(tag=TAG).error(f"错误堆栈: {traceback.format_exc()}")
+ raise