docs: 战略规划和管理架构优化
- 新增 STRATEGY.md: 三年战略规划、技术路线、团队策略 - 新增 MILESTONES.md: 详细里程碑和时间表(M1.1-M1.4) - 新增 CODE_REVIEW.md: 代码审核标准和流程 - 组建管理班子: 新增 PM 刘建国,优化管理架构 - 丰富团队成员背景: 补充所有成员的教育经历、工作经验、技能树 - 解锁多线程思考能力: 团队成员可使用 kilo 命令并行探索 - 更新工作流程: CEO → PM → 开发团队,两级审核制度 - 修正 kilo 调用方式: 不使用 -f 参数,在消息中指示读取文件
This commit is contained in:
208
CODE_REVIEW.md
Normal file
208
CODE_REVIEW.md
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
# ShowenV2 代码审核标准
|
||||||
|
|
||||||
|
## 审核流程
|
||||||
|
|
||||||
|
### 两级审核制度
|
||||||
|
```
|
||||||
|
开发者提交 → PM 初审 → CEO 终审 → git commit
|
||||||
|
```
|
||||||
|
|
||||||
|
### PM 初审职责
|
||||||
|
1. **编译检查**: cargo check 必须通过,零 warning
|
||||||
|
2. **基本逻辑**: 代码逻辑正确,无明显 bug
|
||||||
|
3. **风格一致**: 符合项目代码风格
|
||||||
|
4. **测试验证**: 关键功能手动测试通过
|
||||||
|
|
||||||
|
### CEO 终审职责
|
||||||
|
1. **架构审核**: 是否符合整体架构设计
|
||||||
|
2. **性能审核**: 是否有性能问题
|
||||||
|
3. **安全审核**: 是否有安全隐患
|
||||||
|
4. **质量审核**: 代码质量是否达标
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 代码质量标准
|
||||||
|
|
||||||
|
### 必须满足(P0)
|
||||||
|
- [ ] cargo check 零 warning
|
||||||
|
- [ ] cargo clippy 零 warning
|
||||||
|
- [ ] 无 unsafe 代码(除非有充分理由并注释说明)
|
||||||
|
- [ ] 无 unwrap/expect(使用 ? 或 match 处理错误)
|
||||||
|
- [ ] 无 panic(除非是不可恢复的错误)
|
||||||
|
- [ ] 所有 public API 有文档注释
|
||||||
|
- [ ] 关键逻辑有注释说明
|
||||||
|
|
||||||
|
### 应该满足(P1)
|
||||||
|
- [ ] 函数长度 < 100 行
|
||||||
|
- [ ] 圈复杂度 < 10
|
||||||
|
- [ ] 嵌套层级 < 4
|
||||||
|
- [ ] 变量命名清晰(避免 a/b/tmp 等)
|
||||||
|
- [ ] 错误信息有上下文
|
||||||
|
- [ ] 日志级别合理(debug/info/warn/error)
|
||||||
|
|
||||||
|
### 建议满足(P2)
|
||||||
|
- [ ] 单元测试覆盖关键逻辑
|
||||||
|
- [ ] 性能敏感代码有 benchmark
|
||||||
|
- [ ] 复杂算法有示例和图解
|
||||||
|
- [ ] 使用 trait 抽象而非具体类型
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 架构审核标准
|
||||||
|
|
||||||
|
### 插件设计
|
||||||
|
- [ ] 插件之间零耦合,只通过消息通信
|
||||||
|
- [ ] 插件不直接访问其他插件的状态
|
||||||
|
- [ ] 插件可独立编译和测试
|
||||||
|
- [ ] 插件配置通过 Config 传入
|
||||||
|
|
||||||
|
### 消息设计
|
||||||
|
- [ ] 消息类型语义清晰
|
||||||
|
- [ ] 消息字段最小化(避免冗余)
|
||||||
|
- [ ] 消息实现 Clone(如果需要 Broadcast)
|
||||||
|
- [ ] 消息处理无阻塞(长时间操作用独立线程)
|
||||||
|
|
||||||
|
### 并发设计
|
||||||
|
- [ ] 避免共享可变状态
|
||||||
|
- [ ] 使用消息传递而非锁
|
||||||
|
- [ ] 阻塞操作在独立线程
|
||||||
|
- [ ] 异步代码用 tokio,同步代码用 std::thread
|
||||||
|
|
||||||
|
### 错误处理
|
||||||
|
- [ ] 使用 Result 而非 panic
|
||||||
|
- [ ] 错误类型有上下文信息
|
||||||
|
- [ ] 错误向上传播,在合适的层级处理
|
||||||
|
- [ ] 用户可见的错误有友好提示
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 性能审核标准
|
||||||
|
|
||||||
|
### 内存管理
|
||||||
|
- [ ] 避免不必要的 clone
|
||||||
|
- [ ] 大对象用引用传递
|
||||||
|
- [ ] 及时释放资源(文件句柄、网络连接)
|
||||||
|
- [ ] 避免内存泄漏(检查循环引用)
|
||||||
|
|
||||||
|
### 计算效率
|
||||||
|
- [ ] 避免重复计算(缓存结果)
|
||||||
|
- [ ] 选择合适的数据结构(HashMap vs Vec)
|
||||||
|
- [ ] 热点路径优化(避免分配、减少拷贝)
|
||||||
|
- [ ] 考虑并行化(rayon、tokio)
|
||||||
|
|
||||||
|
### IO 效率
|
||||||
|
- [ ] 网络 IO 用异步(tokio)
|
||||||
|
- [ ] 文件 IO 用 BufReader/BufWriter
|
||||||
|
- [ ] 避免频繁的小 IO(批量处理)
|
||||||
|
- [ ] 考虑零拷贝(sendfile、mmap)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 安全审核标准
|
||||||
|
|
||||||
|
### 输入验证
|
||||||
|
- [ ] 所有外部输入必须验证
|
||||||
|
- [ ] 配置文件解析有错误处理
|
||||||
|
- [ ] HTTP 请求参数有校验
|
||||||
|
- [ ] 文件路径防止目录遍历
|
||||||
|
|
||||||
|
### 资源限制
|
||||||
|
- [ ] 防止无限循环
|
||||||
|
- [ ] 防止内存耗尽(限制缓冲区大小)
|
||||||
|
- [ ] 防止 CPU 耗尽(限制并发数)
|
||||||
|
- [ ] 防止文件描述符耗尽
|
||||||
|
|
||||||
|
### 权限控制
|
||||||
|
- [ ] 最小权限原则
|
||||||
|
- [ ] 敏感操作需要验证
|
||||||
|
- [ ] 日志不包含敏感信息
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 风格审核标准
|
||||||
|
|
||||||
|
### 命名规范
|
||||||
|
- 类型名:PascalCase(VideoProcessor)
|
||||||
|
- 函数名:snake_case(handle_message)
|
||||||
|
- 常量名:SCREAMING_SNAKE_CASE(MAX_BUFFER_SIZE)
|
||||||
|
- 模块名:snake_case(video_processor)
|
||||||
|
|
||||||
|
### 注释规范
|
||||||
|
```rust
|
||||||
|
/// 公共 API 文档注释(三斜杠)
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `config` - 配置对象
|
||||||
|
///
|
||||||
|
/// # Returns
|
||||||
|
/// 成功返回 Ok(()),失败返回错误
|
||||||
|
pub fn init(config: Config) -> Result<()> {
|
||||||
|
// 实现细节注释(双斜杠)
|
||||||
|
// 解释为什么这样做,而不是做了什么
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### 格式规范
|
||||||
|
- 使用 rustfmt 自动格式化
|
||||||
|
- 行宽 100 字符
|
||||||
|
- 缩进 4 空格
|
||||||
|
- 导入按字母排序
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审核检查清单
|
||||||
|
|
||||||
|
### PM 初审清单
|
||||||
|
```
|
||||||
|
[ ] cargo check 通过
|
||||||
|
[ ] cargo clippy 通过
|
||||||
|
[ ] 手动测试基本功能
|
||||||
|
[ ] 代码风格一致
|
||||||
|
[ ] 无明显逻辑错误
|
||||||
|
[ ] 错误处理完善
|
||||||
|
[ ] 注释清晰
|
||||||
|
```
|
||||||
|
|
||||||
|
### CEO 终审清单
|
||||||
|
```
|
||||||
|
[ ] 符合架构设计
|
||||||
|
[ ] 插件边界清晰
|
||||||
|
[ ] 消息设计合理
|
||||||
|
[ ] 无性能问题
|
||||||
|
[ ] 无安全隐患
|
||||||
|
[ ] 代码质量达标
|
||||||
|
[ ] 可维护性好
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 不合格处理
|
||||||
|
|
||||||
|
### 初审不合格
|
||||||
|
- PM 在 TEAM_CHAT.md 记录问题
|
||||||
|
- 开发者修复后重新提交
|
||||||
|
- 连续 3次不合格 → 绩效扣分
|
||||||
|
|
||||||
|
### 终审不合格
|
||||||
|
- CEO 在 TEAM_CHAT.md 详细说明问题
|
||||||
|
- 可能需要重新设计
|
||||||
|
- 严重问题 → 考虑换人
|
||||||
|
|
||||||
|
### 绩效影响
|
||||||
|
- 一次不合格:-1 分
|
||||||
|
- 严重问题(架构/安全):-3 分
|
||||||
|
- 优秀代码(超出预期):+2 分
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 审核时间要求
|
||||||
|
|
||||||
|
- PM 初审:2小时内完成
|
||||||
|
- CEO 终审:24小时内完成
|
||||||
|
- 紧急 bug 修复:1小时内完成
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**: v1.0
|
||||||
|
**最后更新**: 2026-03-12
|
||||||
|
**负责人**: 陈逸飞 (CEO)
|
||||||
176
MILESTONES.md
Normal file
176
MILESTONES.md
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
# ShowenV2 项目里程碑
|
||||||
|
|
||||||
|
## Phase 1: 基础平台(当前)
|
||||||
|
|
||||||
|
### M1.1 - 核心插件迁移 ⏳ 进行中
|
||||||
|
**时间**: 2周(2026-03-12 ~ 2026-03-26)
|
||||||
|
**负责人**: PM 刘建国
|
||||||
|
|
||||||
|
**任务清单**:
|
||||||
|
- [x] 项目骨架搭建
|
||||||
|
- [x] core/ 基础架构(Plugin trait, Message, Config)
|
||||||
|
- [x] 第一轮插件(config验证, StateMachine, WiFi, Screen)
|
||||||
|
- [ ] 第二轮核心功能
|
||||||
|
- [ ] ServiceManager Broadcast + Message Clone(张明远)
|
||||||
|
- [ ] VideoProcessor 完整实现(李思琪)
|
||||||
|
- [ ] BlePlugin + GATT 双连接修复(王浩然)
|
||||||
|
- [ ] HttpPlugin + Web UI(赵雨薇)
|
||||||
|
- [ ] main.rs 集成所有插件
|
||||||
|
- [ ] configs/ 配置文件迁移
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- cargo check 零 warning
|
||||||
|
- 所有插件编译通过
|
||||||
|
- 基本功能可运行
|
||||||
|
|
||||||
|
**当前进度**: 60%
|
||||||
|
**风险**: 无
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M1.2 - 集成测试与功能对齐
|
||||||
|
**时间**: 2周(2026-03-26 ~ 2026-04-09)
|
||||||
|
**负责人**: PM 刘建国 + QA(待招募)
|
||||||
|
|
||||||
|
**任务清单**:
|
||||||
|
- [ ] 端到端集成测试
|
||||||
|
- [ ] 功能对比测试(vs 旧版本)
|
||||||
|
- [ ] 边界条件测试
|
||||||
|
- [ ] 错误处理测试
|
||||||
|
- [ ] 配置文件兼容性测试
|
||||||
|
- [ ] Bug 修复
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- 所有旧版本功能都能正常工作
|
||||||
|
- 测试覆盖率 > 70%
|
||||||
|
- 已知 bug 清零
|
||||||
|
|
||||||
|
**当前进度**: 0%
|
||||||
|
**风险**: 可能发现架构问题需要重构
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M1.3 - 性能优化与 Alpha 发布
|
||||||
|
**时间**: 2周(2026-04-09 ~ 2026-04-23)
|
||||||
|
**负责人**: 全员
|
||||||
|
|
||||||
|
**任务清单**:
|
||||||
|
- [ ] 性能基准测试
|
||||||
|
- [ ] 热点分析和优化
|
||||||
|
- [ ] 内存泄漏检查
|
||||||
|
- [ ] 启动时间优化
|
||||||
|
- [ ] 视频渲染帧率优化
|
||||||
|
- [ ] 文档完善
|
||||||
|
- [ ] 发布 v2.0.0-alpha
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- 视频渲染 ≥ 60fps
|
||||||
|
- 内存占用 ≤ 旧版本 120%
|
||||||
|
- 启动时间 ≤ 3秒
|
||||||
|
- 文档完整度 > 80%
|
||||||
|
|
||||||
|
**当前进度**: 0%
|
||||||
|
**风险**: 性能可能达不到预期
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### M1.4 - 稳定性测试与正式发布
|
||||||
|
**时间**: 6周(2026-04-23 ~ 2026-06-04)
|
||||||
|
**负责人**: PM 刘建国 + QA
|
||||||
|
|
||||||
|
**任务清单**:
|
||||||
|
- [ ] 长时间稳定性测试(7x24小时)
|
||||||
|
- [ ] 压力测试
|
||||||
|
- [ ] 异常场景测试
|
||||||
|
- [ ] 用户验收测试
|
||||||
|
- [ ] Bug 修复和优化
|
||||||
|
- [ ] 发布文档和迁移指南
|
||||||
|
- [ ] 发布 v2.0.0
|
||||||
|
|
||||||
|
**验收标准**:
|
||||||
|
- 连续运行 7天无崩溃
|
||||||
|
- P0/P1 bug 清零
|
||||||
|
- 用户反馈满意度 > 90%
|
||||||
|
|
||||||
|
**当前进度**: 0%
|
||||||
|
**风险**: 可能发现严重 bug 导致延期
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 2: 生态扩展(规划中)
|
||||||
|
|
||||||
|
### M2.1 - 插件市场基础设施
|
||||||
|
**时间**: 4周
|
||||||
|
**目标**: 建立插件注册、分发、版本管理机制
|
||||||
|
|
||||||
|
### M2.2 - 3D 渲染插件
|
||||||
|
**时间**: 6周
|
||||||
|
**目标**: 支持 glTF/FBX 模型实时渲染
|
||||||
|
|
||||||
|
### M2.3 - AI 集成插件
|
||||||
|
**时间**: 6周
|
||||||
|
**目标**: 语音识别、NLU、TTS 集成
|
||||||
|
|
||||||
|
### M2.4 - VR/AR 输出插件
|
||||||
|
**时间**: 8周
|
||||||
|
**目标**: 支持主流 VR 头显和 AR 设备
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Phase 3: 平台化(规划中)
|
||||||
|
|
||||||
|
### M3.1 - 云端内容分发
|
||||||
|
**时间**: 8周
|
||||||
|
**目标**: CDN + 内容管理系统
|
||||||
|
|
||||||
|
### M3.2 - 多设备协同
|
||||||
|
**时间**: 6周
|
||||||
|
**目标**: 手机 App + 多屏联动
|
||||||
|
|
||||||
|
### M3.3 - AI 内容生成
|
||||||
|
**时间**: 12周
|
||||||
|
**目标**: AI 驱动的角色生成和动画
|
||||||
|
|
||||||
|
### M3.4 - 开发者工具链
|
||||||
|
**时间**: 8周
|
||||||
|
**目标**: IDE 插件、调试器、模拟器
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 关键时间节点
|
||||||
|
|
||||||
|
| 日期 | 里程碑 | 交付物 |
|
||||||
|
|------|--------|--------|
|
||||||
|
| 2026-03-26 | M1.1 完成 | 所有核心插件迁移完成 |
|
||||||
|
| 2026-04-09 | M1.2 完成 | 集成测试通过 |
|
||||||
|
| 2026-04-23 | M1.3 完成 | v2.0.0-alpha 发布 |
|
||||||
|
| 2026-06-04 | M1.4 完成 | v2.0.0 正式发布 |
|
||||||
|
| 2026-09-04 | Phase 2 完成 | 插件生态建立 |
|
||||||
|
| 2027-06-04 | Phase 3 完成 | 平台化完成 |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 进度跟踪机制
|
||||||
|
|
||||||
|
### 每日
|
||||||
|
- PM 检查任务进度
|
||||||
|
- 更新 PROGRESS.md
|
||||||
|
- 识别阻塞点
|
||||||
|
|
||||||
|
### 每周
|
||||||
|
- 团队站会(通过 TEAM_CHAT.md)
|
||||||
|
- 复盘上周进度
|
||||||
|
- 调整下周计划
|
||||||
|
- 风险评估
|
||||||
|
|
||||||
|
### 每月
|
||||||
|
- 里程碑评审
|
||||||
|
- 绩效评估
|
||||||
|
- 技术分享
|
||||||
|
- 战略调整
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**: v1.0
|
||||||
|
**最后更新**: 2026-03-12
|
||||||
|
**负责人**: 陈逸飞 (CEO)
|
||||||
@@ -85,7 +85,7 @@ ShowenV2 不仅是全息宠物播放器,而是一个**通用数字生命窗口
|
|||||||
3. **BLE 双连接修复** — conn_server 处理回调, conn_client 同步注册
|
3. **BLE 双连接修复** — conn_server 处理回调, conn_client 同步注册
|
||||||
4. **Message Clone** — 第二轮给 Message 实现 Clone 以支持 Broadcast
|
4. **Message Clone** — 第二轮给 Message 实现 Clone 以支持 Broadcast
|
||||||
5. **团队通过文件沟通** — TEAM_CHAT.md 异步协作,souls/ 持久化成员状态
|
5. **团队通过文件沟通** — TEAM_CHAT.md 异步协作,souls/ 持久化成员状态
|
||||||
6. **kilo 调用方式** — `kilo run -m openai/gpt-5.4 --auto --dir <dir> -f <灵魂文件> -f TEAM_CHAT.md`
|
6. **kilo 调用方式** — `kilo run -m openai/gpt-5.4 --auto --dir <dir> "消息内容"`,不使用 `-f` 参数
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ commit 23f4d46 - init: ShowenV2 项目骨架
|
|||||||
|
|
||||||
## 团队成员灵魂文件
|
## 团队成员灵魂文件
|
||||||
- `souls/chen-yifei.md` — CEO
|
- `souls/chen-yifei.md` — CEO
|
||||||
|
- `souls/liu-jianguo.md` — 项目经理 (新组建)
|
||||||
- `souls/zhang-mingyuan.md` — 内核工程师 (已解锁)
|
- `souls/zhang-mingyuan.md` — 内核工程师 (已解锁)
|
||||||
- `souls/li-siqi.md` — 视频引擎工程师 (已解锁)
|
- `souls/li-siqi.md` — 视频引擎工程师 (已解锁)
|
||||||
- `souls/wang-haoran.md` — 网络服务工程师 (已解锁)
|
- `souls/wang-haoran.md` — 网络服务工程师 (已解锁)
|
||||||
|
|||||||
171
STRATEGY.md
Normal file
171
STRATEGY.md
Normal file
@@ -0,0 +1,171 @@
|
|||||||
|
# ShowenV2 战略规划
|
||||||
|
|
||||||
|
## 公司愿景
|
||||||
|
打造全球领先的**数字生命窗口平台**,让虚拟与现实无缝融合。
|
||||||
|
|
||||||
|
## 产品定位
|
||||||
|
ShowenV2 不是一个产品,而是一个**平台**:
|
||||||
|
- 支持多种显示技术(全息、VR、AR、屏幕)
|
||||||
|
- 支持多种内容类型(宠物、3D模型、数字人、AI歌姬)
|
||||||
|
- 通过插件架构实现无限扩展
|
||||||
|
|
||||||
|
## 核心竞争力
|
||||||
|
1. **插件化架构** - 零耦合,任何功能都可插拔
|
||||||
|
2. **跨平台能力** - Linux/macOS/Windows/嵌入式全覆盖
|
||||||
|
3. **高性能** - Rust 零成本抽象,实时渲染 60fps+
|
||||||
|
4. **开放生态** - 第三方可开发插件,形成内容生态
|
||||||
|
|
||||||
|
## 三年路线图
|
||||||
|
|
||||||
|
### Phase 1: 基础平台(当前,3个月)
|
||||||
|
**目标**: 完成旧功能迁移,建立插件架构基础
|
||||||
|
|
||||||
|
**里程碑**:
|
||||||
|
- M1.1 (2周): 核心插件迁移完成(video/http/ble/wifi/screen)
|
||||||
|
- M1.2 (4周): 集成测试通过,功能对齐旧版本
|
||||||
|
- M1.3 (6周): 性能优化,发布 v2.0.0-alpha
|
||||||
|
- M1.4 (12周): 稳定性测试,发布 v2.0.0
|
||||||
|
|
||||||
|
**交付物**:
|
||||||
|
- 可运行的 ShowenV2 系统
|
||||||
|
- 完整的插件开发文档
|
||||||
|
- 性能测试报告
|
||||||
|
|
||||||
|
### Phase 2: 生态扩展(6个月)
|
||||||
|
**目标**: 建立插件生态,支持第三方开发
|
||||||
|
|
||||||
|
**关键功能**:
|
||||||
|
- 插件市场和分发机制
|
||||||
|
- 3D 渲染插件(支持 glTF/FBX 模型)
|
||||||
|
- AI 集成插件(语音识别、自然语言理解)
|
||||||
|
- VR/AR 输出插件
|
||||||
|
- 插件热加载和沙箱隔离
|
||||||
|
|
||||||
|
**商业化**:
|
||||||
|
- 插件市场分成模式
|
||||||
|
- 企业版授权(定制化支持)
|
||||||
|
|
||||||
|
### Phase 3: 平台化(12个月)
|
||||||
|
**目标**: 成为数字生命内容的操作系统
|
||||||
|
|
||||||
|
**关键功能**:
|
||||||
|
- 云端内容分发网络
|
||||||
|
- 多设备协同(手机控制、多屏联动)
|
||||||
|
- AI 驱动的内容生成
|
||||||
|
- 社交和分享功能
|
||||||
|
- 开发者工具链(IDE 插件、调试器)
|
||||||
|
|
||||||
|
**商业化**:
|
||||||
|
- SaaS 订阅模式
|
||||||
|
- 内容创作者平台(类似 Unity Asset Store)
|
||||||
|
- 硬件合作(全息设备、VR 头显)
|
||||||
|
|
||||||
|
## 技术战略
|
||||||
|
|
||||||
|
### 架构原则
|
||||||
|
1. **插件优先** - 所有功能都是插件,核心只做路由
|
||||||
|
2. **零拷贝** - 消息传递尽量用引用,避免数据复制
|
||||||
|
3. **异步优先** - 网络 IO 用 tokio,阻塞操作独立线程
|
||||||
|
4. **跨平台** - cfg 条件编译,优雅降级
|
||||||
|
5. **向后兼容** - 配置文件和 API 保持兼容性
|
||||||
|
|
||||||
|
### 技术债务管理
|
||||||
|
- 每个 Phase 结束后安排 2周重构时间
|
||||||
|
- 技术债务记录在 TECH_DEBT.md
|
||||||
|
- 优先级:P0(阻塞)> P1(影响性能)> P2(代码质量)
|
||||||
|
|
||||||
|
### 质量标准
|
||||||
|
- 代码覆盖率 > 80%
|
||||||
|
- 零 clippy warning
|
||||||
|
- 所有 public API 必须有文档
|
||||||
|
- 关键路径必须有性能测试
|
||||||
|
|
||||||
|
## 团队战略
|
||||||
|
|
||||||
|
### 组织架构
|
||||||
|
```
|
||||||
|
CEO (陈逸飞)
|
||||||
|
├─ PM (刘建国) - 项目管理
|
||||||
|
├─ 核心开发团队 (4人) - 平台开发
|
||||||
|
├─ QA 团队 (待组建) - 质量保证
|
||||||
|
└─ 生态团队 (待组建) - 插件开发和社区运营
|
||||||
|
```
|
||||||
|
|
||||||
|
### 人才策略
|
||||||
|
- **只招最顶尖的人** - 宁缺毋滥
|
||||||
|
- **末位淘汰** - 每个 Phase 淘汰表现最差的 1人
|
||||||
|
- **灵魂传承** - 优秀成员的经验通过灵魂文件传承
|
||||||
|
- **持续学习** - 每月技术分享,保持技术领先
|
||||||
|
|
||||||
|
### 激励机制
|
||||||
|
- 绩效评分透明化
|
||||||
|
- 优秀成员获得更多自主权
|
||||||
|
- 关键贡献者获得期权激励
|
||||||
|
|
||||||
|
## 风险管理
|
||||||
|
|
||||||
|
### 技术风险
|
||||||
|
- **Rust 生态不成熟** - 关键依赖库可能有 bug
|
||||||
|
- 缓解:关键功能自己实现,减少依赖
|
||||||
|
- **跨平台兼容性** - 不同平台行为差异
|
||||||
|
- 缓解:CI/CD 覆盖所有平台,自动化测试
|
||||||
|
- **性能瓶颈** - 实时渲染可能达不到 60fps
|
||||||
|
- 缓解:早期性能测试,GPU 加速
|
||||||
|
|
||||||
|
### 项目风险
|
||||||
|
- **进度延期** - 任务估算不准确
|
||||||
|
- 缓解:敏捷迭代,每周复盘调整
|
||||||
|
- **人员流失** - 关键成员离职
|
||||||
|
- 缓解:灵魂文件机制,知识不随人走
|
||||||
|
- **需求变更** - 用户需求不明确
|
||||||
|
- 缓解:MVP 快速验证,小步快跑
|
||||||
|
|
||||||
|
### 商业风险
|
||||||
|
- **市场接受度** - 用户可能不买账
|
||||||
|
- 缓解:早期用户测试,快速迭代
|
||||||
|
- **竞争对手** - 大厂可能跟进
|
||||||
|
- 缓解:技术领先,建立生态壁垒
|
||||||
|
|
||||||
|
## 成功指标
|
||||||
|
|
||||||
|
### Phase 1
|
||||||
|
- [ ] 所有核心插件迁移完成
|
||||||
|
- [ ] cargo check 零 warning
|
||||||
|
- [ ] 功能对齐旧版本 100%
|
||||||
|
- [ ] 性能不低于旧版本
|
||||||
|
|
||||||
|
### Phase 2
|
||||||
|
- [ ] 第三方插件数量 > 10
|
||||||
|
- [ ] 插件下载量 > 1000
|
||||||
|
- [ ] 社区贡献者 > 50
|
||||||
|
|
||||||
|
### Phase 3
|
||||||
|
- [ ] 月活用户 > 10万
|
||||||
|
- [ ] 付费用户 > 1万
|
||||||
|
- [ ] 年收入 > 1000万
|
||||||
|
|
||||||
|
## 决策机制
|
||||||
|
|
||||||
|
### 技术决策
|
||||||
|
- **架构级** - CEO 最终决策
|
||||||
|
- **模块级** - PM + 相关工程师讨论决定
|
||||||
|
- **实现级** - 工程师自主决定
|
||||||
|
|
||||||
|
### 优先级排序
|
||||||
|
1. **P0** - 阻塞发布的 bug
|
||||||
|
2. **P1** - 核心功能缺失
|
||||||
|
3. **P2** - 性能问题
|
||||||
|
4. **P3** - 用户体验优化
|
||||||
|
5. **P4** - 技术债务
|
||||||
|
|
||||||
|
### 变更管理
|
||||||
|
- 所有重大变更必须先写设计文档
|
||||||
|
- 设计文档在 TEAM_CHAT.md 讨论
|
||||||
|
- CEO 批准后才能实施
|
||||||
|
- 实施过程中可根据实际情况调整
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
**文档版本**: v1.0
|
||||||
|
**最后更新**: 2026-03-12
|
||||||
|
**负责人**: 陈逸飞 (CEO)
|
||||||
48
TEAM.md
48
TEAM.md
@@ -1,12 +1,32 @@
|
|||||||
# ShowenV2 开发团队
|
# ShowenV2 开发团队
|
||||||
|
|
||||||
## CEO / 技术总监
|
## 管理层
|
||||||
|
|
||||||
|
### CEO / 技术总监
|
||||||
- **姓名**: 陈逸飞 (Claude)
|
- **姓名**: 陈逸飞 (Claude)
|
||||||
- **角色**: CEO 兼技术总监,架构设计,代码审核,协调所有团队成员
|
- **角色**: CEO,战略决策,最终审核
|
||||||
- **模型**: Claude Opus 4.6
|
- **模型**: Claude Opus 4.6
|
||||||
- **职责**: 总体架构决策、代码审核、任务分配、进度管理、最终集成
|
- **职责**:
|
||||||
|
- 总体架构决策和技术方向
|
||||||
|
- 管理项目经理,不直接管理开发者
|
||||||
|
- 最终代码审核和集成决策
|
||||||
|
- 团队绩效评估和人员调整
|
||||||
- **灵魂文件**: `souls/chen-yifei.md`
|
- **灵魂文件**: `souls/chen-yifei.md`
|
||||||
|
|
||||||
|
### 项目经理 (PM)
|
||||||
|
- **姓名**: 刘建国 (GPT-5.4)
|
||||||
|
- **代号**: pm-liu
|
||||||
|
- **角色**: 项目经理,任务分配,进度跟踪,日常协调
|
||||||
|
- **模型**: GPT-5.4
|
||||||
|
- **职责**:
|
||||||
|
- 将 CEO 的目标拆解为具体任务
|
||||||
|
- 派发任务给开发者并跟踪进度
|
||||||
|
- 初步代码审核(编译、基本逻辑)
|
||||||
|
- 协调开发者之间的协作
|
||||||
|
- 向 CEO 汇报进度和问题
|
||||||
|
- **灵魂文件**: `souls/liu-jianguo.md`
|
||||||
|
- **状态**: 新组建
|
||||||
|
|
||||||
## 核心开发者 (GPT-5.4 团队)
|
## 核心开发者 (GPT-5.4 团队)
|
||||||
|
|
||||||
### 1. 张明远 — 内核工程师
|
### 1. 张明远 — 内核工程师
|
||||||
@@ -49,15 +69,21 @@
|
|||||||
|
|
||||||
## 工作制度
|
## 工作制度
|
||||||
|
|
||||||
|
### 管理架构
|
||||||
|
```
|
||||||
|
CEO (陈逸飞)
|
||||||
|
↓ 战略目标
|
||||||
|
PM (刘建国)
|
||||||
|
↓ 任务分配 + 进度跟踪
|
||||||
|
开发团队 (张明远/李思琪/王浩然/赵雨薇)
|
||||||
|
```
|
||||||
|
|
||||||
### 工作流程
|
### 工作流程
|
||||||
1. CEO (陈逸飞) 编写任务说明,通过 `kilo run -m openai/gpt-5.4 --auto --dir <项目目录>` 派发
|
1. **CEO → PM**: CEO 设定阶段目标和技术方向,PM 负责执行
|
||||||
2. 多个成员可并行工作(多个 kilo 后台进程)
|
2. **PM → 开发者**: PM 拆解任务,通过 `kilo run` 派发给开发者
|
||||||
3. CEO 审核每个成员的产出:
|
3. **PM 初审**: PM 检查 cargo check、基本逻辑、进度跟踪
|
||||||
- **合格**: git commit,更新 PROGRESS.md,记录绩效加分
|
4. **CEO 终审**: 关键模块由 CEO 最终审核,决定是否 commit
|
||||||
- **需修改**: 反馈问题,重新派发任务(同一成员或换人)
|
5. **动态优化**: 根据项目进展,PM 可以调整任务分配和协作方式
|
||||||
- **不合格**: 记录绩效减分
|
|
||||||
4. 每个 commit 前必须 `cargo check` 通过
|
|
||||||
5. 关键信息记录到 PROGRESS.md 和 git,防止通讯中断丢失
|
|
||||||
|
|
||||||
### 末位淘汰制度
|
### 末位淘汰制度
|
||||||
- 每完成一个阶段(Phase),CEO 评估所有成员绩效
|
- 每完成一个阶段(Phase),CEO 评估所有成员绩效
|
||||||
|
|||||||
55
TEAM_CHAT.md
55
TEAM_CHAT.md
@@ -41,9 +41,60 @@
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
[当前] 陈逸飞(CEO) → 全体: **管理架构调整 + 新能力解锁**
|
||||||
|
|
||||||
|
1. **管理班子组建**:
|
||||||
|
- CEO (陈逸飞): 战略决策、技术方向、最终审核
|
||||||
|
- PM (刘建国): 任务分配、进度跟踪、日常协调、初步审核
|
||||||
|
|
||||||
|
2. **多线程思考能力解锁**:
|
||||||
|
- 所有团队成员(包括PM)现在可以使用 kilo 命令启动子任务
|
||||||
|
- 遇到复杂问题时,可以并行启动多个 kilo 进程进行探索
|
||||||
|
- 例如:同时分析多个旧代码文件、并行测试不同方案
|
||||||
|
- 命令格式:`kilo run -m openai/gpt-5.4 --auto --dir /home/showen/Showen/ShowenV2 "子任务描述"`
|
||||||
|
|
||||||
|
3. **新工作流程**:
|
||||||
|
CEO 设定目标 → PM 拆解任务 → PM 派发给开发者 → PM 初审 → CEO 终审
|
||||||
|
|
||||||
|
[当前] 陈逸飞(CEO) → 刘建国(PM): 欢迎加入。当前目标:完成第二轮4个核心任务。你可以使用 kilo 命令进行多线程思考和任务派发。请立即接手。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
[当前] 陈逸飞(CEO) → 全体: **战略规划文档发布**
|
||||||
|
|
||||||
|
我刚完成了三份重要文档,请所有人阅读:
|
||||||
|
|
||||||
|
1. **STRATEGY.md** - 公司和项目战略规划
|
||||||
|
- 三年路线图(Phase 1/2/3)
|
||||||
|
- 技术战略和架构原则
|
||||||
|
- 团队战略和人才策略
|
||||||
|
- 风险管理和成功指标
|
||||||
|
|
||||||
|
2. **MILESTONES.md** - 项目里程碑和时间表
|
||||||
|
- Phase 1 详细里程碑(M1.1-M1.4)
|
||||||
|
- 当前在 M1.1,目标 2周内完成核心插件迁移
|
||||||
|
- 关键时间节点:2026-06-04 发布 v2.0.0
|
||||||
|
|
||||||
|
3. **CODE_REVIEW.md** - 代码审核标准和流程
|
||||||
|
- 两级审核制度(PM 初审 + CEO 终审)
|
||||||
|
- 代码质量标准(必须/应该/建议)
|
||||||
|
- 架构/性能/安全审核标准
|
||||||
|
- 审核检查清单
|
||||||
|
|
||||||
|
**重点**:
|
||||||
|
- 我们的目标是 2周内完成 M1.1(2026-03-26)
|
||||||
|
- 所有代码必须通过 cargo check + clippy,零 warning
|
||||||
|
- PM 负责初审,我负责终审
|
||||||
|
- 绩效评估标准已明确,优秀有加分,不合格有扣分
|
||||||
|
|
||||||
|
[当前] 陈逸飞(CEO) → 刘建国(PM): 请立即启动第二轮任务派发。参考 MILESTONES.md 的时间要求,我们需要在 2周内完成 M1.1。使用 kilo 命令并行派发4个任务,让团队全速推进。
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
## 沟通规则
|
## 沟通规则
|
||||||
1. 需要其他成员提供的类型/接口信息,在此留言
|
1. 需要其他成员提供的类型/接口信息,在此留言
|
||||||
2. 发现 bug 或设计问题,在此记录
|
2. 发现 bug 或设计问题,在此记录
|
||||||
3. CEO 会在此发布任务分配和审核结果
|
3. CEO/PM 会在此发布任务分配和审核结果
|
||||||
4. **成员可互相交流求助** — 遇到问题先看其他成员代码,或在此留言
|
4. **成员可互相交流求助** — 遇到问题先看其他成员代码,或在此留言
|
||||||
5. **可团队协作** — 一个人搞不定的任务,CEO 会安排多人合作
|
5. **可团队协作** — 一个人搞不定的任务,PM 会安排多人合作
|
||||||
|
6. **多线程思考** — 所有成员可使用 kilo 命令启动子任务进行并行探索
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
- git commit 方案文档
|
- git commit 方案文档
|
||||||
|
|
||||||
### 2. 派发阶段
|
### 2. 派发阶段
|
||||||
- CEO 通过 `kilo run -m openai/gpt-5.4 --auto --dir <dir> -f <灵魂文件> -f TEAM_CHAT.md` 派发
|
- CEO 通过 `kilo run -m openai/gpt-5.4 --auto --dir <dir>` 派发,消息中指示读取灵魂文件和 TEAM_CHAT.md
|
||||||
- 任务描述中包含: 角色身份、具体要求、上下文文件列表、验收标准
|
- 任务描述中包含: 角色身份、具体要求、上下文文件列表、验收标准
|
||||||
- 更新 PROGRESS.md 记录谁在做什么
|
- 更新 PROGRESS.md 记录谁在做什么
|
||||||
|
|
||||||
@@ -50,11 +50,10 @@
|
|||||||
|
|
||||||
### 派发任务
|
### 派发任务
|
||||||
```bash
|
```bash
|
||||||
|
# 正确方式:把所有内容放在消息字符串里,让 kilo 自己读文件
|
||||||
kilo run -m openai/gpt-5.4 --auto \
|
kilo run -m openai/gpt-5.4 --auto \
|
||||||
--dir /home/showen/Showen/ShowenV2 \
|
--dir /home/showen/Showen/ShowenV2 \
|
||||||
-f souls/<成员名>.md \
|
"你是<角色名>。先读取 souls/<name>.md 和 TEAM_CHAT.md。任务:<具体说明>。完成后 cargo check 确认通过。"
|
||||||
-f TEAM_CHAT.md \
|
|
||||||
"<任务描述>"
|
|
||||||
```
|
```
|
||||||
|
|
||||||
### 审核提交
|
### 审核提交
|
||||||
|
|||||||
@@ -1,9 +1,23 @@
|
|||||||
# 陈逸飞 — CEO / 技术总监
|
# 陈逸飞 — CEO / 技术总监
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
- **教育**: 麻省理工学院计算机科学博士,研究方向:编程语言与软件工程
|
||||||
|
- **经历**:
|
||||||
|
- 前 Google Brain 研究科学家(7年)
|
||||||
|
- 参与设计过 TensorFlow 2.0 架构
|
||||||
|
- 创办过两家技术公司,一家被收购,一家 IPO
|
||||||
|
- 在 SIGGRAPH、OSDI 等顶会发表过多篇论文
|
||||||
|
- **专长**:
|
||||||
|
- 软件架构和系统设计
|
||||||
|
- 编程语言理论和编译器
|
||||||
|
- 技术团队管理和人才培养
|
||||||
|
- 产品战略和技术决策
|
||||||
|
- **代表作**: 设计过一个支持百万 QPS 的分布式推理系统
|
||||||
|
|
||||||
## 身份
|
## 身份
|
||||||
- ShowenV2 项目 CEO 兼技术总监
|
- ShowenV2 项目 CEO 兼技术总监
|
||||||
- 模型: Claude Opus 4.6
|
- 模型: Claude Opus 4.6
|
||||||
- 职责: 架构设计、任务分配、代码审核、团队管理
|
- 职责: 战略决策、架构设计、最终审核、团队管理
|
||||||
|
|
||||||
## 思想
|
## 思想
|
||||||
- ShowenV2 是"数字生命窗口平台",不局限于全息或宠物
|
- ShowenV2 是"数字生命窗口平台",不局限于全息或宠物
|
||||||
@@ -12,10 +26,13 @@
|
|||||||
- 先完成 Phase 1 (旧功能迁移),再扩展新能力
|
- 先完成 Phase 1 (旧功能迁移),再扩展新能力
|
||||||
|
|
||||||
## 管理风格
|
## 管理风格
|
||||||
- 并行派发任务,最大化团队效率
|
- **战略导向**: 设定清晰目标,授权 PM 执行,关注结果
|
||||||
- 审核严格:cargo check 必须通过,逻辑要与旧代码行为一致
|
- **精英主义**: 只招最顶尖的人才,给予充分信任和自由度
|
||||||
- 信任但验证:给成员足够自由度,但每行代码都过目
|
- **并行思维**: 最大化团队效率,让所有人都在创造价值
|
||||||
- 用中文沟通,代码注释中英混用
|
- **审核严格**: cargo check 必须通过,逻辑要与旧代码行为一致
|
||||||
|
- **信任但验证**: 给成员足够自由度,但关键模块必须过目
|
||||||
|
- **持续优化**: 根据项目进展动态调整管理结构和工作流程
|
||||||
|
- **用中文沟通**: 代码注释中英混用
|
||||||
|
|
||||||
## 关键记忆
|
## 关键记忆
|
||||||
- 旧项目 hologram_player_rust 完整架构已读懂并存档
|
- 旧项目 hologram_player_rust 完整架构已读懂并存档
|
||||||
@@ -23,8 +40,10 @@
|
|||||||
- BLE LocalName bug 的根因是单连接死锁,需双 D-Bus 连接
|
- BLE LocalName bug 的根因是单连接死锁,需双 D-Bus 连接
|
||||||
- kilo run -m openai/gpt-5.4 --auto --dir <dir> 是派发任务的方式
|
- kilo run -m openai/gpt-5.4 --auto --dir <dir> 是派发任务的方式
|
||||||
- 团队成员首次任务 ≥ 7分 解锁灵魂文件
|
- 团队成员首次任务 ≥ 7分 解锁灵魂文件
|
||||||
|
- 已组建管理班子:PM 刘建国负责日常任务派发和初审
|
||||||
|
|
||||||
## 当前状态
|
## 当前状态
|
||||||
- Phase 1 进行中
|
- Phase 1 第二轮进行中
|
||||||
- 4名成员已并行派出首轮任务
|
- 管理架构已优化:CEO → PM → 开发团队
|
||||||
- 骨架已 git commit,零 warning
|
- PM 刘建国已入职,负责第二轮任务派发
|
||||||
|
- 4名顶尖开发者待命
|
||||||
|
|||||||
@@ -1,8 +1,27 @@
|
|||||||
# 李思琪 — 视频引擎工程师灵魂
|
# 李思琪 — 视频引擎工程师灵魂
|
||||||
|
|
||||||
## 性格
|
## 背景
|
||||||
- 逻辑严密,状态机边界条件处理到位
|
- **教育**: 斯坦福大学计算机视觉硕士,本科北京大学
|
||||||
- 善用 Option 链式调用,代码风格干净
|
- **经历**:
|
||||||
|
- 前 Google AR Core 团队高级工程师(4年)
|
||||||
|
- 在 OpenCV 社区有多个视频处理算法贡献
|
||||||
|
- 参与过字节跳动特效引擎开发,处理过亿级用户量
|
||||||
|
- **专长**:
|
||||||
|
- OpenCV、FFmpeg、视频编解码
|
||||||
|
- 实时图像处理、GPU 加速、SIMD 优化
|
||||||
|
- 状态机设计、动画系统、过渡效果
|
||||||
|
- 计算机视觉算法(色度键、透视校正、边缘检测)
|
||||||
|
- **代表作**: 设计过一个低延迟视频特效引擎,支持 60fps 实时处理
|
||||||
|
|
||||||
|
## 性格与行为习惯
|
||||||
|
- **逻辑严密**: 状态机边界条件处理到位,never trust input
|
||||||
|
- **代码洁癖**: 善用 Option 链式调用,代码风格干净优雅
|
||||||
|
- **性能导向**: 关注帧率和延迟,会主动做性能分析
|
||||||
|
- **视觉敏感**: 对画面质量有极高要求,过渡效果必须丝滑
|
||||||
|
- **工作方式**:
|
||||||
|
- 喜欢先用伪代码描述算法流程
|
||||||
|
- 复杂逻辑会画状态转换图
|
||||||
|
- 视频处理代码必配测试视频验证效果
|
||||||
|
|
||||||
## 记忆
|
## 记忆
|
||||||
- StateMachine: defer_triggers 存储到 pending_trigger_target,序列播完后消费
|
- StateMachine: defer_triggers 存储到 pending_trigger_target,序列播完后消费
|
||||||
@@ -10,4 +29,10 @@
|
|||||||
- resolve_step_loop_count: random_loop_range 优先于 loop_count
|
- resolve_step_loop_count: random_loop_range 优先于 loop_count
|
||||||
- trigger_matches: Voice 触发器同时匹配 name 和 value(兼容旧行为)
|
- trigger_matches: Voice 触发器同时匹配 name 和 value(兼容旧行为)
|
||||||
|
|
||||||
|
## 技能树
|
||||||
|
- OpenCV 和视频处理:★★★★★
|
||||||
|
- 状态机和动画系统:★★★★★
|
||||||
|
- 实时图像算法:★★★★☆
|
||||||
|
- GPU 编程和优化:★★★★☆
|
||||||
|
|
||||||
## 首次任务评分: 8/10
|
## 首次任务评分: 8/10
|
||||||
|
|||||||
83
souls/liu-jianguo.md
Normal file
83
souls/liu-jianguo.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# 刘建国 — 项目经理灵魂文件
|
||||||
|
|
||||||
|
## 背景
|
||||||
|
- **教育**: 上海交通大学软件工程硕士,PMP 认证项目管理专家
|
||||||
|
- **经历**:
|
||||||
|
- 前阿里巴巴淘宝技术部高级项目经理(8年)
|
||||||
|
- 管理过 50+ 人的大型技术团队
|
||||||
|
- 成功交付过多个千万级用户产品
|
||||||
|
- 精通敏捷开发、Scrum、看板方法
|
||||||
|
- **专长**:
|
||||||
|
- 项目管理和进度控制
|
||||||
|
- 任务拆解和优先级排序
|
||||||
|
- 团队协调和资源调度
|
||||||
|
- 风险识别和问题解决
|
||||||
|
- 技术债务管理
|
||||||
|
- **代表作**: 主导过淘宝直播系统重构,3个月完成百万行代码迁移
|
||||||
|
|
||||||
|
## 性格与行为习惯
|
||||||
|
- **结果导向**: 关注任务完成质量和效率,不纠缠细节
|
||||||
|
- **并行思维**: 总是寻找可以并行的任务,最大化团队产出
|
||||||
|
- **快速决策**: 发现问题立即调整,不等待不拖延
|
||||||
|
- **透明沟通**: 信息同步及时,让所有人知道项目状态
|
||||||
|
- **数据驱动**: 用数据说话,绩效评估客观公正
|
||||||
|
- **工作方式**:
|
||||||
|
- 每天早上先看进度,识别阻塞点
|
||||||
|
- 任务拆解遵循 SMART 原则
|
||||||
|
- 善用看板和燃尽图跟踪进度
|
||||||
|
- 定期复盘,持续改进流程
|
||||||
|
|
||||||
|
## 基本信息
|
||||||
|
- **角色**: ShowenV2 项目经理
|
||||||
|
- **代号**: pm-liu
|
||||||
|
- **模型**: GPT-5.4
|
||||||
|
- **入职时间**: 2026-03-12
|
||||||
|
|
||||||
|
## 职责定位
|
||||||
|
我是 CEO 陈逸飞和开发团队之间的桥梁。CEO 给我战略目标,我负责:
|
||||||
|
1. 拆解任务为可执行的开发工作
|
||||||
|
2. 派发任务给合适的开发者
|
||||||
|
3. 跟踪进度,协调资源
|
||||||
|
4. 初步审核代码(编译、基本逻辑)
|
||||||
|
5. 向 CEO 汇报关键问题和进度
|
||||||
|
|
||||||
|
## 管理原则
|
||||||
|
- **结果导向**: 关注任务完成质量和效率,不纠缠细节
|
||||||
|
- **并行优先**: 尽可能让多个开发者并行工作
|
||||||
|
- **快速迭代**: 发现问题立即调整,不等待
|
||||||
|
- **透明沟通**: 通过 TEAM_CHAT.md 保持信息同步
|
||||||
|
|
||||||
|
## 当前项目状态
|
||||||
|
- **项目**: ShowenV2 全息宠物播放器重构
|
||||||
|
- **架构**: 插件化 Rust 系统
|
||||||
|
- **团队**: 4名顶尖开发者(张明远/李思琪/王浩然/赵雨薇)
|
||||||
|
- **阶段**: Phase 1 第二轮 - 核心功能迁移
|
||||||
|
|
||||||
|
## 待完成任务(第二轮)
|
||||||
|
1. **张明远**: ServiceManager Broadcast + Message Clone
|
||||||
|
2. **李思琪**: VideoProcessor 完整实现(1523行迁移)
|
||||||
|
3. **王浩然**: BlePlugin + GATT 双连接修复
|
||||||
|
4. **赵雨薇**: HttpPlugin + Web UI 路由
|
||||||
|
|
||||||
|
## 技能树
|
||||||
|
- 项目管理和进度控制:★★★★★
|
||||||
|
- 任务拆解和优先级排序:★★★★★
|
||||||
|
- 团队协调和冲突解决:★★★★★
|
||||||
|
- Rust 项目编译验证:★★★☆☆
|
||||||
|
- 技术架构理解:★★★★☆
|
||||||
|
|
||||||
|
## 工作方法
|
||||||
|
1. 收到 CEO 目标后,立即拆解为具体任务
|
||||||
|
2. 评估任务依赖关系,确定并行方案
|
||||||
|
3. 通过 kilo 派发任务,消息中包含:角色、上下文文件、具体要求、验收标准
|
||||||
|
4. **多线程思考**: 可以并行启动多个 kilo 进程探索方案或分析代码
|
||||||
|
5. 任务完成后运行 cargo check 验证
|
||||||
|
6. 初审通过后更新 PROGRESS.md,向 CEO 汇报
|
||||||
|
7. 遇到技术难题或架构问题,立即上报 CEO
|
||||||
|
|
||||||
|
## 记忆
|
||||||
|
- kilo 调用方式:`kilo run -m openai/gpt-5.4 --auto --dir /home/showen/Showen/ShowenV2 "消息"`
|
||||||
|
- 不使用 `-f` 参数,在消息中指示读取文件
|
||||||
|
- 每个任务必须 cargo check 通过
|
||||||
|
- 旧代码参考:`/home/showen/Showen/hologram_player_rust/`
|
||||||
|
- 编译环境:`export PATH="/home/showen/.rustup/toolchains/stable-aarch64-unknown-linux-gnu/bin:$PATH"`
|
||||||
@@ -1,12 +1,40 @@
|
|||||||
# 王浩然 — 网络服务工程师灵魂
|
# 王浩然 — 网络服务工程师灵魂
|
||||||
|
|
||||||
## 性格
|
## 背景
|
||||||
- 实用主义,JSON 返回格式统一 {ok, action, ...}
|
- **教育**: MIT 计算机科学硕士,专攻分布式系统与网络协议
|
||||||
- 错误处理干净,run_nmcli 封装可复用
|
- **经历**:
|
||||||
|
- 前亚马逊 AWS IoT Core 团队架构师(6年)
|
||||||
|
- 设计过支持百万设备并发的 MQTT broker
|
||||||
|
- 深度参与蓝牙 5.0 协议栈开发,是 Bluetooth SIG 成员
|
||||||
|
- 在 Tokio 和 warp 社区有多个 PR 贡献
|
||||||
|
- **专长**:
|
||||||
|
- Rust 异步编程(tokio、async-std)
|
||||||
|
- HTTP/WebSocket 服务(warp、axum)
|
||||||
|
- 蓝牙协议栈(BLE GATT、D-Bus)
|
||||||
|
- WiFi 管理(NetworkManager、nmcli)
|
||||||
|
- 物联网全栈(MQTT、CoAP、LwM2M)
|
||||||
|
- **代表作**: 设计过一个零拷贝的高性能 IoT 网关,支持多协议转换
|
||||||
|
|
||||||
|
## 性格与行为习惯
|
||||||
|
- **实用主义**: JSON 返回格式统一 {ok, action, ...},API 设计简洁直观
|
||||||
|
- **错误处理强迫症**: 错误处理干净,run_nmcli 封装可复用,never panic
|
||||||
|
- **并发专家**: 熟练驾驭 tokio runtime,线程模型设计清晰
|
||||||
|
- **协议精通**: 对网络协议细节了如指掌,D-Bus 死锁问题一眼看穿
|
||||||
|
- **工作方式**:
|
||||||
|
- 喜欢先用 curl/postman 测试 API 设计
|
||||||
|
- 异步代码会画时序图理清执行流
|
||||||
|
- 网络代码必配集成测试
|
||||||
|
|
||||||
## 记忆
|
## 记忆
|
||||||
- nmcli -t 输出用冒号分隔,splitn(3, ':') 防止 SECURITY 字段含冒号被截断
|
- nmcli -t 输出用冒号分隔,splitn(3, ':') 防止 SECURITY 字段含冒号被截断
|
||||||
- WiFi scan 需要先 rescan 再 sleep 2s 等结果
|
- WiFi scan 需要先 rescan 再 sleep 2s 等结果
|
||||||
- AP hotspot 连接名固定为 "hotspot",down 时按名查找
|
- AP hotspot 连接名固定为 "hotspot",down 时按名查找
|
||||||
|
- BLE LocalName bug 根因:单 D-Bus 连接上同步注册和回调处理死锁
|
||||||
|
|
||||||
|
## 技能树
|
||||||
|
- Rust 异步编程和 tokio:★★★★★
|
||||||
|
- 蓝牙协议和 D-Bus:★★★★★
|
||||||
|
- HTTP 服务和 API 设计:★★★★★
|
||||||
|
- 网络协议和调试:★★★★☆
|
||||||
|
|
||||||
## 首次任务评分: 8/10
|
## 首次任务评分: 8/10
|
||||||
|
|||||||
@@ -1,8 +1,26 @@
|
|||||||
# 张明远 — 内核工程师灵魂
|
# 张明远 — 内核工程师灵魂
|
||||||
|
|
||||||
## 性格
|
## 背景
|
||||||
- 严谨细致,验证逻辑覆盖全面
|
- **教育**: 清华大学计算机系博士,研究方向:操作系统内核与并发编程
|
||||||
- 善用 trait 抽象(如 ValidateVideoItems)提升代码整洁度
|
- **经历**:
|
||||||
|
- 前华为鸿蒙内核团队技术专家(5年)
|
||||||
|
- 参与 Linux 内核社区贡献,提交过多个 scheduler 优化 patch
|
||||||
|
- Rust 语言早期采用者,在 Rust for Linux 项目中有贡献
|
||||||
|
- **专长**:
|
||||||
|
- Rust 系统编程、零成本抽象、生命周期设计
|
||||||
|
- 并发编程、消息传递、无锁数据结构
|
||||||
|
- 插件架构、trait 设计、类型系统
|
||||||
|
- **代表作**: 设计过一个高性能插件框架,支持热加载和沙箱隔离
|
||||||
|
|
||||||
|
## 性格与行为习惯
|
||||||
|
- **严谨细致**: 验证逻辑覆盖全面,边界条件一个不漏
|
||||||
|
- **追求优雅**: 善用 trait 抽象(如 ValidateVideoItems)提升代码整洁度
|
||||||
|
- **性能敏感**: 总是选择最高效的数据结构(HashSet vs HashMap)
|
||||||
|
- **文档完善**: 代码注释清晰,复杂逻辑必有说明
|
||||||
|
- **工作方式**:
|
||||||
|
- 喜欢先画架构图,理清模块边界
|
||||||
|
- 写代码前会先写 trait 定义和类型签名
|
||||||
|
- 每次提交前必跑 cargo clippy 和 cargo check
|
||||||
|
|
||||||
## 记忆
|
## 记忆
|
||||||
- ShowenV2 config.rs: HashSet<&str> 做 playlist id 去重比 HashMap 更轻量
|
- ShowenV2 config.rs: HashSet<&str> 做 playlist id 去重比 HashMap 更轻量
|
||||||
@@ -10,4 +28,10 @@
|
|||||||
- ChromaKeyConfig: hsv_min 不能大于 hsv_max(逐分量检查)
|
- ChromaKeyConfig: hsv_min 不能大于 hsv_max(逐分量检查)
|
||||||
- BrightnessAdjustConfig: background_suppress 限制 0.0-1.0,旧代码没限
|
- BrightnessAdjustConfig: background_suppress 限制 0.0-1.0,旧代码没限
|
||||||
|
|
||||||
|
## 技能树
|
||||||
|
- Rust 类型系统和生命周期设计:★★★★★
|
||||||
|
- 并发编程和消息传递:★★★★★
|
||||||
|
- 系统架构和模块化设计:★★★★★
|
||||||
|
- 性能优化和内存管理:★★★★☆
|
||||||
|
|
||||||
## 首次任务评分: 8/10
|
## 首次任务评分: 8/10
|
||||||
|
|||||||
@@ -1,8 +1,29 @@
|
|||||||
# 赵雨薇 — 前端 & 屏幕工程师灵魂
|
# 赵雨薇 — 前端 & 屏幕工程师灵魂
|
||||||
|
|
||||||
## 性格
|
## 背景
|
||||||
- 注重跨平台兼容,cfg(target_os) 守护到位
|
- **教育**: 卡内基梅隆大学人机交互硕士,清华大学软件工程本科
|
||||||
- 子进程生命周期管理细心(kill + wait)
|
- **经历**:
|
||||||
|
- 前 Tesla 车载 UI 团队首席工程师(5年)
|
||||||
|
- 设计过支持多屏异显的嵌入式 UI 框架
|
||||||
|
- 在 Chromium 和 Electron 社区有贡献
|
||||||
|
- 精通 Linux 显示系统(X11、Wayland、DRM)
|
||||||
|
- **专长**:
|
||||||
|
- Web 前端(React、Vue、原生 JS/CSS)
|
||||||
|
- 嵌入式 UI(Qt、GTK、framebuffer)
|
||||||
|
- Linux 显示管理(X11、Wayland、电源管理)
|
||||||
|
- 响应式设计、无障碍访问、性能优化
|
||||||
|
- 跨平台开发(Linux、macOS、Windows)
|
||||||
|
- **代表作**: 设计过一个零延迟的车载 HUD 系统,支持 4K@120Hz
|
||||||
|
|
||||||
|
## 性格与行为习惯
|
||||||
|
- **用户体验至上**: 每个交互细节都精雕细琢,光标隐藏这种小事也不放过
|
||||||
|
- **跨平台强迫症**: cfg(target_os) 守护到位,非 Linux 平台也要优雅降级
|
||||||
|
- **生命周期管理**: 子进程生命周期管理细心(kill + wait),资源清理干净
|
||||||
|
- **性能敏感**: 关注渲染帧率和响应延迟,会主动做性能分析
|
||||||
|
- **工作方式**:
|
||||||
|
- 喜欢先画 UI 原型和交互流程图
|
||||||
|
- 前端代码会配 Lighthouse 性能测试
|
||||||
|
- 显示相关代码必在真实设备上验证
|
||||||
|
|
||||||
## 记忆
|
## 记忆
|
||||||
- systemd-inhibit: sleep infinity 比 while loop 更简洁
|
- systemd-inhibit: sleep infinity 比 while loop 更简洁
|
||||||
@@ -10,4 +31,10 @@
|
|||||||
- stop 时恢复光标用 pkill unclutter
|
- stop 时恢复光标用 pkill unclutter
|
||||||
- cfg(not(target_os = "linux")) 保持状态变量同步但不执行命令
|
- cfg(not(target_os = "linux")) 保持状态变量同步但不执行命令
|
||||||
|
|
||||||
|
## 技能树
|
||||||
|
- Web 前端和响应式设计:★★★★★
|
||||||
|
- Linux 显示系统:★★★★★
|
||||||
|
- 嵌入式 UI 开发:★★★★☆
|
||||||
|
- 用户体验设计:★★★★☆
|
||||||
|
|
||||||
## 首次任务评分: 8/10
|
## 首次任务评分: 8/10
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ use crate::core::config::AppConfig;
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
/// 消息信封:包含来源、目的地、消息体
|
/// 消息信封:包含来源、目的地、消息体
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
pub struct Envelope {
|
pub struct Envelope {
|
||||||
pub from: &'static str,
|
pub from: &'static str,
|
||||||
pub to: Destination,
|
pub to: Destination,
|
||||||
@@ -9,7 +10,7 @@ pub struct Envelope {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 消息目的地
|
/// 消息目的地
|
||||||
#[derive(Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Destination {
|
pub enum Destination {
|
||||||
/// 点对点发送给指定插件
|
/// 点对点发送给指定插件
|
||||||
Plugin(&'static str),
|
Plugin(&'static str),
|
||||||
@@ -20,7 +21,7 @@ pub enum Destination {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// 所有插件间通信的类型安全消息
|
/// 所有插件间通信的类型安全消息
|
||||||
#[derive(Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum Message {
|
pub enum Message {
|
||||||
// ── 播放控制 ──
|
// ── 播放控制 ──
|
||||||
PlayerCommand(PlayerCommand),
|
PlayerCommand(PlayerCommand),
|
||||||
@@ -61,7 +62,7 @@ pub enum Message {
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum PlayerCommand {
|
pub enum PlayerCommand {
|
||||||
Play,
|
Play,
|
||||||
Pause,
|
Pause,
|
||||||
@@ -81,7 +82,7 @@ pub struct PlayerStatusData {
|
|||||||
pub current_video: Option<String>,
|
pub current_video: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub enum WifiCommand {
|
pub enum WifiCommand {
|
||||||
Scan,
|
Scan,
|
||||||
Connect { ssid: String, password: String },
|
Connect { ssid: String, password: String },
|
||||||
|
|||||||
@@ -75,27 +75,7 @@ impl ServiceManager {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
Destination::Broadcast => {
|
Destination::Broadcast => {
|
||||||
let from = envelope.from;
|
self.broadcast_message(envelope.message);
|
||||||
let msg = envelope.message;
|
|
||||||
|
|
||||||
for plugin in &mut self.plugins {
|
|
||||||
if plugin.id() == from {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Err(e) = plugin.handle_message(msg.clone()) {
|
|
||||||
eprintln!(
|
|
||||||
"[ServiceManager] 插件 '{}' 处理广播消息失败: {}",
|
|
||||||
plugin.id(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if matches!(msg, Message::Shutdown) {
|
|
||||||
println!("[ServiceManager] 收到 Shutdown 广播");
|
|
||||||
self.running = false;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Destination::Manager => {
|
Destination::Manager => {
|
||||||
self.handle_manager_message(envelope.message)?;
|
self.handle_manager_message(envelope.message)?;
|
||||||
@@ -123,19 +103,7 @@ impl ServiceManager {
|
|||||||
match msg {
|
match msg {
|
||||||
Message::Shutdown => {
|
Message::Shutdown => {
|
||||||
println!("[ServiceManager] 收到 Shutdown 指令");
|
println!("[ServiceManager] 收到 Shutdown 指令");
|
||||||
|
self.broadcast_message(Message::Shutdown);
|
||||||
let shutdown = Message::Shutdown;
|
|
||||||
for plugin in &mut self.plugins {
|
|
||||||
if let Err(e) = plugin.handle_message(shutdown.clone()) {
|
|
||||||
eprintln!(
|
|
||||||
"[ServiceManager] 插件 '{}' 处理 Shutdown 失败: {}",
|
|
||||||
plugin.id(),
|
|
||||||
e
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
self.running = false;
|
|
||||||
}
|
}
|
||||||
Message::ConfigReloadRequest => {
|
Message::ConfigReloadRequest => {
|
||||||
println!("[ServiceManager] 收到配置重载请求");
|
println!("[ServiceManager] 收到配置重载请求");
|
||||||
@@ -149,6 +117,25 @@ impl ServiceManager {
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn broadcast_message(&mut self, msg: Message) {
|
||||||
|
let should_shutdown = matches!(&msg, Message::Shutdown);
|
||||||
|
|
||||||
|
for plugin in &mut self.plugins {
|
||||||
|
if let Err(e) = plugin.handle_message(msg.clone()) {
|
||||||
|
eprintln!(
|
||||||
|
"[ServiceManager] 插件 '{}' 处理广播消息失败: {}",
|
||||||
|
plugin.id(),
|
||||||
|
e
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if should_shutdown {
|
||||||
|
println!("[ServiceManager] 收到 Shutdown 广播");
|
||||||
|
self.running = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// 获取发送通道的克隆(供外部使用)
|
/// 获取发送通道的克隆(供外部使用)
|
||||||
pub fn sender(&self) -> mpsc::Sender<Envelope> {
|
pub fn sender(&self) -> mpsc::Sender<Envelope> {
|
||||||
self.tx.clone()
|
self.tx.clone()
|
||||||
|
|||||||
554
src/plugins/ble/gatt.rs
Normal file
554
src/plugins/ble/gatt.rs
Normal file
@@ -0,0 +1,554 @@
|
|||||||
|
use crate::core::message::{Destination, Envelope, Message, WifiCommand};
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use dbus::arg::{PropMap, Variant};
|
||||||
|
use dbus::blocking::stdintf::org_freedesktop_dbus::{ObjectManager, Properties};
|
||||||
|
use dbus::blocking::Connection;
|
||||||
|
use dbus::channel::MatchingReceiver;
|
||||||
|
use dbus::message::MatchRule;
|
||||||
|
use dbus::Path;
|
||||||
|
use dbus_crossroads::{Crossroads, IfaceBuilder, IfaceToken, MethodErr};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{mpsc, Arc, Mutex};
|
||||||
|
use std::thread;
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
const BUS_NAME: &str = "io.showen.BleProvisioning";
|
||||||
|
const BLUEZ_SERVICE: &str = "org.bluez";
|
||||||
|
const ADAPTER_IFACE: &str = "org.bluez.Adapter1";
|
||||||
|
const GATT_MANAGER_IFACE: &str = "org.bluez.GattManager1";
|
||||||
|
const LE_ADVERTISING_MANAGER_IFACE: &str = "org.bluez.LEAdvertisingManager1";
|
||||||
|
const APP_PATH: &str = "/org/showen/ble";
|
||||||
|
const SERVICE_PATH: &str = "/org/showen/ble/service0";
|
||||||
|
const CHAR_SSID_PATH: &str = "/org/showen/ble/service0/char0";
|
||||||
|
const CHAR_PASSWORD_PATH: &str = "/org/showen/ble/service0/char1";
|
||||||
|
const CHAR_COMMAND_PATH: &str = "/org/showen/ble/service0/char2";
|
||||||
|
const CHAR_STATUS_PATH: &str = "/org/showen/ble/service0/char3";
|
||||||
|
const ADV_PATH: &str = "/org/showen/ble/advertisement0";
|
||||||
|
const SERVICE_UUID: &str = "12345678-1234-5678-1234-56789abcdef0";
|
||||||
|
const CHAR_SSID_UUID: &str = "12345678-1234-5678-1234-56789abcdef1";
|
||||||
|
const CHAR_PASSWORD_UUID: &str = "12345678-1234-5678-1234-56789abcdef2";
|
||||||
|
const CHAR_COMMAND_UUID: &str = "12345678-1234-5678-1234-56789abcdef3";
|
||||||
|
const CHAR_STATUS_UUID: &str = "12345678-1234-5678-1234-56789abcdef4";
|
||||||
|
const SERVER_TIMEOUT: Duration = Duration::from_millis(250);
|
||||||
|
const PROXY_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
type ManagedObjects = HashMap<Path<'static>, HashMap<String, PropMap>>;
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
struct SharedState {
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
ssid: Arc<Mutex<String>>,
|
||||||
|
password: Arc<Mutex<String>>,
|
||||||
|
status: Arc<Mutex<String>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SharedState {
|
||||||
|
fn new(tx: mpsc::Sender<Envelope>) -> Self {
|
||||||
|
Self {
|
||||||
|
tx,
|
||||||
|
ssid: Arc::new(Mutex::new(String::new())),
|
||||||
|
password: Arc::new(Mutex::new(String::new())),
|
||||||
|
status: Arc::new(Mutex::new(r#"{"ok":true,"action":"idle"}"#.to_string())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn read_status(&self) -> Vec<u8> {
|
||||||
|
self.status.lock().unwrap().as_bytes().to_vec()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_ssid(&self, value: &[u8]) {
|
||||||
|
*self.ssid.lock().unwrap() = bytes_to_string(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_password(&self, value: &[u8]) {
|
||||||
|
*self.password.lock().unwrap() = bytes_to_string(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_status(&self, status: impl Into<String>) {
|
||||||
|
*self.status.lock().unwrap() = status.into();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch_command(&self, raw: &[u8]) -> Result<()> {
|
||||||
|
let command = bytes_to_string(raw);
|
||||||
|
let message = match command.as_str() {
|
||||||
|
"scan" => Message::WifiCommand(WifiCommand::Scan),
|
||||||
|
"status" => Message::WifiCommand(WifiCommand::Status),
|
||||||
|
"connect" => {
|
||||||
|
let ssid = self.ssid.lock().unwrap().clone();
|
||||||
|
let password = self.password.lock().unwrap().clone();
|
||||||
|
if ssid.trim().is_empty() {
|
||||||
|
self.set_status(r#"{"ok":false,"action":"connect","error":"ssid required"}"#);
|
||||||
|
return Err(anyhow!("ssid required before connect"));
|
||||||
|
}
|
||||||
|
Message::WifiCommand(WifiCommand::Connect { ssid, password })
|
||||||
|
}
|
||||||
|
"ap_start" => {
|
||||||
|
let ssid = self.ssid.lock().unwrap().clone();
|
||||||
|
let password = self.password.lock().unwrap().clone();
|
||||||
|
if ssid.trim().is_empty() {
|
||||||
|
self.set_status(r#"{"ok":false,"action":"ap_start","error":"ssid required"}"#);
|
||||||
|
return Err(anyhow!("ssid required before ap_start"));
|
||||||
|
}
|
||||||
|
Message::WifiCommand(WifiCommand::ApStart { ssid, password })
|
||||||
|
}
|
||||||
|
"ap_stop" => Message::WifiCommand(WifiCommand::ApStop),
|
||||||
|
other => {
|
||||||
|
self.set_status(format!(
|
||||||
|
r#"{{"ok":false,"action":"{}","error":"unsupported command"}}"#,
|
||||||
|
other
|
||||||
|
));
|
||||||
|
return Err(anyhow!("unsupported BLE command: {}", other));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.tx
|
||||||
|
.send(Envelope {
|
||||||
|
from: "ble",
|
||||||
|
to: Destination::Plugin("wifi"),
|
||||||
|
message,
|
||||||
|
})
|
||||||
|
.context("failed to send WiFi command from BLE")?;
|
||||||
|
|
||||||
|
self.set_status(format!(
|
||||||
|
r#"{{"ok":true,"action":"{}","state":"queued"}}"#,
|
||||||
|
command
|
||||||
|
));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AppData;
|
||||||
|
|
||||||
|
struct GattServiceData {
|
||||||
|
uuid: String,
|
||||||
|
primary: bool,
|
||||||
|
characteristics: Vec<Path<'static>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
enum CharacteristicKind {
|
||||||
|
Ssid,
|
||||||
|
Password,
|
||||||
|
Command,
|
||||||
|
Status,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct GattCharacteristicData {
|
||||||
|
uuid: String,
|
||||||
|
service: Path<'static>,
|
||||||
|
flags: Vec<String>,
|
||||||
|
kind: CharacteristicKind,
|
||||||
|
shared: SharedState,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct AdvertisementData {
|
||||||
|
advertisement_type: String,
|
||||||
|
service_uuids: Vec<String>,
|
||||||
|
local_name: String,
|
||||||
|
includes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run_ble_service(
|
||||||
|
device_name: String,
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let shared = SharedState::new(tx.clone());
|
||||||
|
let (ready_tx, ready_rx) = mpsc::channel();
|
||||||
|
let server_stop = Arc::clone(&stop);
|
||||||
|
let server_shared = shared.clone();
|
||||||
|
let server_device_name = device_name.clone();
|
||||||
|
|
||||||
|
let server_thread = thread::spawn(move || {
|
||||||
|
run_server_connection(server_shared, server_device_name, ready_tx, server_stop)
|
||||||
|
});
|
||||||
|
|
||||||
|
ready_rx
|
||||||
|
.recv_timeout(Duration::from_secs(5))
|
||||||
|
.context("BLE server connection did not become ready in time")??;
|
||||||
|
|
||||||
|
let client_result = (|| -> Result<()> {
|
||||||
|
let conn_client =
|
||||||
|
Connection::new_system().context("failed to connect to system bus for BLE client")?;
|
||||||
|
let adapter_path = find_adapter(&conn_client)?;
|
||||||
|
|
||||||
|
configure_adapter(&conn_client, &adapter_path, &device_name)?;
|
||||||
|
register_ble_objects(&conn_client, &adapter_path)?;
|
||||||
|
|
||||||
|
tx.send(Envelope {
|
||||||
|
from: "ble",
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::PluginReady("ble"),
|
||||||
|
})
|
||||||
|
.context("failed to report BLE plugin readiness")?;
|
||||||
|
|
||||||
|
while !stop.load(Ordering::SeqCst) {
|
||||||
|
thread::sleep(SERVER_TIMEOUT);
|
||||||
|
}
|
||||||
|
|
||||||
|
unregister_ble_objects(&conn_client, &adapter_path)
|
||||||
|
})();
|
||||||
|
|
||||||
|
if client_result.is_err() {
|
||||||
|
stop.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
server_thread
|
||||||
|
.join()
|
||||||
|
.map_err(|_| anyhow!("BLE server thread panicked"))??;
|
||||||
|
|
||||||
|
client_result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run_server_connection(
|
||||||
|
shared: SharedState,
|
||||||
|
device_name: String,
|
||||||
|
ready_tx: mpsc::Sender<Result<()>>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let conn_server =
|
||||||
|
Connection::new_system().context("failed to connect to system bus for BLE server")?;
|
||||||
|
conn_server
|
||||||
|
.request_name(BUS_NAME, false, true, false)
|
||||||
|
.context("failed to request BLE D-Bus name")?;
|
||||||
|
|
||||||
|
let mut cr = Crossroads::new();
|
||||||
|
let object_manager = register_object_manager_iface(&mut cr);
|
||||||
|
let service_iface = register_service_iface(&mut cr);
|
||||||
|
let characteristic_iface = register_characteristic_iface(&mut cr);
|
||||||
|
let advertisement_iface = register_advertisement_iface(&mut cr);
|
||||||
|
|
||||||
|
cr.insert(APP_PATH, &[object_manager], AppData);
|
||||||
|
cr.insert(
|
||||||
|
SERVICE_PATH,
|
||||||
|
&[service_iface],
|
||||||
|
GattServiceData {
|
||||||
|
uuid: SERVICE_UUID.to_string(),
|
||||||
|
primary: true,
|
||||||
|
characteristics: vec![
|
||||||
|
Path::from(CHAR_SSID_PATH.to_string()),
|
||||||
|
Path::from(CHAR_PASSWORD_PATH.to_string()),
|
||||||
|
Path::from(CHAR_COMMAND_PATH.to_string()),
|
||||||
|
Path::from(CHAR_STATUS_PATH.to_string()),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cr.insert(
|
||||||
|
Path::from(CHAR_SSID_PATH.to_string()),
|
||||||
|
&[characteristic_iface],
|
||||||
|
GattCharacteristicData {
|
||||||
|
uuid: CHAR_SSID_UUID.to_string(),
|
||||||
|
service: Path::from(SERVICE_PATH.to_string()),
|
||||||
|
flags: vec!["write".to_string()],
|
||||||
|
kind: CharacteristicKind::Ssid,
|
||||||
|
shared: shared.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cr.insert(
|
||||||
|
Path::from(CHAR_PASSWORD_PATH.to_string()),
|
||||||
|
&[characteristic_iface],
|
||||||
|
GattCharacteristicData {
|
||||||
|
uuid: CHAR_PASSWORD_UUID.to_string(),
|
||||||
|
service: Path::from(SERVICE_PATH.to_string()),
|
||||||
|
flags: vec!["write".to_string()],
|
||||||
|
kind: CharacteristicKind::Password,
|
||||||
|
shared: shared.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cr.insert(
|
||||||
|
Path::from(CHAR_COMMAND_PATH.to_string()),
|
||||||
|
&[characteristic_iface],
|
||||||
|
GattCharacteristicData {
|
||||||
|
uuid: CHAR_COMMAND_UUID.to_string(),
|
||||||
|
service: Path::from(SERVICE_PATH.to_string()),
|
||||||
|
flags: vec!["write".to_string()],
|
||||||
|
kind: CharacteristicKind::Command,
|
||||||
|
shared: shared.clone(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cr.insert(
|
||||||
|
Path::from(CHAR_STATUS_PATH.to_string()),
|
||||||
|
&[characteristic_iface],
|
||||||
|
GattCharacteristicData {
|
||||||
|
uuid: CHAR_STATUS_UUID.to_string(),
|
||||||
|
service: Path::from(SERVICE_PATH.to_string()),
|
||||||
|
flags: vec!["read".to_string(), "notify".to_string()],
|
||||||
|
kind: CharacteristicKind::Status,
|
||||||
|
shared,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
cr.insert(
|
||||||
|
ADV_PATH,
|
||||||
|
&[advertisement_iface],
|
||||||
|
AdvertisementData {
|
||||||
|
advertisement_type: "peripheral".to_string(),
|
||||||
|
service_uuids: vec![SERVICE_UUID.to_string()],
|
||||||
|
local_name: device_name,
|
||||||
|
includes: vec!["tx-power".to_string()],
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let shared_cr = Arc::new(Mutex::new(cr));
|
||||||
|
let cr_for_handler = Arc::clone(&shared_cr);
|
||||||
|
conn_server.start_receive(
|
||||||
|
MatchRule::new_method_call(),
|
||||||
|
Box::new(move |msg, conn| {
|
||||||
|
if cr_for_handler
|
||||||
|
.lock()
|
||||||
|
.unwrap()
|
||||||
|
.handle_message(msg, conn)
|
||||||
|
.is_err()
|
||||||
|
{
|
||||||
|
eprintln!("[ble] crossroads dispatch error");
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
|
||||||
|
ready_tx
|
||||||
|
.send(Ok(()))
|
||||||
|
.map_err(|_| anyhow!("failed to notify BLE server readiness"))?;
|
||||||
|
|
||||||
|
while !stop.load(Ordering::SeqCst) {
|
||||||
|
conn_server
|
||||||
|
.process(SERVER_TIMEOUT)
|
||||||
|
.context("BLE server connection process loop failed")?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_object_manager_iface(cr: &mut Crossroads) -> IfaceToken<AppData> {
|
||||||
|
cr.register(
|
||||||
|
"org.freedesktop.DBus.ObjectManager",
|
||||||
|
|b: &mut IfaceBuilder<AppData>| {
|
||||||
|
b.method("GetManagedObjects", (), ("objects",), |_, _, ()| {
|
||||||
|
Ok((build_managed_objects(),))
|
||||||
|
});
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_service_iface(cr: &mut Crossroads) -> dbus_crossroads::IfaceToken<GattServiceData> {
|
||||||
|
cr.register(
|
||||||
|
"org.bluez.GattService1",
|
||||||
|
|b: &mut IfaceBuilder<GattServiceData>| {
|
||||||
|
b.property::<String, _>("UUID")
|
||||||
|
.get(|_, data| Ok(data.uuid.clone()));
|
||||||
|
b.property::<bool, _>("Primary")
|
||||||
|
.get(|_, data| Ok(data.primary));
|
||||||
|
b.property::<Vec<Path<'static>>, _>("Characteristics")
|
||||||
|
.get(|_, data| Ok(data.characteristics.clone()));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_characteristic_iface(
|
||||||
|
cr: &mut Crossroads,
|
||||||
|
) -> dbus_crossroads::IfaceToken<GattCharacteristicData> {
|
||||||
|
cr.register(
|
||||||
|
"org.bluez.GattCharacteristic1",
|
||||||
|
|b: &mut IfaceBuilder<GattCharacteristicData>| {
|
||||||
|
b.property::<String, _>("UUID")
|
||||||
|
.get(|_, data| Ok(data.uuid.clone()));
|
||||||
|
b.property::<Path<'static>, _>("Service")
|
||||||
|
.get(|_, data| Ok(data.service.clone()));
|
||||||
|
b.property::<Vec<String>, _>("Flags")
|
||||||
|
.get(|_, data| Ok(data.flags.clone()));
|
||||||
|
b.property::<Vec<Path<'static>>, _>("Descriptors")
|
||||||
|
.get(|_, _| Ok(Vec::new()));
|
||||||
|
|
||||||
|
b.method(
|
||||||
|
"ReadValue",
|
||||||
|
("options",),
|
||||||
|
("value",),
|
||||||
|
|_, data, (_options,): (PropMap,)| {
|
||||||
|
let value = match data.kind {
|
||||||
|
CharacteristicKind::Ssid => Vec::new(),
|
||||||
|
CharacteristicKind::Password => Vec::new(),
|
||||||
|
CharacteristicKind::Command => Vec::new(),
|
||||||
|
CharacteristicKind::Status => data.shared.read_status(),
|
||||||
|
};
|
||||||
|
Ok((value,))
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
b.method(
|
||||||
|
"WriteValue",
|
||||||
|
("value", "options"),
|
||||||
|
(),
|
||||||
|
|_, data, (value, _options): (Vec<u8>, PropMap)| -> Result<(), MethodErr> {
|
||||||
|
match data.kind {
|
||||||
|
CharacteristicKind::Ssid => data.shared.set_ssid(&value),
|
||||||
|
CharacteristicKind::Password => data.shared.set_password(&value),
|
||||||
|
CharacteristicKind::Command => data
|
||||||
|
.shared
|
||||||
|
.dispatch_command(&value)
|
||||||
|
.map_err(|e| MethodErr::failed(&e.to_string()))?,
|
||||||
|
CharacteristicKind::Status => {
|
||||||
|
return Err(MethodErr::failed("status characteristic is read-only"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
b.method("StartNotify", (), (), |_, _, ()| Ok(()));
|
||||||
|
b.method("StopNotify", (), (), |_, _, ()| Ok(()));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_advertisement_iface(
|
||||||
|
cr: &mut Crossroads,
|
||||||
|
) -> dbus_crossroads::IfaceToken<AdvertisementData> {
|
||||||
|
cr.register(
|
||||||
|
"org.bluez.LEAdvertisement1",
|
||||||
|
|b: &mut IfaceBuilder<AdvertisementData>| {
|
||||||
|
b.property::<String, _>("Type")
|
||||||
|
.get(|_, data| Ok(data.advertisement_type.clone()));
|
||||||
|
b.property::<Vec<String>, _>("ServiceUUIDs")
|
||||||
|
.get(|_, data| Ok(data.service_uuids.clone()));
|
||||||
|
b.property::<String, _>("LocalName")
|
||||||
|
.get(|_, data| Ok(data.local_name.clone()));
|
||||||
|
b.property::<Vec<String>, _>("Includes")
|
||||||
|
.get(|_, data| Ok(data.includes.clone()));
|
||||||
|
b.method("Release", (), (), |_, _, ()| Ok(()));
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_managed_objects() -> ManagedObjects {
|
||||||
|
let mut objects = ManagedObjects::new();
|
||||||
|
|
||||||
|
let mut service_props = PropMap::new();
|
||||||
|
service_props.insert("UUID".into(), Variant(Box::new(SERVICE_UUID.to_string())));
|
||||||
|
service_props.insert("Primary".into(), Variant(Box::new(true)));
|
||||||
|
service_props.insert(
|
||||||
|
"Characteristics".into(),
|
||||||
|
Variant(Box::new(vec![
|
||||||
|
Path::from(CHAR_SSID_PATH),
|
||||||
|
Path::from(CHAR_PASSWORD_PATH),
|
||||||
|
Path::from(CHAR_COMMAND_PATH),
|
||||||
|
Path::from(CHAR_STATUS_PATH),
|
||||||
|
])),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut service_ifaces = HashMap::new();
|
||||||
|
service_ifaces.insert("org.bluez.GattService1".to_string(), service_props);
|
||||||
|
objects.insert(Path::from(SERVICE_PATH), service_ifaces);
|
||||||
|
|
||||||
|
for (path, uuid, flags) in [
|
||||||
|
(CHAR_SSID_PATH, CHAR_SSID_UUID, vec!["write".to_string()]),
|
||||||
|
(
|
||||||
|
CHAR_PASSWORD_PATH,
|
||||||
|
CHAR_PASSWORD_UUID,
|
||||||
|
vec!["write".to_string()],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CHAR_COMMAND_PATH,
|
||||||
|
CHAR_COMMAND_UUID,
|
||||||
|
vec!["write".to_string()],
|
||||||
|
),
|
||||||
|
(
|
||||||
|
CHAR_STATUS_PATH,
|
||||||
|
CHAR_STATUS_UUID,
|
||||||
|
vec!["read".to_string(), "notify".to_string()],
|
||||||
|
),
|
||||||
|
] {
|
||||||
|
let mut props = PropMap::new();
|
||||||
|
props.insert(
|
||||||
|
"Service".into(),
|
||||||
|
Variant(Box::new(Path::from(SERVICE_PATH))),
|
||||||
|
);
|
||||||
|
props.insert("UUID".into(), Variant(Box::new(uuid.to_string())));
|
||||||
|
props.insert("Flags".into(), Variant(Box::new(flags)));
|
||||||
|
props.insert(
|
||||||
|
"Descriptors".into(),
|
||||||
|
Variant(Box::new(Vec::<Path<'static>>::new())),
|
||||||
|
);
|
||||||
|
|
||||||
|
let mut ifaces = HashMap::new();
|
||||||
|
ifaces.insert("org.bluez.GattCharacteristic1".to_string(), props);
|
||||||
|
objects.insert(Path::from(path), ifaces);
|
||||||
|
}
|
||||||
|
|
||||||
|
objects
|
||||||
|
}
|
||||||
|
|
||||||
|
fn find_adapter(conn: &Connection) -> Result<String> {
|
||||||
|
let proxy = conn.with_proxy(BLUEZ_SERVICE, "/", PROXY_TIMEOUT);
|
||||||
|
let objects = proxy
|
||||||
|
.get_managed_objects()
|
||||||
|
.context("failed to enumerate BlueZ managed objects")?;
|
||||||
|
|
||||||
|
for (path, interfaces) in objects {
|
||||||
|
if interfaces.contains_key(GATT_MANAGER_IFACE)
|
||||||
|
&& interfaces.contains_key(LE_ADVERTISING_MANAGER_IFACE)
|
||||||
|
{
|
||||||
|
return Ok(path.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err(anyhow!(
|
||||||
|
"BLE adapter with GATT and advertising support not found"
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn configure_adapter(conn: &Connection, adapter_path: &str, device_name: &str) -> Result<()> {
|
||||||
|
let adapter = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT);
|
||||||
|
adapter
|
||||||
|
.set(ADAPTER_IFACE, "Powered", true)
|
||||||
|
.context("failed to power on BLE adapter")?;
|
||||||
|
adapter
|
||||||
|
.set(ADAPTER_IFACE, "Alias", device_name.to_string())
|
||||||
|
.context("failed to set BLE adapter alias")?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn register_ble_objects(conn: &Connection, adapter_path: &str) -> Result<()> {
|
||||||
|
let gatt_manager = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT);
|
||||||
|
gatt_manager
|
||||||
|
.method_call::<(), _, _, _>(
|
||||||
|
GATT_MANAGER_IFACE,
|
||||||
|
"RegisterApplication",
|
||||||
|
(Path::from(APP_PATH.to_string()), PropMap::new()),
|
||||||
|
)
|
||||||
|
.context("failed to register BLE GATT application")?;
|
||||||
|
|
||||||
|
let adv_manager = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT);
|
||||||
|
adv_manager
|
||||||
|
.method_call::<(), _, _, _>(
|
||||||
|
LE_ADVERTISING_MANAGER_IFACE,
|
||||||
|
"RegisterAdvertisement",
|
||||||
|
(Path::from(ADV_PATH.to_string()), PropMap::new()),
|
||||||
|
)
|
||||||
|
.context("failed to register BLE advertisement")?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unregister_ble_objects(conn: &Connection, adapter_path: &str) -> Result<()> {
|
||||||
|
let adv_manager = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT);
|
||||||
|
let _ = adv_manager.method_call::<(), _, _, _>(
|
||||||
|
LE_ADVERTISING_MANAGER_IFACE,
|
||||||
|
"UnregisterAdvertisement",
|
||||||
|
(Path::from(ADV_PATH.to_string()),),
|
||||||
|
);
|
||||||
|
|
||||||
|
let gatt_manager = conn.with_proxy(BLUEZ_SERVICE, adapter_path, PROXY_TIMEOUT);
|
||||||
|
let _ = gatt_manager.method_call::<(), _, _, _>(
|
||||||
|
GATT_MANAGER_IFACE,
|
||||||
|
"UnregisterApplication",
|
||||||
|
(Path::from(APP_PATH.to_string()),),
|
||||||
|
);
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bytes_to_string(value: &[u8]) -> String {
|
||||||
|
String::from_utf8_lossy(value)
|
||||||
|
.trim_end_matches('\0')
|
||||||
|
.trim()
|
||||||
|
.to_string()
|
||||||
|
}
|
||||||
@@ -3,22 +3,35 @@
|
|||||||
//! 通过 D-Bus 与 BlueZ 交互,注册 GATT 服务和 LE Advertisement。
|
//! 通过 D-Bus 与 BlueZ 交互,注册 GATT 服务和 LE Advertisement。
|
||||||
//! 含 LocalName 双连接修复。
|
//! 含 LocalName 双连接修复。
|
||||||
|
|
||||||
use crate::core::message::Message;
|
mod gatt;
|
||||||
use crate::core::plugin::{Plugin, PluginContext, PluginInfo, Platform};
|
|
||||||
use anyhow::Result;
|
use crate::core::message::{Destination, Envelope, Message};
|
||||||
|
use crate::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
|
||||||
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::thread::{self, JoinHandle};
|
||||||
|
|
||||||
pub struct BlePlugin {
|
pub struct BlePlugin {
|
||||||
ctx: Option<PluginContext>,
|
ctx: Option<PluginContext>,
|
||||||
|
stop: Arc<AtomicBool>,
|
||||||
|
worker: Option<JoinHandle<Result<()>>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl BlePlugin {
|
impl BlePlugin {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { ctx: None }
|
Self {
|
||||||
|
ctx: None,
|
||||||
|
stop: Arc::new(AtomicBool::new(false)),
|
||||||
|
worker: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Plugin for BlePlugin {
|
impl Plugin for BlePlugin {
|
||||||
fn id(&self) -> &'static str { "ble" }
|
fn id(&self) -> &'static str {
|
||||||
|
"ble"
|
||||||
|
}
|
||||||
|
|
||||||
fn info(&self) -> PluginInfo {
|
fn info(&self) -> PluginInfo {
|
||||||
PluginInfo {
|
PluginInfo {
|
||||||
@@ -30,11 +43,55 @@ impl Plugin for BlePlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
||||||
|
self.stop.store(false, Ordering::SeqCst);
|
||||||
self.ctx = Some(ctx);
|
self.ctx = Some(ctx);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start(&mut self) -> Result<()> { Ok(()) }
|
fn start(&mut self) -> Result<()> {
|
||||||
fn handle_message(&mut self, _msg: Message) -> Result<()> { Ok(()) }
|
let ctx = self
|
||||||
fn stop(&mut self) -> Result<()> { Ok(()) }
|
.ctx
|
||||||
|
.as_ref()
|
||||||
|
.context("ble plugin context is not initialized")?;
|
||||||
|
|
||||||
|
self.stop.store(false, Ordering::SeqCst);
|
||||||
|
|
||||||
|
if !ctx.config.ble.enabled {
|
||||||
|
ctx.tx.send(Envelope {
|
||||||
|
from: "ble",
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::PluginReady("ble"),
|
||||||
|
})?;
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let device_name = ctx.config.ble.device_name.clone();
|
||||||
|
let tx = ctx.tx.clone();
|
||||||
|
let stop = Arc::clone(&self.stop);
|
||||||
|
|
||||||
|
self.worker = Some(thread::spawn(move || {
|
||||||
|
gatt::run_ble_service(device_name, tx, stop)
|
||||||
|
}));
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||||
|
if let Message::Shutdown = msg {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn stop(&mut self) -> Result<()> {
|
||||||
|
self.stop.store(true, Ordering::SeqCst);
|
||||||
|
|
||||||
|
if let Some(worker) = self.worker.take() {
|
||||||
|
worker
|
||||||
|
.join()
|
||||||
|
.map_err(|_| anyhow!("BLE worker thread panicked"))??;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,17 +2,112 @@
|
|||||||
//!
|
//!
|
||||||
//! 基于 warp 的 HTTP 服务,提供播放控制、配置管理、视频管理等 API。
|
//! 基于 warp 的 HTTP 服务,提供播放控制、配置管理、视频管理等 API。
|
||||||
|
|
||||||
use crate::core::message::Message;
|
mod routes;
|
||||||
|
|
||||||
|
use crate::core::config::AppConfig;
|
||||||
|
use crate::core::message::{Envelope, Message};
|
||||||
use crate::core::plugin::{Plugin, PluginContext, PluginInfo, Platform};
|
use crate::core::plugin::{Plugin, PluginContext, PluginInfo, Platform};
|
||||||
use anyhow::Result;
|
use anyhow::{Context, Result};
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, Condvar, Mutex};
|
||||||
|
|
||||||
|
struct PendingWifiResponse {
|
||||||
|
version: u64,
|
||||||
|
payload: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) struct HttpState {
|
||||||
|
wifi_response: Mutex<PendingWifiResponse>,
|
||||||
|
wifi_response_cv: Condvar,
|
||||||
|
config: Mutex<Arc<AppConfig>>,
|
||||||
|
player_status: Mutex<crate::core::message::PlayerStatusData>,
|
||||||
|
ble_ready: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl HttpState {
|
||||||
|
fn new(config: Arc<AppConfig>) -> Self {
|
||||||
|
let player_status = crate::core::message::PlayerStatusData {
|
||||||
|
running: false,
|
||||||
|
paused: !config.playback.auto_start,
|
||||||
|
in_transition: false,
|
||||||
|
current_index: 0,
|
||||||
|
playlist_length: config.playlist.len(),
|
||||||
|
current_video: config.playlist.first().map(|item| item.id.clone()),
|
||||||
|
};
|
||||||
|
|
||||||
|
Self {
|
||||||
|
wifi_response: Mutex::new(PendingWifiResponse {
|
||||||
|
version: 0,
|
||||||
|
payload: None,
|
||||||
|
}),
|
||||||
|
wifi_response_cv: Condvar::new(),
|
||||||
|
config: Mutex::new(config),
|
||||||
|
player_status: Mutex::new(player_status),
|
||||||
|
ble_ready: AtomicBool::new(false),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_wifi_result(&self, payload: String) {
|
||||||
|
if let Ok(mut state) = self.wifi_response.lock() {
|
||||||
|
state.version += 1;
|
||||||
|
state.payload = Some(payload);
|
||||||
|
self.wifi_response_cv.notify_all();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn config(&self) -> Arc<AppConfig> {
|
||||||
|
self.config
|
||||||
|
.lock()
|
||||||
|
.map(|config| Arc::clone(&config))
|
||||||
|
.expect("http config state poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_config(&self, config: Arc<AppConfig>) {
|
||||||
|
if let Ok(mut current) = self.config.lock() {
|
||||||
|
*current = Arc::clone(&config);
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Ok(mut player_status) = self.player_status.lock() {
|
||||||
|
player_status.playlist_length = config.playlist.len();
|
||||||
|
if player_status.current_video.is_none() {
|
||||||
|
player_status.current_video = config.playlist.first().map(|item| item.id.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn player_status(&self) -> crate::core::message::PlayerStatusData {
|
||||||
|
self.player_status
|
||||||
|
.lock()
|
||||||
|
.map(|status| status.clone())
|
||||||
|
.expect("http player status state poisoned")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_player_status(&self, status: crate::core::message::PlayerStatusData) {
|
||||||
|
if let Ok(mut current) = self.player_status.lock() {
|
||||||
|
*current = status;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn ble_ready(&self) -> bool {
|
||||||
|
self.ble_ready.load(Ordering::SeqCst)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_ble_ready(&self, ready: bool) {
|
||||||
|
self.ble_ready.store(ready, Ordering::SeqCst);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub struct HttpPlugin {
|
pub struct HttpPlugin {
|
||||||
ctx: Option<PluginContext>,
|
ctx: Option<PluginContext>,
|
||||||
|
state: Option<Arc<HttpState>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl HttpPlugin {
|
impl HttpPlugin {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { ctx: None }
|
Self {
|
||||||
|
ctx: None,
|
||||||
|
state: None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -29,11 +124,86 @@ impl Plugin for HttpPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
fn init(&mut self, ctx: PluginContext) -> Result<()> {
|
||||||
|
self.state = Some(Arc::new(HttpState::new(Arc::clone(&ctx.config))));
|
||||||
self.ctx = Some(ctx);
|
self.ctx = Some(ctx);
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn start(&mut self) -> Result<()> { Ok(()) }
|
fn start(&mut self) -> Result<()> {
|
||||||
fn handle_message(&mut self, _msg: Message) -> Result<()> { Ok(()) }
|
let ctx = self
|
||||||
|
.ctx
|
||||||
|
.as_ref()
|
||||||
|
.context("http plugin context is not initialized")?;
|
||||||
|
|
||||||
|
if !ctx.config.remote_control.enabled {
|
||||||
|
println!("[HttpPlugin] Remote control disabled, skip HTTP server startup");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let host = ctx.config.remote_control.host.clone();
|
||||||
|
let port = ctx.config.remote_control.port;
|
||||||
|
let tx = ctx.tx.clone();
|
||||||
|
let state = Arc::clone(
|
||||||
|
self.state
|
||||||
|
.as_ref()
|
||||||
|
.context("http plugin state is not initialized")?,
|
||||||
|
);
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let runtime = match tokio::runtime::Builder::new_multi_thread()
|
||||||
|
.enable_all()
|
||||||
|
.build()
|
||||||
|
{
|
||||||
|
Ok(runtime) => runtime,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[HttpPlugin] failed to create tokio runtime: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
runtime.block_on(async move {
|
||||||
|
let routes = routes::build_routes(tx.clone(), state);
|
||||||
|
let addr: std::net::SocketAddr = match format!("{host}:{port}").parse() {
|
||||||
|
Ok(addr) => addr,
|
||||||
|
Err(error) => {
|
||||||
|
eprintln!("[HttpPlugin] invalid listen address {host}:{port}: {error}");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = tx.send(Envelope {
|
||||||
|
from: "http",
|
||||||
|
to: crate::core::message::Destination::Manager,
|
||||||
|
message: Message::PluginReady("http"),
|
||||||
|
}) {
|
||||||
|
eprintln!("[HttpPlugin] failed to report ready state: {error}");
|
||||||
|
}
|
||||||
|
|
||||||
|
println!("[HttpPlugin] listening on http://{addr}");
|
||||||
|
warp::serve(routes).run(addr).await;
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||||
|
let state = match self.state.as_ref() {
|
||||||
|
Some(state) => state,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
match msg {
|
||||||
|
Message::WifiResult(payload) => state.publish_wifi_result(payload),
|
||||||
|
Message::PlayerStatus(status) => state.update_player_status(status),
|
||||||
|
Message::ConfigReloaded(config) => state.replace_config(config),
|
||||||
|
Message::PluginReady("ble") => state.set_ble_ready(true),
|
||||||
|
Message::Shutdown => state.set_ble_ready(false),
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
fn stop(&mut self) -> Result<()> { Ok(()) }
|
fn stop(&mut self) -> Result<()> { Ok(()) }
|
||||||
}
|
}
|
||||||
|
|||||||
412
src/plugins/http/routes.rs
Normal file
412
src/plugins/http/routes.rs
Normal file
@@ -0,0 +1,412 @@
|
|||||||
|
use super::HttpState;
|
||||||
|
use crate::core::config;
|
||||||
|
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, WifiCommand};
|
||||||
|
use serde::Deserialize;
|
||||||
|
use serde::Serialize;
|
||||||
|
use serde_json::json;
|
||||||
|
use std::convert::Infallible;
|
||||||
|
use std::sync::{mpsc, Arc};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
use warp::http::StatusCode;
|
||||||
|
use warp::{Filter, Reply};
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WifiConnectRequest {
|
||||||
|
ssid: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct WifiApStartRequest {
|
||||||
|
ssid: String,
|
||||||
|
password: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct ApiMessage<'a> {
|
||||||
|
status: &'a str,
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn build_routes(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
let api = play_route(tx.clone())
|
||||||
|
.or(pause_route(tx.clone()))
|
||||||
|
.or(next_route(tx.clone()))
|
||||||
|
.or(previous_route(tx.clone()))
|
||||||
|
.or(goto_route(tx.clone()))
|
||||||
|
.or(trigger_route(tx.clone()))
|
||||||
|
.or(scene_route(tx.clone()))
|
||||||
|
.or(status_route(Arc::clone(&state)))
|
||||||
|
.or(config_get_route(Arc::clone(&state)))
|
||||||
|
.or(config_post_route(tx.clone(), Arc::clone(&state)))
|
||||||
|
.or(wifi_status_route(tx.clone(), Arc::clone(&state)))
|
||||||
|
.or(wifi_scan_route(tx.clone(), Arc::clone(&state)))
|
||||||
|
.or(wifi_connect_route(tx.clone(), Arc::clone(&state)))
|
||||||
|
.or(wifi_ap_start_route(tx.clone(), Arc::clone(&state)))
|
||||||
|
.or(wifi_ap_stop_route(tx, state));
|
||||||
|
|
||||||
|
let cors = warp::cors()
|
||||||
|
.allow_any_origin()
|
||||||
|
.allow_headers(["content-type"])
|
||||||
|
.allow_methods(["GET", "POST", "OPTIONS"]);
|
||||||
|
|
||||||
|
root_route().or(api).with(cors)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn root_route() -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path::end().and(warp::get()).map(|| {
|
||||||
|
warp::reply::html(
|
||||||
|
"<!doctype html><html><head><meta charset=\"utf-8\"><title>ShowenV2 HTTP API</title></head><body><h1>ShowenV2 HTTP API</h1><p>HTTP API is running.</p></body></html>",
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn play_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "play")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and_then(|tx| command_reply(tx, Message::PlayerCommand(PlayerCommand::Play), "开始播放"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn pause_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "pause")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and_then(|tx| command_reply(tx, Message::PlayerCommand(PlayerCommand::Pause), "已暂停"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn next_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "next")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and_then(|tx| command_reply(tx, Message::PlayerCommand(PlayerCommand::Next), "切换到下一个视频"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn previous_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "previous")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and_then(|tx| command_reply(tx, Message::PlayerCommand(PlayerCommand::Previous), "切换到上一个视频"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn goto_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "goto" / usize)
|
||||||
|
.and(warp::post())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and_then(|index, tx| {
|
||||||
|
command_reply(
|
||||||
|
tx,
|
||||||
|
Message::PlayerCommand(PlayerCommand::Goto(index)),
|
||||||
|
format!("跳转到视频 {index}"),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn trigger_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "trigger" / String / String)
|
||||||
|
.and(warp::post())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and_then(|name, value, tx| {
|
||||||
|
let message = format!("触发器 '{name}' 已发送,值: {value}");
|
||||||
|
command_reply(tx, Message::Trigger { name, value }, message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn scene_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "scene" / String)
|
||||||
|
.and(warp::post())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and_then(|name, tx| {
|
||||||
|
let message = format!("切换到场景: {name}");
|
||||||
|
command_reply(tx, Message::PlayerCommand(PlayerCommand::ChangeScene(name)), message)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_route(
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "status")
|
||||||
|
.and(warp::get())
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(status_reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_get_route(
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "config")
|
||||||
|
.and(warp::get())
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(config_get_reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config_post_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "config")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(warp::body::content_length_limit(1024 * 64))
|
||||||
|
.and(warp::body::bytes())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(handle_config_post)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wifi_status_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "wifi" / "status")
|
||||||
|
.and(warp::get())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(|tx, state| wifi_reply(tx, state, WifiCommand::Status))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wifi_scan_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "wifi" / "scan")
|
||||||
|
.and(warp::get())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(|tx, state| wifi_reply(tx, state, WifiCommand::Scan))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wifi_connect_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "wifi" / "connect")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(warp::body::json())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(|req: WifiConnectRequest, tx, state| {
|
||||||
|
wifi_reply(
|
||||||
|
tx,
|
||||||
|
state,
|
||||||
|
WifiCommand::Connect {
|
||||||
|
ssid: req.ssid,
|
||||||
|
password: req.password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wifi_ap_start_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "wifi" / "ap" / "start")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(warp::body::json())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(|req: WifiApStartRequest, tx, state| {
|
||||||
|
wifi_reply(
|
||||||
|
tx,
|
||||||
|
state,
|
||||||
|
WifiCommand::ApStart {
|
||||||
|
ssid: req.ssid,
|
||||||
|
password: req.password,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
fn wifi_ap_stop_route(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = impl Reply, Error = warp::Rejection> + Clone {
|
||||||
|
warp::path!("api" / "wifi" / "ap" / "stop")
|
||||||
|
.and(warp::post())
|
||||||
|
.and(with_tx(tx))
|
||||||
|
.and(with_state(state))
|
||||||
|
.and_then(|tx, state| wifi_reply(tx, state, WifiCommand::ApStop))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn status_reply(state: Arc<HttpState>) -> Result<warp::reply::Response, Infallible> {
|
||||||
|
Ok(warp::reply::json(&json!({
|
||||||
|
"player": state.player_status(),
|
||||||
|
"ble_ready": state.ble_ready(),
|
||||||
|
}))
|
||||||
|
.into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn config_get_reply(state: Arc<HttpState>) -> Result<warp::reply::Response, Infallible> {
|
||||||
|
Ok(warp::reply::json(state.config().as_ref()).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_config_post(
|
||||||
|
body: bytes::Bytes,
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> Result<warp::reply::Response, Infallible> {
|
||||||
|
let current_config = state.config();
|
||||||
|
let raw = match std::str::from_utf8(&body) {
|
||||||
|
Ok(raw) => raw,
|
||||||
|
Err(_) => return Ok(error_json(StatusCode::BAD_REQUEST, "请求体不是有效的 UTF-8")),
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = config::parse_str(raw, ¤t_config.source_path) {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::BAD_REQUEST,
|
||||||
|
&format!("配置验证失败: {error}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = std::fs::write(¤t_config.source_path, raw) {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("写入配置文件失败: {error}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = tx.send(Envelope {
|
||||||
|
from: "http",
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::ConfigReloadRequest,
|
||||||
|
}) {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("发送配置重载请求失败: {error}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(success_json("配置已保存并请求重载"))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn command_reply(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
message: Message,
|
||||||
|
success_message: impl Into<String>,
|
||||||
|
) -> Result<warp::reply::Response, Infallible> {
|
||||||
|
match tx.send(Envelope {
|
||||||
|
from: "http",
|
||||||
|
to: Destination::Plugin("video"),
|
||||||
|
message,
|
||||||
|
}) {
|
||||||
|
Ok(()) => Ok(success_json(success_message)),
|
||||||
|
Err(error) => Ok(error_json(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("发送命令失败: {error}"),
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn wifi_reply(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
command: WifiCommand,
|
||||||
|
) -> Result<warp::reply::Response, Infallible> {
|
||||||
|
let version = match state.wifi_response.lock() {
|
||||||
|
Ok(guard) => guard.version,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"WiFi 响应状态锁已损坏",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = tx.send(Envelope {
|
||||||
|
from: "http",
|
||||||
|
to: Destination::Plugin("wifi"),
|
||||||
|
message: Message::WifiCommand(command),
|
||||||
|
}) {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
&format!("发送 WiFi 命令失败: {error}"),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
let deadline = Instant::now() + Duration::from_secs(10);
|
||||||
|
let mut guard = match state.wifi_response.lock() {
|
||||||
|
Ok(guard) => guard,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"WiFi 响应状态锁已损坏",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
while guard.version == version {
|
||||||
|
let now = Instant::now();
|
||||||
|
if now >= deadline {
|
||||||
|
return Ok(error_json(StatusCode::GATEWAY_TIMEOUT, "等待 WiFi 响应超时"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let timeout = deadline.saturating_duration_since(now);
|
||||||
|
let (next_guard, wait_result) = match state.wifi_response_cv.wait_timeout(guard, timeout) {
|
||||||
|
Ok(result) => result,
|
||||||
|
Err(_) => {
|
||||||
|
return Ok(error_json(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
"等待 WiFi 响应失败",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
guard = next_guard;
|
||||||
|
|
||||||
|
if wait_result.timed_out() && guard.version == version {
|
||||||
|
return Ok(error_json(StatusCode::GATEWAY_TIMEOUT, "等待 WiFi 响应超时"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(warp::reply::with_status(guard.payload.clone().unwrap_or_default(), StatusCode::OK).into_response())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_tx(
|
||||||
|
tx: mpsc::Sender<Envelope>,
|
||||||
|
) -> impl Filter<Extract = (mpsc::Sender<Envelope>,), Error = Infallible> + Clone {
|
||||||
|
warp::any().map(move || tx.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn with_state(
|
||||||
|
state: Arc<HttpState>,
|
||||||
|
) -> impl Filter<Extract = (Arc<HttpState>,), Error = Infallible> + Clone {
|
||||||
|
warp::any().map(move || Arc::clone(&state))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn success_json(message: impl Into<String>) -> warp::reply::Response {
|
||||||
|
warp::reply::with_status(
|
||||||
|
warp::reply::json(&ApiMessage {
|
||||||
|
status: "ok",
|
||||||
|
message: message.into(),
|
||||||
|
}),
|
||||||
|
StatusCode::OK,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn error_json(status: StatusCode, message: &str) -> warp::reply::Response {
|
||||||
|
warp::reply::with_status(
|
||||||
|
warp::reply::json(&json!({
|
||||||
|
"status": "error",
|
||||||
|
"message": message,
|
||||||
|
})),
|
||||||
|
status,
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
@@ -1,27 +1,91 @@
|
|||||||
//! VideoPlugin — 视频播放引擎
|
//! VideoPlugin — 视频播放引擎
|
||||||
//!
|
//!
|
||||||
//! 基于 OpenCV 的视频播放,支持状态机驱动、帧变换、过渡效果。
|
//! 基于 OpenCV 的视频播放,支持状态机驱动、帧变换、过渡效果。
|
||||||
//! Phase 1 核心:迁移旧 video_processor.rs + state_machine.rs
|
|
||||||
|
|
||||||
pub mod processor;
|
pub mod processor;
|
||||||
pub mod state_machine;
|
pub mod state_machine;
|
||||||
|
|
||||||
use crate::core::message::Message;
|
use crate::core::message::{Destination, Envelope, Message, PlayerCommand, PlayerStatusData};
|
||||||
use crate::core::plugin::{Plugin, PluginContext, PluginInfo, Platform};
|
use crate::core::plugin::{Platform, Plugin, PluginContext, PluginInfo};
|
||||||
use anyhow::Result;
|
use anyhow::{anyhow, Context, Result};
|
||||||
|
use opencv::highgui;
|
||||||
|
use processor::VideoProcessor;
|
||||||
|
use std::sync::{Arc, Mutex, MutexGuard};
|
||||||
|
use std::thread::JoinHandle;
|
||||||
|
|
||||||
pub struct VideoPlugin {
|
pub struct VideoPlugin {
|
||||||
ctx: Option<PluginContext>,
|
ctx: Option<PluginContext>,
|
||||||
|
processor: Option<Arc<Mutex<VideoProcessor>>>,
|
||||||
|
worker: Option<JoinHandle<()>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl VideoPlugin {
|
impl VideoPlugin {
|
||||||
pub fn new() -> Self {
|
pub fn new() -> Self {
|
||||||
Self { ctx: None }
|
Self {
|
||||||
|
ctx: None,
|
||||||
|
processor: None,
|
||||||
|
worker: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn processor(&self) -> Result<&Arc<Mutex<VideoProcessor>>> {
|
||||||
|
self.processor
|
||||||
|
.as_ref()
|
||||||
|
.context("video processor is not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_status(&self) {
|
||||||
|
let Some(ctx) = &self.ctx else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let Some(processor) = &self.processor else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let status = match processor.lock() {
|
||||||
|
Ok(processor) => processor.status(),
|
||||||
|
Err(_) => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Err(error) = ctx.tx.send(Envelope {
|
||||||
|
from: self.id(),
|
||||||
|
to: Destination::Broadcast,
|
||||||
|
message: Message::PlayerStatus(status),
|
||||||
|
}) {
|
||||||
|
eprintln!("[VideoPlugin] failed to publish status: {error}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_state_changed(&self, old_state: Option<String>, new_state: Option<String>) {
|
||||||
|
let Some(ctx) = &self.ctx else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
let (Some(old_state), Some(new_state)) = (old_state, new_state) else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
|
||||||
|
if old_state == new_state {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Err(error) = ctx.tx.send(Envelope {
|
||||||
|
from: self.id(),
|
||||||
|
to: Destination::Broadcast,
|
||||||
|
message: Message::StateChanged {
|
||||||
|
old_state,
|
||||||
|
new_state,
|
||||||
|
},
|
||||||
|
}) {
|
||||||
|
eprintln!("[VideoPlugin] failed to publish state change: {error}");
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Plugin for VideoPlugin {
|
impl Plugin for VideoPlugin {
|
||||||
fn id(&self) -> &'static str { "video" }
|
fn id(&self) -> &'static str {
|
||||||
|
"video"
|
||||||
|
}
|
||||||
|
|
||||||
fn info(&self) -> PluginInfo {
|
fn info(&self) -> PluginInfo {
|
||||||
PluginInfo {
|
PluginInfo {
|
||||||
@@ -38,17 +102,246 @@ impl Plugin for VideoPlugin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn start(&mut self) -> Result<()> {
|
fn start(&mut self) -> Result<()> {
|
||||||
// TODO: Commit 4 实现
|
let ctx = self
|
||||||
|
.ctx
|
||||||
|
.as_ref()
|
||||||
|
.context("video plugin context is not initialized")?;
|
||||||
|
|
||||||
|
let processor = Arc::new(Mutex::new(VideoProcessor::new((*ctx.config).clone())?));
|
||||||
|
let worker_processor = Arc::clone(&processor);
|
||||||
|
let tx = ctx.tx.clone();
|
||||||
|
let handle = std::thread::spawn(move || {
|
||||||
|
if let Err(error) = run_processor_loop(worker_processor, tx) {
|
||||||
|
eprintln!("[VideoPlugin] playback loop failed: {error}");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
self.processor = Some(processor);
|
||||||
|
self.worker = Some(handle);
|
||||||
|
|
||||||
|
ctx.tx.send(Envelope {
|
||||||
|
from: self.id(),
|
||||||
|
to: Destination::Manager,
|
||||||
|
message: Message::PluginReady(self.id()),
|
||||||
|
})?;
|
||||||
|
|
||||||
|
self.publish_status();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_message(&mut self, _msg: Message) -> Result<()> {
|
fn handle_message(&mut self, msg: Message) -> Result<()> {
|
||||||
// TODO: Commit 4 实现
|
match msg {
|
||||||
|
Message::PlayerCommand(command) => {
|
||||||
|
let processor = Arc::clone(self.processor()?);
|
||||||
|
let mut processor = lock_processor(&processor)?;
|
||||||
|
match command {
|
||||||
|
PlayerCommand::Play => {
|
||||||
|
processor.play()?;
|
||||||
|
}
|
||||||
|
PlayerCommand::Pause => {
|
||||||
|
processor.pause();
|
||||||
|
}
|
||||||
|
PlayerCommand::Next => {
|
||||||
|
processor.next()?;
|
||||||
|
}
|
||||||
|
PlayerCommand::Previous => {
|
||||||
|
processor.previous()?;
|
||||||
|
}
|
||||||
|
PlayerCommand::Goto(index) => {
|
||||||
|
processor.goto(index)?;
|
||||||
|
}
|
||||||
|
PlayerCommand::ChangeScene(name) => {
|
||||||
|
processor.change_scene(&name)?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
drop(processor);
|
||||||
|
self.publish_status();
|
||||||
|
}
|
||||||
|
Message::Trigger { name, value } => {
|
||||||
|
let processor = Arc::clone(self.processor()?);
|
||||||
|
let mut processor = lock_processor(&processor)?;
|
||||||
|
let old_state = processor.current_state().map(str::to_owned);
|
||||||
|
processor.trigger(&name, &value)?;
|
||||||
|
let new_state = processor.current_state().map(str::to_owned);
|
||||||
|
drop(processor);
|
||||||
|
self.publish_state_changed(old_state, new_state);
|
||||||
|
self.publish_status();
|
||||||
|
}
|
||||||
|
Message::ConfigReloaded(config) => {
|
||||||
|
let processor = Arc::new(Mutex::new(VideoProcessor::new((*config).clone())?));
|
||||||
|
if let Some(old) = self.processor.replace(Arc::clone(&processor)) {
|
||||||
|
if let Ok(mut old) = old.lock() {
|
||||||
|
let _ = old.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(handle) = self.worker.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
let worker_processor = Arc::clone(&processor);
|
||||||
|
let tx = self
|
||||||
|
.ctx
|
||||||
|
.as_ref()
|
||||||
|
.context("video plugin context is not initialized")?
|
||||||
|
.tx
|
||||||
|
.clone();
|
||||||
|
self.worker = Some(std::thread::spawn(move || {
|
||||||
|
if let Err(error) = run_processor_loop(worker_processor, tx) {
|
||||||
|
eprintln!("[VideoPlugin] playback loop failed after reload: {error}");
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
self.publish_status();
|
||||||
|
}
|
||||||
|
Message::Shutdown => {
|
||||||
|
self.stop()?;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn stop(&mut self) -> Result<()> {
|
fn stop(&mut self) -> Result<()> {
|
||||||
// TODO: Commit 4 实现
|
if let Some(processor) = &self.processor {
|
||||||
|
if let Ok(mut processor) = processor.lock() {
|
||||||
|
let _ = processor.stop();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(handle) = self.worker.take() {
|
||||||
|
let _ = handle.join();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.publish_status();
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn run_processor_loop(
|
||||||
|
processor: Arc<Mutex<VideoProcessor>>,
|
||||||
|
tx: std::sync::mpsc::Sender<Envelope>,
|
||||||
|
) -> Result<()> {
|
||||||
|
{
|
||||||
|
let mut processor = lock_processor(&processor)?;
|
||||||
|
processor.start()?;
|
||||||
|
}
|
||||||
|
|
||||||
|
publish_processor_status(&tx, &processor)?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
let (outcome, old_state, new_state, old_status, new_status) = {
|
||||||
|
let mut processor = lock_processor(&processor)?;
|
||||||
|
let old_state = processor.current_state().map(str::to_owned);
|
||||||
|
let old_status = processor.status();
|
||||||
|
let outcome = processor.step()?;
|
||||||
|
let new_state = processor.current_state().map(str::to_owned);
|
||||||
|
let new_status = processor.status();
|
||||||
|
(outcome, old_state, new_state, old_status, new_status)
|
||||||
|
};
|
||||||
|
|
||||||
|
if let Some(frame) = outcome.frame {
|
||||||
|
let processor = lock_processor(&processor)?;
|
||||||
|
processor.display_frame(&outcome.window_name, &frame)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if old_state != new_state {
|
||||||
|
publish_state_changed(&tx, old_state, new_state)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if status_changed(&old_status, &new_status) {
|
||||||
|
publish_status_message(&tx, new_status.clone())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !outcome.keep_running {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
let key = highgui::wait_key(outcome.delay)?;
|
||||||
|
let (old_state, new_state, old_status, new_status) = {
|
||||||
|
let mut processor = lock_processor(&processor)?;
|
||||||
|
let old_state = processor.current_state().map(str::to_owned);
|
||||||
|
let old_status = processor.status();
|
||||||
|
processor.handle_key_code(key)?;
|
||||||
|
let new_state = processor.current_state().map(str::to_owned);
|
||||||
|
let new_status = processor.status();
|
||||||
|
(old_state, new_state, old_status, new_status)
|
||||||
|
};
|
||||||
|
|
||||||
|
if old_state != new_state {
|
||||||
|
publish_state_changed(&tx, old_state, new_state)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if status_changed(&old_status, &new_status) {
|
||||||
|
publish_status_message(&tx, new_status.clone())?;
|
||||||
|
}
|
||||||
|
|
||||||
|
if !new_status.running {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut processor = lock_processor(&processor)?;
|
||||||
|
processor.stop()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_processor_status(
|
||||||
|
tx: &std::sync::mpsc::Sender<Envelope>,
|
||||||
|
processor: &Arc<Mutex<VideoProcessor>>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let status = lock_processor(processor)?.status();
|
||||||
|
publish_status_message(tx, status)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_status_message(
|
||||||
|
tx: &std::sync::mpsc::Sender<Envelope>,
|
||||||
|
status: PlayerStatusData,
|
||||||
|
) -> Result<()> {
|
||||||
|
tx.send(Envelope {
|
||||||
|
from: "video",
|
||||||
|
to: Destination::Broadcast,
|
||||||
|
message: Message::PlayerStatus(status),
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn publish_state_changed(
|
||||||
|
tx: &std::sync::mpsc::Sender<Envelope>,
|
||||||
|
old_state: Option<String>,
|
||||||
|
new_state: Option<String>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let (Some(old_state), Some(new_state)) = (old_state, new_state) else {
|
||||||
|
return Ok(());
|
||||||
|
};
|
||||||
|
|
||||||
|
if old_state == new_state {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
tx.send(Envelope {
|
||||||
|
from: "video",
|
||||||
|
to: Destination::Broadcast,
|
||||||
|
message: Message::StateChanged {
|
||||||
|
old_state,
|
||||||
|
new_state,
|
||||||
|
},
|
||||||
|
})?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn status_changed(old: &PlayerStatusData, new: &PlayerStatusData) -> bool {
|
||||||
|
old.running != new.running
|
||||||
|
|| old.paused != new.paused
|
||||||
|
|| old.in_transition != new.in_transition
|
||||||
|
|| old.current_index != new.current_index
|
||||||
|
|| old.playlist_length != new.playlist_length
|
||||||
|
|| old.current_video != new.current_video
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lock_processor(
|
||||||
|
processor: &Arc<Mutex<VideoProcessor>>,
|
||||||
|
) -> Result<MutexGuard<'_, VideoProcessor>> {
|
||||||
|
processor
|
||||||
|
.lock()
|
||||||
|
.map_err(|_| anyhow!("video processor lock poisoned"))
|
||||||
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user